css align

This commit is contained in:
Will McGugan
2022-04-14 13:10:49 +01:00
parent 69694c4591
commit 8e76e524a8
13 changed files with 188 additions and 44 deletions

19
sandbox/align.css Normal file
View 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
View 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)

View File

@@ -459,6 +459,7 @@ class App(DOMNode):
Args: Args:
error (Exception): An exception instance. error (Exception): An exception instance.
""" """
if hasattr(error, "__rich__"): if hasattr(error, "__rich__"):
# Exception has a rich method, so we can defer to that for the rendering # Exception has a rich method, so we can defer to that for the rendering
self.panic(error) self.panic(error)
@@ -489,15 +490,9 @@ class App(DOMNode):
if os.getenv("TEXTUAL_DEVTOOLS") == "1": if os.getenv("TEXTUAL_DEVTOOLS") == "1":
try: try:
await self.devtools.connect() await self.devtools.connect()
if self._log_console: self.log(f"Connected to devtools ({self.devtools.url})")
self._log_console.print(
f"Connected to devtools ({self.devtools.url})"
)
except DevtoolsConnectionError: except DevtoolsConnectionError:
if self._log_console: self.log(f"Couldn't connect to devtools ({self.devtools.url})")
self._log_console.print(
f"Couldn't connect to devtools ({self.devtools.url})"
)
try: try:
if self.css_file is not None: if self.css_file is not None:
self.stylesheet.read(self.css_file) self.stylesheet.read(self.css_file)
@@ -529,10 +524,11 @@ class App(DOMNode):
self.title = self._title self.title = self._title
self.refresh() self.refresh()
await self.animator.start() await self.animator.start()
with redirect_stdout(StdoutRedirector(self.devtools, self._log_file)): # type: ignore
await super().process_messages() await super().process_messages()
log("Message processing stopped") 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()"): with timer("animator.stop()"):
await self.animator.stop() await self.animator.stop()
with timer("self.close_all()"): with timer("self.close_all()"):
@@ -545,12 +541,6 @@ class App(DOMNode):
self._running = False self._running = False
if self._exit_renderables: if self._exit_renderables:
self._print_error_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: if self._log_file is not None:
self._log_file.close() self._log_file.close()

View File

