mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
css align
This commit is contained in:
19
sandbox/align.css
Normal file
19
sandbox/align.css
Normal file
@@ -0,0 +1,19 @@
|
||||
|
||||
Screen {
|
||||
layout: horizontal;
|
||||
|
||||
}
|
||||
|
||||
Widget#thing {
|
||||
width: 20;
|
||||
height: 10;
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
|
||||
Widget#thing2 {
|
||||
width: 30;
|
||||
height: 8;
|
||||
align: center middle;
|
||||
background: green;
|
||||
}
|
||||
17
sandbox/align.py
Normal file
17
sandbox/align.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from textual.app import App
|
||||
from textual.widget import Widget
|
||||
|
||||
|
||||
class AlignApp(App):
|
||||
def on_load(self):
|
||||
self.bind("t", "log_tree")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.log("MOUNTED")
|
||||
self.mount(thing=Widget(), thing2=Widget())
|
||||
|
||||
def action_log_tree(self):
|
||||
self.log(self.screen.tree)
|
||||
|
||||
|
||||
AlignApp.run(css_file="align.css", log="textual.log", watch_css=True)
|
||||
@@ -459,6 +459,7 @@ class App(DOMNode):
|
||||
Args:
|
||||
error (Exception): An exception instance.
|
||||
"""
|
||||
|
||||
if hasattr(error, "__rich__"):
|
||||
# Exception has a rich method, so we can defer to that for the rendering
|
||||
self.panic(error)
|
||||
@@ -489,15 +490,9 @@ class App(DOMNode):
|
||||
if os.getenv("TEXTUAL_DEVTOOLS") == "1":
|
||||
try:
|
||||
await self.devtools.connect()
|
||||
if self._log_console:
|
||||
self._log_console.print(
|
||||
f"Connected to devtools ({self.devtools.url})"
|
||||
)
|
||||
self.log(f"Connected to devtools ({self.devtools.url})")
|
||||
except DevtoolsConnectionError:
|
||||
if self._log_console:
|
||||
self._log_console.print(
|
||||
f"Couldn't connect to devtools ({self.devtools.url})"
|
||||
)
|
||||
self.log(f"Couldn't connect to devtools ({self.devtools.url})")
|
||||
try:
|
||||
if self.css_file is not None:
|
||||
self.stylesheet.read(self.css_file)
|
||||
@@ -529,14 +524,15 @@ class App(DOMNode):
|
||||
self.title = self._title
|
||||
self.refresh()
|
||||
await self.animator.start()
|
||||
|
||||
with redirect_stdout(StdoutRedirector(self.devtools, self._log_file)): # type: ignore
|
||||
await super().process_messages()
|
||||
log("Message processing stopped")
|
||||
with timer("animator.stop()"):
|
||||
await self.animator.stop()
|
||||
with timer("self.close_all()"):
|
||||
await self.close_all()
|
||||
await super().process_messages()
|
||||
log("PROCESS END")
|
||||
if self.devtools.is_connected:
|
||||
await self._disconnect_devtools()
|
||||
self.log(f"Disconnected from devtools ({self.devtools.url})")
|
||||
with timer("animator.stop()"):
|
||||
await self.animator.stop()
|
||||
with timer("self.close_all()"):
|
||||
await self.close_all()
|
||||
finally:
|
||||
driver.stop_application_mode()
|
||||
except Exception as error:
|
||||
@@ -545,12 +541,6 @@ class App(DOMNode):
|
||||
self._running = False
|
||||
if self._exit_renderables:
|
||||
self._print_error_renderables()
|
||||
if self.devtools.is_connected:
|
||||
await self._disconnect_devtools()
|
||||
if self._log_console is not None:
|
||||
self._log_console.print(
|
||||
f"Disconnected from devtools ({self.devtools.url})"
|
||||
)
|
||||
if self._log_file is not None:
|
||||
self._log_file.close()
|
||||
|
||||
|
||||
@@ -139,6 +139,7 @@ class Color(NamedTuple):
|
||||
@property
|
||||
def rich_color(self) -> RichColor:
|
||||
"""This color encoded in Rich's Color class."""
|
||||
# TODO: This isn't cheap as I'd like - cache in a LRUCache ?
|
||||
r, g, b, _a = self
|
||||
return RichColor.from_rgb(r, g, b)
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import rich.repr
|
||||
|
||||
from ._error_tools import friendly_list
|
||||
from .constants import (
|
||||
VALID_ALIGN_HORIZONTAL,
|
||||
VALID_ALIGN_VERTICAL,
|
||||
VALID_BORDER,
|
||||
VALID_BOX_SIZING,
|
||||
VALID_EDGE,
|
||||
@@ -600,3 +602,30 @@ class StylesBuilder:
|
||||
transitions[css_property] = Transition(duration, easing, delay)
|
||||
|
||||
self.styles._rules["transitions"] = transitions
|
||||
|
||||
def process_align(self, name: str, tokens: list[Token]) -> None:
|
||||
if len(tokens) != 2:
|
||||
self.error(name, tokens[0], "expected two tokens")
|
||||
token_horizontal = tokens[0]
|
||||
token_vertical = tokens[1]
|
||||
if token_horizontal.name != "token":
|
||||
self.error(
|
||||
name,
|
||||
token_horizontal,
|
||||
f"invalid token {token_horizontal!r}, expected {friendly_list(VALID_ALIGN_HORIZONTAL)}",
|
||||
)
|
||||
if token_vertical.name != "token":
|
||||
self.error(
|
||||
name,
|
||||
token_vertical,
|
||||
f"invalid token {token_vertical!r}, expected {friendly_list(VALID_ALIGN_VERTICAL)}",
|
||||
)
|
||||
|
||||
self.styles._rules["align_horizontal"] = token_horizontal.value
|
||||
self.styles._rules["align_vertical"] = token_vertical.value
|
||||
|
||||
def process_align_horizontal(self, name: str, tokens: list[Token]) -> None:
|
||||
self._process_enum(name, tokens, VALID_ALIGN_HORIZONTAL)
|
||||
|
||||
def process_align_vertical(self, name: str, tokens: list[Token]) -> None:
|
||||
self._process_enum(name, tokens, VALID_ALIGN_VERTICAL)
|
||||
|
||||
@@ -29,6 +29,6 @@ VALID_LAYOUT: Final = {"dock", "vertical", "grid"}
|
||||
|
||||
VALID_BOX_SIZING: Final = {"border-box", "content-box"}
|
||||
VALID_OVERFLOW: Final = {"scroll", "hidden", "auto"}
|
||||
|
||||
|
||||
VALID_ALIGN_HORIZONTAL: Final = {"left", "center", "right"}
|
||||
VALID_ALIGN_VERTICAL: Final = {"top", "middle", "bottom"}
|
||||
NULL_SPACING: Final = Spacing(0, 0, 0, 0)
|
||||
|
||||
@@ -34,6 +34,7 @@ class Unit(Enum):
|
||||
HEIGHT = 5
|
||||
VIEW_WIDTH = 6
|
||||
VIEW_HEIGHT = 7
|
||||
AUTO = 8
|
||||
|
||||
|
||||
UNIT_SYMBOL = {
|
||||
@@ -124,11 +125,14 @@ class Scalar(NamedTuple):
|
||||
Returns:
|
||||
Scalar: New scalar
|
||||
"""
|
||||
match = _MATCH_SCALAR(token)
|
||||
if match is None:
|
||||
raise ScalarParseError(f"{token!r} is not a valid scalar")
|
||||
value, unit_name = match.groups()
|
||||
scalar = cls(float(value), SYMBOL_UNIT[unit_name or ""], percent_unit)
|
||||
if token.lower() == "auto":
|
||||
scalar = cls(1.0, Unit.AUTO, Unit.AUTO)
|
||||
else:
|
||||
match = _MATCH_SCALAR(token)
|
||||
if match is None:
|
||||
raise ScalarParseError(f"{token!r} is not a valid scalar")
|
||||
value, unit_name = match.groups()
|
||||
scalar = cls(float(value), SYMBOL_UNIT[unit_name or ""], percent_unit)
|
||||
return scalar
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
|
||||
@@ -13,7 +13,7 @@ from rich.style import Style
|
||||
from .. import log
|
||||
from .._animator import Animation, EasingFunction
|
||||
from ..color import Color
|
||||
from ..geometry import Size, Spacing
|
||||
from ..geometry import Offset, Size, Spacing
|
||||
from ._style_properties import (
|
||||
BorderProperty,
|
||||
BoxProperty,
|
||||
@@ -32,17 +32,28 @@ from ._style_properties import (
|
||||
TransitionsProperty,
|
||||
FractionalProperty,
|
||||
)
|
||||
from .constants import VALID_BOX_SIZING, VALID_DISPLAY, VALID_VISIBILITY, VALID_OVERFLOW
|
||||
from .constants import (
|
||||
VALID_ALIGN_HORIZONTAL,
|
||||
VALID_ALIGN_VERTICAL,
|
||||
VALID_BOX_SIZING,
|
||||
VALID_DISPLAY,
|
||||
VALID_VISIBILITY,
|
||||
VALID_OVERFLOW,
|
||||
)
|
||||
from .scalar import Scalar, ScalarOffset, Unit
|
||||
from .scalar_animation import ScalarAnimation
|
||||
from .transition import Transition
|
||||
from .types import (
|
||||
BoxSizing,
|
||||
Display,
|
||||
AlignHorizontal,
|
||||
AlignVertical,
|
||||
Edge,
|
||||
AlignHorizontal,
|
||||
Overflow,
|
||||
Specificity3,
|
||||
Specificity4,
|
||||
AlignVertical,
|
||||
Visibility,
|
||||
)
|
||||
|
||||
@@ -116,6 +127,9 @@ class RulesMap(TypedDict, total=False):
|
||||
scrollbar_background_hover: Color
|
||||
scrollbar_background_active: Color
|
||||
|
||||
align_horizontal: AlignHorizontal
|
||||
align_vertical: AlignVertical
|
||||
|
||||
|
||||
RULE_NAMES = list(RulesMap.__annotations__.keys())
|
||||
_rule_getter = attrgetter(*RULE_NAMES)
|
||||
@@ -204,6 +218,9 @@ class StylesBase(ABC):
|
||||
scrollbar_background_hover = ColorProperty("#444444")
|
||||
scrollbar_background_active = ColorProperty("black")
|
||||
|
||||
align_horizontal = StringEnumProperty(VALID_ALIGN_HORIZONTAL, "left")
|
||||
align_vertical = StringEnumProperty(VALID_ALIGN_VERTICAL, "top")
|
||||
|
||||
def __eq__(self, styles: object) -> bool:
|
||||
"""Check that Styles containts the same rules."""
|
||||
if not isinstance(styles, StylesBase):
|
||||
@@ -410,6 +427,44 @@ class StylesBase(ABC):
|
||||
|
||||
return size, margin
|
||||
|
||||
def align_width(self, width: int, parent_width: int) -> int:
|
||||
"""Align the width dimension.
|
||||
|
||||
Args:
|
||||
width (int): Width of the content.
|
||||
parent_width (int): Width of the parent container.
|
||||
|
||||
Returns:
|
||||
int: An offset to add to the X coordinate.
|
||||
"""
|
||||
offset_x = 0
|
||||
align_horizontal = self.align_horizontal
|
||||
if align_horizontal != "left":
|
||||
if align_horizontal == "center":
|
||||
offset_x = (parent_width - width) // 2
|
||||
else:
|
||||
offset_x = parent_width - width
|
||||
return offset_x
|
||||
|
||||
def align_height(self, height: int, parent_height: int) -> int:
|
||||
"""Align the height dimensions
|
||||
|
||||
Args:
|
||||
height (int): Height of the content.
|
||||
parent_height (int): Height of the parent container.
|
||||
|
||||
Returns:
|
||||
int: An offset to add to the Y coordinate.
|
||||
"""
|
||||
offset_y = 0
|
||||
align_vertical = self.align_vertical
|
||||
if align_vertical != "left":
|
||||
if align_vertical == "middle":
|
||||
offset_y = (parent_height - height) // 2
|
||||
else:
|
||||
offset_y = parent_height - height
|
||||
return offset_y
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
@dataclass
|
||||
@@ -676,6 +731,15 @@ class Styles(StylesBase):
|
||||
),
|
||||
)
|
||||
|
||||
if has_rule("align_horizontal") and has_rule("align_vertical"):
|
||||
append_declaration(
|
||||
"align", f"{self.align_horizontal} {self.align_vertical}"
|
||||
)
|
||||
elif has_rule("align_horizontal"):
|
||||
append_declaration("align-horizontal", self.align_horizontal)
|
||||
elif has_rule("align_horizontal"):
|
||||
append_declaration("align-vertical", self.align_vertical)
|
||||
|
||||
lines.sort()
|
||||
return lines
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ EdgeType = Literal[
|
||||
]
|
||||
Visibility = Literal["visible", "hidden", "initial", "inherit"]
|
||||
Display = Literal["block", "none"]
|
||||
AlignHorizontal = Literal["left", "center", "right"]
|
||||
AlignVertical = Literal["top", "middle", "bottom"]
|
||||
BoxSizing = Literal["border-box", "content-box"]
|
||||
Overflow = Literal["scroll", "hidden", "auto"]
|
||||
EdgeStyle = Tuple[str, Color]
|
||||
|
||||
@@ -25,10 +25,14 @@ class HorizontalLayout(Layout):
|
||||
parent_size = parent.size
|
||||
|
||||
for widget in parent.children:
|
||||
styles = widget.styles
|
||||
(content_width, content_height), margin = widget.styles.get_box_model(
|
||||
size, parent_size
|
||||
)
|
||||
region = Region(margin.left + x, margin.top, content_width, content_height)
|
||||
offset_y = styles.align_height(content_height, parent_size.height)
|
||||
region = Region(
|
||||
margin.left + x, margin.top + offset_y, content_width, content_height
|
||||
)
|
||||
max_height = max(max_height, content_height + margin.height)
|
||||
add_placement(WidgetPlacement(region, widget, 0))
|
||||
x += region.width + margin.left
|
||||
|
||||
@@ -27,10 +27,15 @@ class VerticalLayout(Layout):
|
||||
parent_size = parent.size
|
||||
|
||||
for widget in parent.children:
|
||||
(content_width, content_height), margin = widget.styles.get_box_model(
|
||||
styles = widget.styles
|
||||
(content_width, content_height), margin = styles.get_box_model(
|
||||
size, parent_size
|
||||
)
|
||||
region = Region(margin.left, y + margin.top, content_width, content_height)
|
||||
offset_x = styles.align_width(content_width, parent_size.width)
|
||||
|
||||
region = Region(
|
||||
margin.left + offset_x, y + margin.top, content_width, content_height
|
||||
)
|
||||
max_width = max(max_width, content_width + margin.width)
|
||||
add_placement(WidgetPlacement(region, widget, 0))
|
||||
y += region.height + margin.top
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.console import ConsoleOptions, Console, RenderResult
|
||||
from rich.color import Color
|
||||
|
||||
from rich.segment import Segment
|
||||
from rich.style import Style
|
||||
|
||||
from ._blend_colors import blend_colors_rgb
|
||||
from ..color import Color
|
||||
|
||||
|
||||
class VerticalGradient:
|
||||
"""Draw a vertical gradient."""
|
||||
|
||||
def __init__(self, color1: str, color2: str) -> None:
|
||||
self._color1 = Color.parse(color1).get_truecolor()
|
||||
self._color2 = Color.parse(color2).get_truecolor()
|
||||
self._color1 = Color.parse(color1)
|
||||
self._color2 = Color.parse(color2)
|
||||
|
||||
def __rich_console__(
|
||||
self, console: Console, options: ConsoleOptions
|
||||
@@ -22,15 +22,20 @@ class VerticalGradient:
|
||||
height = options.height or options.max_height
|
||||
color1 = self._color1
|
||||
color2 = self._color2
|
||||
default_color = Color.default()
|
||||
default_color = Color(0, 0, 0).rich_color
|
||||
from_color = Style.from_color
|
||||
blend = color1.blend
|
||||
rich_color1 = color1.rich_color
|
||||
for y in range(height):
|
||||
yield Segment(
|
||||
f"{width * ' '}\n",
|
||||
from_color(
|
||||
default_color, blend_colors_rgb(color1, color2, y / (height - 1))
|
||||
line_color = from_color(
|
||||
default_color,
|
||||
(
|
||||
blend(color2, y / (height - 1)).rich_color
|
||||
if height > 1
|
||||
else rich_color1
|
||||
),
|
||||
)
|
||||
yield Segment(f"{width * ' '}\n", line_color)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -204,7 +204,11 @@ class Screen(Widget):
|
||||
if isinstance(event, events.MouseDown) and widget.can_focus:
|
||||
await self.app.set_focus(widget)
|
||||
event.style = self.get_style_at(event.screen_x, event.screen_y)
|
||||
await widget.forward_event(event.offset(-region.x, -region.y))
|
||||
if widget is self:
|
||||
event.set_forwarded()
|
||||
await self.post_message(event)
|
||||
else:
|
||||
await widget.forward_event(event.offset(-region.x, -region.y))
|
||||
|
||||
elif isinstance(event, (events.MouseScrollDown, events.MouseScrollUp)):
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user