@@ -139,6 +139,7 @@ class Color(NamedTuple):
@property @property
def rich_color(self) -> RichColor: def rich_color(self) -> RichColor:
"""This color encoded in Rich's Color class.""" """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 r, g, b, _a = self
return RichColor.from_rgb(r, g, b) return RichColor.from_rgb(r, g, b)

View File

@@ -6,6 +6,8 @@ import rich.repr
from ._error_tools import friendly_list from ._error_tools import friendly_list
from .constants import ( from .constants import (
VALID_ALIGN_HORIZONTAL,
VALID_ALIGN_VERTICAL,
VALID_BORDER, VALID_BORDER,
VALID_BOX_SIZING, VALID_BOX_SIZING,
VALID_EDGE, VALID_EDGE,
@@ -600,3 +602,30 @@ class StylesBuilder:
transitions[css_property] = Transition(duration, easing, delay) transitions[css_property] = Transition(duration, easing, delay)
self.styles._rules["transitions"] = transitions 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)

View File

@@ -29,6 +29,6 @@ VALID_LAYOUT: Final = {"dock", "vertical", "grid"}
VALID_BOX_SIZING: Final = {"border-box", "content-box"} VALID_BOX_SIZING: Final = {"border-box", "content-box"}
VALID_OVERFLOW: Final = {"scroll", "hidden", "auto"} 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) NULL_SPACING: Final = Spacing(0, 0, 0, 0)

View File

@@ -34,6 +34,7 @@ class Unit(Enum):
HEIGHT = 5 HEIGHT = 5
VIEW_WIDTH = 6 VIEW_WIDTH = 6
VIEW_HEIGHT = 7 VIEW_HEIGHT = 7
AUTO = 8
UNIT_SYMBOL = { UNIT_SYMBOL = {
@@ -124,6 +125,9 @@ class Scalar(NamedTuple):
Returns: Returns:
Scalar: New scalar Scalar: New scalar
""" """
if token.lower() == "auto":
scalar = cls(1.0, Unit.AUTO, Unit.AUTO)
else:
match = _MATCH_SCALAR(token) match = _MATCH_SCALAR(token)
if match is None: if match is None:
raise ScalarParseError(f"{token!r} is not a valid scalar") raise ScalarParseError(f"{token!r} is not a valid scalar")

View File

@@ -13,7 +13,7 @@ from rich.style import Style
from .. import log from .. import log
from .._animator import Animation, EasingFunction from .._animator import Animation, EasingFunction
from ..color import Color from ..color import Color
from ..geometry import Size, Spacing from ..geometry import Offset, Size, Spacing
from ._style_properties import ( from ._style_properties import (
BorderProperty, BorderProperty,
BoxProperty, BoxProperty,
@@ -32,17 +32,28 @@ from ._style_properties import (
TransitionsProperty, TransitionsProperty,
FractionalProperty, 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 import Scalar, ScalarOffset, Unit
from .scalar_animation import ScalarAnimation from .scalar_animation import ScalarAnimation
from .transition import Transition from .transition import Transition
from .types import ( from .types import (
BoxSizing, BoxSizing,
Display, Display,
AlignHorizontal,
AlignVertical,
Edge, Edge,
AlignHorizontal,
Overflow, Overflow,
Specificity3, Specificity3,
Specificity4, Specificity4,
AlignVertical,
Visibility, Visibility,
) )
@@ -116,6 +127,9 @@ class RulesMap(TypedDict, total=False):
scrollbar_background_hover: Color scrollbar_background_hover: Color
scrollbar_background_active: Color scrollbar_background_active: Color
align_horizontal: AlignHorizontal
align_vertical: AlignVertical
RULE_NAMES = list(RulesMap.__annotations__.keys()) RULE_NAMES = list(RulesMap.__annotations__.keys())
_rule_getter = attrgetter(*RULE_NAMES) _rule_getter = attrgetter(*RULE_NAMES)
@@ -204,6 +218,9 @@ class StylesBase(ABC):
scrollbar_background_hover = ColorProperty("#444444") scrollbar_background_hover = ColorProperty("#444444")
scrollbar_background_active = ColorProperty("black") 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: def __eq__(self, styles: object) -> bool:
"""Check that Styles containts the same rules.""" """Check that Styles containts the same rules."""
if not isinstance(styles, StylesBase): if not isinstance(styles, StylesBase):
@@ -410,6 +427,44 @@ class StylesBase(ABC):
return size, margin 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 @rich.repr.auto
@dataclass @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() lines.sort()
return lines return lines

View File

@@ -30,6 +30,8 @@ EdgeType = Literal[
] ]
Visibility = Literal["visible", "hidden", "initial", "inherit"] Visibility = Literal["visible", "hidden", "initial", "inherit"]
Display = Literal["block", "none"] Display = Literal["block", "none"]
AlignHorizontal = Literal["left", "center", "right"]
AlignVertical = Literal["top", "middle", "bottom"]
BoxSizing = Literal["border-box", "content-box"] BoxSizing = Literal["border-box", "content-box"]
Overflow = Literal["scroll", "hidden", "auto"] Overflow = Literal["scroll", "hidden", "auto"]
EdgeStyle = Tuple[str, Color] EdgeStyle = Tuple[str, Color]

View File

@@ -25,10 +25,14 @@ class HorizontalLayout(Layout):
parent_size = parent.size parent_size = parent.size
for widget in parent.children: for widget in parent.children:
styles = widget.styles
(content_width, content_height), margin = widget.styles.get_box_model( (content_width, content_height), margin = widget.styles.get_box_model(
size, parent_size 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) max_height = max(max_height, content_height + margin.height)
add_placement(WidgetPlacement(region, widget, 0)) add_placement(WidgetPlacement(region, widget, 0))
x += region.width + margin.left x += region.width + margin.left

View File

@@ -27,10 +27,15 @@ class VerticalLayout(Layout):
parent_size = parent.size parent_size = parent.size
for widget in parent.children: 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 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) max_width = max(max_width, content_width + margin.width)
add_placement(WidgetPlacement(region, widget, 0)) add_placement(WidgetPlacement(region, widget, 0))
y += region.height + margin.top y += region.height + margin.top

View File

@@ -1,19 +1,19 @@
from __future__ import annotations from __future__ import annotations
from rich.console import ConsoleOptions, Console, RenderResult from rich.console import ConsoleOptions, Console, RenderResult
from rich.color import Color
from rich.segment import Segment from rich.segment import Segment
from rich.style import Style from rich.style import Style
from ._blend_colors import blend_colors_rgb from ..color import Color
class VerticalGradient: class VerticalGradient:
"""Draw a vertical gradient.""" """Draw a vertical gradient."""
def __init__(self, color1: str, color2: str) -> None: def __init__(self, color1: str, color2: str) -> None:
self._color1 = Color.parse(color1).get_truecolor() self._color1 = Color.parse(color1)
self._color2 = Color.parse(color2).get_truecolor() self._color2 = Color.parse(color2)
def __rich_console__( def __rich_console__(
self, console: Console, options: ConsoleOptions self, console: Console, options: ConsoleOptions
@@ -22,15 +22,20 @@ class VerticalGradient:
height = options.height or options.max_height height = options.height or options.max_height
color1 = self._color1 color1 = self._color1
color2 = self._color2 color2 = self._color2
default_color = Color.default() default_color = Color(0, 0, 0).rich_color
from_color = Style.from_color from_color = Style.from_color
blend = color1.blend
rich_color1 = color1.rich_color
for y in range(height): for y in range(height):
yield Segment( line_color = from_color(
f"{width * ' '}\n", default_color,
from_color( (
default_color, blend_colors_rgb(color1, color2, y / (height - 1)) blend(color2, y / (height - 1)).rich_color
if height > 1
else rich_color1
), ),
) )
yield Segment(f"{width * ' '}\n", line_color)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -204,6 +204,10 @@ class Screen(Widget):
if isinstance(event, events.MouseDown) and widget.can_focus: if isinstance(event, events.MouseDown) and widget.can_focus:
await self.app.set_focus(widget) await self.app.set_focus(widget)
event.style = self.get_style_at(event.screen_x, event.screen_y) event.style = self.get_style_at(event.screen_x, event.screen_y)
if widget is self:
event.set_forwarded()
await self.post_message(event)
else:
await widget.forward_event(event.offset(-region.x, -region.y)) await widget.forward_event(event.offset(-region.x, -region.y))
elif isinstance(event, (events.MouseScrollDown, events.MouseScrollUp)): elif isinstance(event, (events.MouseScrollDown, events.MouseScrollUp)):