From 8e76e524a8d9d9f9b937187c1cff8d3f4be096ba Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 14 Apr 2022 13:10:49 +0100 Subject: [PATCH 01/16] css align --- sandbox/align.css | 19 ++++++++ sandbox/align.py | 17 ++++++++ src/textual/app.py | 34 +++++---------- src/textual/color.py | 1 + src/textual/css/_styles_builder.py | 29 ++++++++++++ src/textual/css/constants.py | 4 +- src/textual/css/scalar.py | 14 +++--- src/textual/css/styles.py | 68 ++++++++++++++++++++++++++++- src/textual/css/types.py | 2 + src/textual/layouts/horizontal.py | 6 ++- src/textual/layouts/vertical.py | 9 +++- src/textual/renderables/gradient.py | 23 ++++++---- src/textual/screen.py | 6 ++- 13 files changed, 188 insertions(+), 44 deletions(-) create mode 100644 sandbox/align.css create mode 100644 sandbox/align.py diff --git a/sandbox/align.css b/sandbox/align.css new file mode 100644 index 000000000..28497b73b --- /dev/null +++ b/sandbox/align.css @@ -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; +} \ No newline at end of file diff --git a/sandbox/align.py b/sandbox/align.py new file mode 100644 index 000000000..851dcbfae --- /dev/null +++ b/sandbox/align.py @@ -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) diff --git a/src/textual/app.py b/src/textual/app.py index 3a63f37d6..d26aaf1c7 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -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() diff --git a/src/textual/color.py b/src/textual/color.py index 9b707b8eb..22949cf8c 100644 --- a/src/textual/color.py +++ b/src/textual/color.py @@ -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) diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index 2903724b0..e49d3f38c 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -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) diff --git a/src/textual/css/constants.py b/src/textual/css/constants.py index d738a8341..25f17c72d 100644 --- a/src/textual/css/constants.py +++ b/src/textual/css/constants.py @@ -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) diff --git a/src/textual/css/scalar.py b/src/textual/css/scalar.py index 9ed1a0e4d..25bcca554 100644 --- a/src/textual/css/scalar.py +++ b/src/textual/css/scalar.py @@ -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) diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 89249ffef..57fcf1013 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -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 diff --git a/src/textual/css/types.py b/src/textual/css/types.py index a74ceb75f..a9f2db433 100644 --- a/src/textual/css/types.py +++ b/src/textual/css/types.py @@ -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] diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index db9e3d7b4..3245c5362 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -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 diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index fca5ed4e1..f18a39477 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -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 diff --git a/src/textual/renderables/gradient.py b/src/textual/renderables/gradient.py index c7eb1a53f..7d5fb3243 100644 --- a/src/textual/renderables/gradient.py +++ b/src/textual/renderables/gradient.py @@ -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__": diff --git a/src/textual/screen.py b/src/textual/screen.py index 0b987352e..0eafcf424 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -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: From cecbf655abf2a99aeabab4a4d9a64530a882e2fc Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 14 Apr 2022 16:55:58 +0100 Subject: [PATCH 02/16] align --- sandbox/align.css | 36 +++++++++++++++++++++------- sandbox/align.py | 9 ++++++- sandbox/basic.css | 7 +++++- src/textual/css/_style_properties.py | 32 ++++++++++++++++++------- src/textual/css/_styles_builder.py | 6 +++-- src/textual/css/scalar.py | 1 + src/textual/css/styles.py | 6 ++--- src/textual/layouts/dock.py | 3 ++- src/textual/layouts/horizontal.py | 4 +++- src/textual/layouts/vertical.py | 4 +++- src/textual/widget.py | 9 ++----- 11 files changed, 84 insertions(+), 33 deletions(-) diff --git a/sandbox/align.css b/sandbox/align.css index 28497b73b..37cdea344 100644 --- a/sandbox/align.css +++ b/sandbox/align.css @@ -1,19 +1,39 @@ + Screen { layout: horizontal; } -Widget#thing { +Widget { + margin:1; +} + +#thing { width: 20; height: 10; - align: center middle; + + background:magenta; + margin: 1; + padding: 1; + /* border: solid white; */ + align-vertical: middle; } -Widget#thing2 { - width: 30; - height: 8; - align: center middle; - background: green; -} \ No newline at end of file +#thing2 { + width: 20; + height: 10; + + background:green; + align-vertical: middle; +} + + +#thing3 { + width: 20; + height: 10; + + background:blue; + align-vertical: bottom; +} diff --git a/sandbox/align.py b/sandbox/align.py index 851dcbfae..06d0b366f 100644 --- a/sandbox/align.py +++ b/sandbox/align.py @@ -1,14 +1,21 @@ +from rich.text import Text + from textual.app import App from textual.widget import Widget +class Thing(Widget): + def render(self): + return Text.from_markup("Hello, World. [b magenta]Lorem impsum.") + + 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()) + self.mount(thing=Thing(), thing2=Widget(), thing3=Widget()) def action_log_tree(self): self.log(self.screen.tree) diff --git a/sandbox/basic.css b/sandbox/basic.css index 4d3e64d65..7ab6d8530 100644 --- a/sandbox/basic.css +++ b/sandbox/basic.css @@ -21,6 +21,7 @@ App > Screen { color: $text-background; } + #sidebar { color: $text-primary; background: $primary; @@ -81,7 +82,8 @@ Tweet { /* border: outer $primary; */ padding: 1; border: wide $panel-darken-2; - overflow-y: scroll + overflow-y: scroll; + align-horizontal: center; } @@ -168,6 +170,7 @@ Error { margin: 1 3; text-style: bold; + align-horizontal: center; } Warning { @@ -179,6 +182,7 @@ Warning { border-bottom: hkey $warning-darken-2; margin: 1 2; text-style: bold; + align-horizontal: center; } Success { @@ -190,4 +194,5 @@ Success { border-bottom: hkey $success; margin: 1 2; text-style: bold; + align-horizontal: center; } diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index af4a9cdd7..9f650be06 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -8,6 +8,7 @@ when setting and getting. """ from __future__ import annotations +from tkinter.tix import AUTO from typing import Iterable, NamedTuple, TYPE_CHECKING, cast @@ -47,10 +48,14 @@ class ScalarProperty: """Descriptor for getting and setting scalar properties. Scalars are numeric values with a unit, e.g. "50vh".""" def __init__( - self, units: set[Unit] | None = None, percent_unit: Unit = Unit.WIDTH + self, + units: set[Unit] | None = None, + percent_unit: Unit = Unit.WIDTH, + allow_auto: bool = True, ) -> None: self.units: set[Unit] = units or {*UNIT_SYMBOL} self.percent_unit = percent_unit + self.allow_auto = allow_auto super().__init__() def __set_name__(self, owner: Styles, name: str) -> None: @@ -90,7 +95,7 @@ class ScalarProperty: if value is None: obj.clear_rule(self.name) return - if isinstance(value, float): + if isinstance(value, (int, float)): new_value = Scalar(float(value), Unit.CELLS, Unit.WIDTH) elif isinstance(value, Scalar): new_value = value @@ -101,12 +106,23 @@ class ScalarProperty: raise StyleValueError("unable to parse scalar from {value!r}") else: raise StyleValueError("expected float, Scalar, or None") - if new_value is not None and new_value.unit not in self.units: - raise StyleValueError( - f"{self.name} units must be one of {friendly_list(get_symbols(self.units))}" - ) - if new_value is not None and new_value.is_percent: - new_value = Scalar(float(new_value.value), self.percent_unit, Unit.WIDTH) + + if ( + new_value is not None + and new_value.unit == Unit.AUTO + and not self.allow_auto + ): + raise StyleValueError("'auto' not allowed here") + + if new_value.unit != Unit.AUTO: + if new_value is not None and new_value.unit not in self.units: + raise StyleValueError( + f"{self.name} units must be one of {friendly_list(get_symbols(self.units))}" + ) + if new_value is not None and new_value.is_percent: + new_value = Scalar( + float(new_value.value), self.percent_unit, Unit.WIDTH + ) if obj.set_rule(self.name, new_value): obj.refresh() diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index e49d3f38c..1e3c07b46 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -625,7 +625,9 @@ class StylesBuilder: 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) + value = self._process_enum(name, tokens, VALID_ALIGN_HORIZONTAL) + self.styles._rules["align_horizontal"] = value def process_align_vertical(self, name: str, tokens: list[Token]) -> None: - self._process_enum(name, tokens, VALID_ALIGN_VERTICAL) + value = self._process_enum(name, tokens, VALID_ALIGN_VERTICAL) + self.styles._rules["align_vertical"] = value diff --git a/src/textual/css/scalar.py b/src/textual/css/scalar.py index 25bcca554..33703ac81 100644 --- a/src/textual/css/scalar.py +++ b/src/textual/css/scalar.py @@ -152,6 +152,7 @@ class Scalar(NamedTuple): float: _description_ """ value, unit, percent_unit = self + if unit == Unit.PERCENT: unit = percent_unit try: diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 57fcf1013..eb9caa517 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -418,8 +418,8 @@ class StylesBase(ABC): margin = self.margin else: # border-box - if has_rule("padding"): - size -= self.padding.totals + # if has_rule("padding"): + # size -= self.padding.totals if has_rule("border"): size -= self.border.spacing.totals if has_rule("margin"): @@ -458,7 +458,7 @@ class StylesBase(ABC): """ offset_y = 0 align_vertical = self.align_vertical - if align_vertical != "left": + if align_vertical != "top": if align_vertical == "middle": offset_y = (parent_height - height) // 2 else: diff --git a/src/textual/layouts/dock.py b/src/textual/layouts/dock.py index e040a9642..b507bbc67 100644 --- a/src/textual/layouts/dock.py +++ b/src/textual/layouts/dock.py @@ -3,6 +3,7 @@ from __future__ import annotations import sys from collections import defaultdict from dataclasses import dataclass +from operator import attrgetter from typing import Iterable, TYPE_CHECKING, NamedTuple, Sequence from .._layout_resolve import layout_resolve @@ -91,7 +92,7 @@ class DockLayout(Layout): add_placement = placements.append arranged_widgets: set[Widget] = set() - for edge, widgets, z in docks: + for z, (edge, widgets, _z) in enumerate(sorted(docks, key=attrgetter("z"))): arranged_widgets.update(widgets) dock_options = [make_dock_options(widget, edge) for widget in widgets] diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index 3245c5362..9bf3485c6 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -29,7 +29,9 @@ class HorizontalLayout(Layout): (content_width, content_height), margin = widget.styles.get_box_model( size, parent_size ) - offset_y = styles.align_height(content_height, parent_size.height) + offset_y = styles.align_height( + content_height + margin.height, parent_size.height + ) region = Region( margin.left + x, margin.top + offset_y, content_width, content_height ) diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index f18a39477..9af7e76cb 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -31,7 +31,9 @@ class VerticalLayout(Layout): (content_width, content_height), margin = styles.get_box_model( size, parent_size ) - offset_x = styles.align_width(content_width, parent_size.width) + offset_x = styles.align_width( + content_width + margin.width, parent_size.width + ) region = Region( margin.left + offset_x, y + margin.top, content_width, content_height diff --git a/src/textual/widget.py b/src/textual/widget.py index 74dc4aabf..e987980c0 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -6,7 +6,6 @@ from typing import ( Awaitable, TYPE_CHECKING, Callable, - ClassVar, Iterable, NamedTuple, cast, @@ -16,10 +15,9 @@ import rich.repr from rich.align import Align from rich.console import Console, RenderableType from rich.padding import Padding -from rich.pretty import Pretty from rich.style import Style from rich.styled import Styled -from rich.text import Text + from . import errors, log from . import events @@ -394,10 +392,7 @@ class Widget(DOMNode): if renderable_text_style: renderable = Styled(renderable, renderable_text_style) - if styles.padding: - renderable = Padding( - renderable, styles.padding, style=renderable_text_style - ) + renderable = Padding(renderable, styles.padding, style=renderable_text_style) if styles.border: renderable = Border( From 66ec1307264a3aaf42c595a4dcad72abd7e53249 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 20 Apr 2022 14:20:44 +0100 Subject: [PATCH 03/16] box model --- sandbox/align.css | 14 ++++-- sandbox/align.py | 3 +- sandbox/basic.css | 31 ++++++++++--- sandbox/basic.py | 58 ++++++++++++++++++++++++- src/textual/app.py | 1 + src/textual/css/_style_properties.py | 4 +- src/textual/css/scalar.py | 7 ++- src/textual/css/styles.py | 65 ---------------------------- src/textual/geometry.py | 3 +- src/textual/layouts/horizontal.py | 2 +- src/textual/layouts/vertical.py | 2 +- src/textual/widget.py | 34 ++++++++++++--- 12 files changed, 135 insertions(+), 89 deletions(-) diff --git a/sandbox/align.css b/sandbox/align.css index 37cdea344..4ab85db14 100644 --- a/sandbox/align.css +++ b/sandbox/align.css @@ -10,23 +10,31 @@ Widget { } #thing { - width: 20; + width: auto; height: 10; background:magenta; margin: 1; padding: 1; + border: solid white; + box-sizing: content-box; /* border: solid white; */ align-vertical: middle; } #thing2 { - width: 20; + width: auto; height: 10; - + /* border: solid white; */ + outline: heavy blue; + + padding: 1 2; + box-sizing: content-box; + background:green; align-vertical: middle; + color:white; } diff --git a/sandbox/align.py b/sandbox/align.py index 06d0b366f..59984b57e 100644 --- a/sandbox/align.py +++ b/sandbox/align.py @@ -2,6 +2,7 @@ from rich.text import Text from textual.app import App from textual.widget import Widget +from textual.widgets import Static class Thing(Widget): @@ -15,7 +16,7 @@ class AlignApp(App): def on_mount(self) -> None: self.log("MOUNTED") - self.mount(thing=Thing(), thing2=Widget(), thing3=Widget()) + self.mount(thing=Thing(), thing2=Static("0123456789"), thing3=Widget()) def action_log_tree(self): self.log(self.screen.tree) diff --git a/sandbox/basic.css b/sandbox/basic.css index 7ab6d8530..113d99978 100644 --- a/sandbox/basic.css +++ b/sandbox/basic.css @@ -17,8 +17,8 @@ App > Screen { layout: dock; docks: side=left/1; - background: $background; - color: $text-background; + background: $surface; + color: $text-surface; } @@ -67,13 +67,12 @@ App > Screen { color: $text-background; background: $background; layout: vertical; - overflow-y:scroll; - + overflow-y: scroll; } Tweet { - height: 22; + height: 12; max-width: 80; margin: 1 3; background: $panel; @@ -86,6 +85,21 @@ Tweet { align-horizontal: center; } +.scrollable { + overflow-y: scroll; + max-width:80; + height: 20; + align-horizontal: center; + layout: vertical; +} + +.code { + + height: 34; + + +} + TweetHeader { height:1; @@ -170,7 +184,7 @@ Error { margin: 1 3; text-style: bold; - align-horizontal: center; + align-horizontal: center; } Warning { @@ -196,3 +210,8 @@ Success { text-style: bold; align-horizontal: center; } + + +.horizontal { + layout: horizontal +} \ No newline at end of file diff --git a/sandbox/basic.py b/sandbox/basic.py index bddf8431a..e89f54034 100644 --- a/sandbox/basic.py +++ b/sandbox/basic.py @@ -1,9 +1,47 @@ from rich.align import Align from rich.console import RenderableType +from rich.syntax import Syntax from rich.text import Text from textual.app import App from textual.widget import Widget +from textual.widgets import Static + +CODE = ''' +class Offset(NamedTuple): + """A point defined by x and y coordinates.""" + + x: int = 0 + y: int = 0 + + @property + def is_origin(self) -> bool: + """Check if the point is at the origin (0, 0)""" + return self == (0, 0) + + def __bool__(self) -> bool: + return self != (0, 0) + + def __add__(self, other: object) -> Offset: + if isinstance(other, tuple): + _x, _y = self + x, y = other + return Offset(_x + x, _y + y) + return NotImplemented + + def __sub__(self, other: object) -> Offset: + if isinstance(other, tuple): + _x, _y = self + x, y = other + return Offset(_x - x, _y - y) + return NotImplemented + + def __mul__(self, other: object) -> Offset: + if isinstance(other, (float, int)): + x, y = self + return Offset(int(x * other), int(y * other)) + return NotImplemented +''' lorem = Text.from_markup( @@ -56,9 +94,25 @@ class BasicApp(App): def on_mount(self): """Build layout here.""" self.mount( - header=Widget(), + header=Static( + Align.center( + "[b]This is a [u]Textual[/u] app, running in the terminal", + vertical="middle", + ) + ), content=Widget( - Tweet(TweetBody(), Widget(classes={"button"})), + Tweet( + TweetBody(), + # Widget( + # Widget(classes={"button"}), + # Widget(classes={"button"}), + # classes={"horizontal"}, + # ), + ), + Widget( + Static(Syntax(CODE, "python"), classes={"code"}), + classes={"scrollable"}, + ), Error(), Tweet(TweetBody()), Warning(), diff --git a/src/textual/app.py b/src/textual/app.py index d26aaf1c7..4826c55ec 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -64,6 +64,7 @@ DEFAULT_COLORS = ColorSystem( success="#6d9f71", accent="#ffa62b", system="#5a4599", + dark_surface="#292929", ) diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 9f650be06..b8add4019 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -76,7 +76,9 @@ class ScalarProperty: value = obj.get_rule(self.name) return value - def __set__(self, obj: StylesBase, value: float | Scalar | str | None) -> None: + def __set__( + self, obj: StylesBase, value: float | int | Scalar | str | None + ) -> None: """Set the scalar property Args: diff --git a/src/textual/css/scalar.py b/src/textual/css/scalar.py index 33703ac81..2b6dd719e 100644 --- a/src/textual/css/scalar.py +++ b/src/textual/css/scalar.py @@ -3,11 +3,10 @@ from __future__ import annotations from enum import Enum, unique from functools import lru_cache import re -from typing import Iterable, NamedTuple, TYPE_CHECKING +from typing import Callable, Iterable, NamedTuple, TYPE_CHECKING import rich.repr -from textual.css.tokenizer import Token from .. import log from ..geometry import Offset @@ -108,6 +107,10 @@ class Scalar(NamedTuple): def symbol(self) -> str: return UNIT_SYMBOL[self.unit] + @property + def is_auto(self) -> bool: + return self.unit == Unit.AUTO + @classmethod def from_number(cls, value: float) -> Scalar: return cls(float(value), Unit.CELLS, Unit.WIDTH) diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index eb9caa517..b4f013860 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -362,71 +362,6 @@ class StylesBase(ABC): else: return None - def get_box_model( - self, container_size: Size, parent_size: Size - ) -> tuple[Size, Spacing]: - """Resolve the box model for this Styles. - - Args: - container_size (Size): The size of the widget container. - parent_size (Size): The size widget's parent. - - Returns: - tuple[Size, Spacing]: A tuple with the size of the content area and margin. - """ - has_rule = self.has_rule - width, height = container_size - - if has_rule("width"): - width = self.width.resolve_dimension(container_size, parent_size) - else: - width = max(0, width - self.margin.width) - - if self.min_width: - min_width = self.min_width.resolve_dimension(container_size, parent_size) - width = max(width, min_width) - - if self.max_width: - max_width = self.max_width.resolve_dimension(container_size, parent_size) - width = min(width, max_width) - - if has_rule("height"): - height = self.height.resolve_dimension(container_size, parent_size) - else: - height = max(0, height - self.margin.height) - - if self.min_height: - min_height = self.min_height.resolve_dimension(container_size, parent_size) - height = max(height, min_height) - - if self.max_height: - max_height = self.max_height.resolve_dimension(container_size, parent_size) - height = min(width, max_height) - - # TODO: box sizing - - size = Size(width, height) - margin = Spacing(0, 0, 0, 0) - - if self.box_sizing == "content-box": - - if has_rule("padding"): - size += self.padding.totals - if has_rule("border"): - size += self.border.spacing.totals - if has_rule("margin"): - margin = self.margin - - else: # border-box - # if has_rule("padding"): - # size -= self.padding.totals - if has_rule("border"): - size -= self.border.spacing.totals - if has_rule("margin"): - margin = self.margin - - return size, margin - def align_width(self, width: int, parent_width: int) -> int: """Align the width dimension. diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 5fccc2728..3bf05ca6e 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -7,7 +7,6 @@ Functions and classes to manage terminal geometry (anything involving coordinate from __future__ import annotations -from math import sqrt from typing import Any, cast, NamedTuple, Tuple, Union, TypeVar @@ -97,7 +96,7 @@ class Offset(NamedTuple): """ x1, y1 = self x2, y2 = other - distance = sqrt((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1)) + distance = ((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1)) ** 0.5 return distance diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index 9bf3485c6..464e56acd 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -26,7 +26,7 @@ class HorizontalLayout(Layout): for widget in parent.children: styles = widget.styles - (content_width, content_height), margin = widget.styles.get_box_model( + (content_width, content_height), margin = widget.get_box_model( size, parent_size ) offset_y = styles.align_height( diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 9af7e76cb..3b9e97d8d 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -28,7 +28,7 @@ class VerticalLayout(Layout): for widget in parent.children: styles = widget.styles - (content_width, content_height), margin = styles.get_box_model( + (content_width, content_height), margin = widget.get_box_model( size, parent_size ) offset_x = styles.align_width( diff --git a/src/textual/widget.py b/src/textual/widget.py index e987980c0..7cc611535 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -14,6 +14,7 @@ from typing import ( import rich.repr from rich.align import Align from rich.console import Console, RenderableType +from rich.measure import Measurement from rich.padding import Padding from rich.style import Style from rich.styled import Styled @@ -23,12 +24,13 @@ from . import errors, log from . import events from ._animator import BoundAnimator from ._border import Border +from ._box_model import get_box_model from ._callback import invoke from .color import Color from ._context import active_app from ._types import Lines from .dom import DOMNode -from .geometry import clamp, Offset, Region, Size +from .geometry import clamp, Offset, Region, Size, Spacing from .message import Message from . import messages from .layout import Layout @@ -94,6 +96,8 @@ class Widget(DOMNode): super().__init__(name=name, id=id, classes=classes) self.add_children(*children) + auto_width = Reactive(True) + auto_height = Reactive(True) has_focus = Reactive(False) mouse_over = Reactive(False) scroll_x = Reactive(0.0, repaint=False) @@ -103,6 +107,26 @@ class Widget(DOMNode): show_vertical_scrollbar = Reactive(False, layout=True) show_horizontal_scrollbar = Reactive(False, layout=True) + def get_box_model(self, container_size, parent_size) -> tuple[Size, Spacing]: + box_model = get_box_model( + self.styles, + container_size, + parent_size, + self.get_content_width, + self.get_content_height, + ) + self.log(self, self.styles.padding, self.styles.border.spacing) + return box_model + + def get_content_width(self, container_size: Size, parent_size: Size) -> int: + console = self.app.console + renderable = self.render() + measurement = Measurement.get(console, console.options, renderable) + return measurement.maximum + + def get_content_height(self, container_size: Size, parent_size: Size) -> int: + return container_size.height + async def watch_scroll_x(self, new_value: float) -> None: self.horizontal_scrollbar.position = int(new_value) @@ -651,13 +675,13 @@ class Widget(DOMNode): def on_mouse_scroll_down(self, event) -> None: if self.is_container: - if not self.scroll_down(animate=False): - event.stop() + self.scroll_down(animate=False) + event.stop() def on_mouse_scroll_up(self, event) -> None: if self.is_container: - if not self.scroll_up(animate=False): - event.stop() + self.scroll_up(animate=False) + event.stop() def handle_scroll_to(self, message: ScrollTo) -> None: if self.is_container: From 7f85fc6795f71264c8df1e5d4424c2c3114dfd49 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 20 Apr 2022 14:21:04 +0100 Subject: [PATCH 04/16] box model py --- src/textual/_box_model.py | 76 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 src/textual/_box_model.py diff --git a/src/textual/_box_model.py b/src/textual/_box_model.py new file mode 100644 index 000000000..31687f76e --- /dev/null +++ b/src/textual/_box_model.py @@ -0,0 +1,76 @@ +from __future__ import annotations + + +from typing import Callable, TYPE_CHECKING + +from .geometry import Size, Spacing +from .css.styles import StylesBase + + +def get_box_model( + styles: StylesBase, + container_size: Size, + parent_size: Size, + get_auto_width: Callable[[Size, Size], int], + get_auto_height: Callable[[Size, Size], int], +) -> tuple[Size, Spacing]: + """Resolve the box model for this Styles. + + Args: + container_size (Size): The size of the widget container. + parent_size (Size): The size widget's parent. + + Returns: + tuple[Size, Spacing]: A tuple with the size of the content area and margin. + """ + + has_rule = styles.has_rule + width, height = container_size + + extra = Size(0, 0) + if styles.box_sizing == "content-box": + if has_rule("padding"): + extra += styles.padding.totals + extra += styles.border.spacing.totals + + else: # border-box + extra -= styles.border.spacing.totals + + if has_rule("width"): + if styles.width.is_auto: + # extra_width = styles.padding.width + styles.border.spacing.width + width = get_auto_width(container_size, parent_size) + else: + width = styles.width.resolve_dimension(container_size, parent_size) + else: + width = max(0, width - styles.margin.width) + + if styles.min_width: + min_width = styles.min_width.resolve_dimension(container_size, parent_size) + width = max(width, min_width) + + if styles.max_width: + max_width = styles.max_width.resolve_dimension(container_size, parent_size) + width = min(width, max_width) + + if has_rule("height"): + if styles.height.is_auto: + extra_height = styles.padding.height + styles.border.spacing.height + height = get_auto_height(container_size, parent_size) + extra_height + else: + height = styles.height.resolve_dimension(container_size, parent_size) + else: + height = max(0, height - styles.margin.height) + + if styles.min_height: + min_height = styles.min_height.resolve_dimension(container_size, parent_size) + height = max(height, min_height) + + if styles.max_height: + max_height = styles.max_height.resolve_dimension(container_size, parent_size) + height = min(width, max_height) + + size = Size(width, height) + extra + margin = styles.margin if has_rule("margin") else Spacing(0, 0, 0, 0) + + return size, margin From 3869eb7463f0d94d6ad4cb6d45a4f7ba326f28c3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 20 Apr 2022 15:47:06 +0100 Subject: [PATCH 05/16] vertical layout, fix scroll issue for screen --- sandbox/align.css | 16 ++++----- src/textual/{_box_model.py => box_model.py} | 16 ++++++--- src/textual/layouts/horizontal.py | 37 +++++++++++-------- src/textual/layouts/vertical.py | 39 ++++++++++++--------- src/textual/screen.py | 5 ++- src/textual/widget.py | 6 ++-- 6 files changed, 73 insertions(+), 46 deletions(-) rename src/textual/{_box_model.py => box_model.py} (81%) diff --git a/sandbox/align.css b/sandbox/align.css index 4ab85db14..a507ea1e0 100644 --- a/sandbox/align.css +++ b/sandbox/align.css @@ -1,8 +1,8 @@ Screen { - layout: horizontal; - + layout: vertical; + overflow: auto; } Widget { @@ -11,15 +11,15 @@ Widget { #thing { width: auto; - height: 10; + height: auto; background:magenta; - margin: 1; + margin: 3; padding: 1; border: solid white; box-sizing: content-box; /* border: solid white; */ - align-vertical: middle; + align-horizontal: center; } @@ -33,7 +33,7 @@ Widget { box-sizing: content-box; background:green; - align-vertical: middle; + align-horizontal: center; color:white; } @@ -41,7 +41,7 @@ Widget { #thing3 { width: 20; height: 10; - + margin: 1; background:blue; - align-vertical: bottom; + align-horizontal: center; } diff --git a/src/textual/_box_model.py b/src/textual/box_model.py similarity index 81% rename from src/textual/_box_model.py rename to src/textual/box_model.py index 31687f76e..153e777ac 100644 --- a/src/textual/_box_model.py +++ b/src/textual/box_model.py @@ -1,27 +1,35 @@ from __future__ import annotations -from typing import Callable, TYPE_CHECKING +from typing import Callable, NamedTuple, TYPE_CHECKING from .geometry import Size, Spacing from .css.styles import StylesBase +class BoxModel(NamedTuple): + content: Size + margin: Spacing + + def get_box_model( styles: StylesBase, container_size: Size, parent_size: Size, get_auto_width: Callable[[Size, Size], int], get_auto_height: Callable[[Size, Size], int], -) -> tuple[Size, Spacing]: +) -> BoxModel: """Resolve the box model for this Styles. Args: + styles (StylesBase): Styles object. container_size (Size): The size of the widget container. parent_size (Size): The size widget's parent. + get_auto_width (Callable): A callable which accepts container size and parent size and returns a width. + get_auto_height (Callable): A callable which accepts container size and parent size and returns a height. Returns: - tuple[Size, Spacing]: A tuple with the size of the content area and margin. + BoxModel: A tuple with the size of the content area and margin. """ has_rule = styles.has_rule @@ -73,4 +81,4 @@ def get_box_model( size = Size(width, height) + extra margin = styles.margin if has_rule("margin") else Spacing(0, 0, 0, 0) - return size, margin + return BoxModel(size, margin) diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index 464e56acd..0668301f8 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -1,5 +1,6 @@ from __future__ import annotations +from typing import cast from textual.geometry import Size, Offset, Region from textual.layout import Layout, WidgetPlacement @@ -24,21 +25,29 @@ class HorizontalLayout(Layout): x = max_width = max_height = 0 parent_size = parent.size - for widget in parent.children: - styles = widget.styles - (content_width, content_height), margin = widget.get_box_model( - size, parent_size - ) - offset_y = styles.align_height( - content_height + margin.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) + box_models = [ + widget.get_box_model(size, parent_size) + for widget in cast("list[Widget]", parent.children) + ] + + margins = [ + max((box1.margin.right, box2.margin.left)) + for box1, box2 in zip(box_models, box_models[1:]) + ] + margins.append(box_models[-1].margin.right) + + x = box_models[0].margin.left + + for widget, box_model, margin in zip(parent.children, box_models, margins): + content_width, content_height = box_model.content + offset_y = widget.styles.align_height(content_height, parent_size.height) + region = Region(x, offset_y, content_width, content_height) + max_height = max(max_height, content_height) add_placement(WidgetPlacement(region, widget, 0)) - x += region.width + margin.left - max_width = x + margin.right + x += region.width + margin + max_width = x + + max_width += margins[-1] total_region = Region(0, 0, max_width, max_height) add_placement(WidgetPlacement(total_region, None, 0)) diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 3b9e97d8d..49f78ae30 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import cast, TYPE_CHECKING from .. import log @@ -12,7 +12,7 @@ if TYPE_CHECKING: class VerticalLayout(Layout): - """Simple vertical layout.""" + """Used to layout Widgets vertically on screen, from top to bottom.""" name = "vertical" @@ -26,22 +26,29 @@ class VerticalLayout(Layout): y = max_width = max_height = 0 parent_size = parent.size - for widget in parent.children: - styles = widget.styles - (content_width, content_height), margin = widget.get_box_model( - size, parent_size - ) - offset_x = styles.align_width( - content_width + margin.width, parent_size.width - ) + box_models = [ + widget.get_box_model(size, parent_size) + for widget in cast("list[Widget]", parent.children) + ] - region = Region( - margin.left + offset_x, y + margin.top, content_width, content_height - ) - max_width = max(max_width, content_width + margin.width) + margins = [ + max((box1.margin.bottom, box2.margin.top)) + for box1, box2 in zip(box_models, box_models[1:]) + ] + margins.append(box_models[-1].margin.bottom) + + y = box_models[0].margin.top + + for widget, box_model, margin in zip(parent.children, box_models, margins): + content_width, content_height = box_model.content + offset_x = widget.styles.align_width(content_width, parent_size.width) + region = Region(offset_x, y, content_width, content_height) + max_height = max(max_height, content_height) add_placement(WidgetPlacement(region, widget, 0)) - y += region.height + margin.top - max_height = y + margin.bottom + y += region.height + margin + max_height = y + + max_height += margins[-1] total_region = Region(0, 0, max_width, max_height) add_placement(WidgetPlacement(total_region, None, 0)) diff --git a/src/textual/screen.py b/src/textual/screen.py index 0eafcf424..2e11ed3ec 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -217,6 +217,9 @@ class Screen(Widget): return scroll_widget = widget if scroll_widget is not None: - await scroll_widget.forward_event(event) + if scroll_widget is self: + await self.post_message(event) + else: + await scroll_widget.forward_event(event) else: await self.post_message(event) diff --git a/src/textual/widget.py b/src/textual/widget.py index 7cc611535..953c7cb10 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -24,7 +24,7 @@ from . import errors, log from . import events from ._animator import BoundAnimator from ._border import Border -from ._box_model import get_box_model +from .box_model import BoxModel, get_box_model from ._callback import invoke from .color import Color from ._context import active_app @@ -107,7 +107,7 @@ class Widget(DOMNode): show_vertical_scrollbar = Reactive(False, layout=True) show_horizontal_scrollbar = Reactive(False, layout=True) - def get_box_model(self, container_size, parent_size) -> tuple[Size, Spacing]: + def get_box_model(self, container_size, parent_size) -> BoxModel: box_model = get_box_model( self.styles, container_size, @@ -260,7 +260,7 @@ class Widget(DOMNode): self.scroll_target_x = x if x != self.scroll_x: self.animate( - "scroll_x", self.scroll_target_x, speed=80, easing="out_cubic" + "scroll_x", self.scroll_target_x, speed=80, easing="lineary" ) scrolled_x = True if y is not None: From 2c85bd97bd550db31ba9e4d5929a2bf194c631b0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 20 Apr 2022 17:00:52 +0100 Subject: [PATCH 06/16] box model calculations --- src/textual/box_model.py | 26 +++++++++++++------------- src/textual/geometry.py | 9 +++++++++ src/textual/layouts/horizontal.py | 2 +- src/textual/layouts/vertical.py | 2 +- src/textual/widget.py | 18 ++++++++++++------ 5 files changed, 36 insertions(+), 21 deletions(-) diff --git a/src/textual/box_model.py b/src/textual/box_model.py index 153e777ac..027a1ef8a 100644 --- a/src/textual/box_model.py +++ b/src/textual/box_model.py @@ -8,16 +8,18 @@ from .css.styles import StylesBase class BoxModel(NamedTuple): - content: Size - margin: Spacing + """The result of `get_box_model`.""" + + size: Size # Content + padding + border + margin: Spacing # Additional margin def get_box_model( styles: StylesBase, container_size: Size, parent_size: Size, - get_auto_width: Callable[[Size, Size], int], - get_auto_height: Callable[[Size, Size], int], + get_auto_width: Callable[[Size, Size, Spacing], int], + get_auto_height: Callable[[Size, Size, Spacing], int], ) -> BoxModel: """Resolve the box model for this Styles. @@ -35,19 +37,18 @@ def get_box_model( has_rule = styles.has_rule width, height = container_size - extra = Size(0, 0) + gutter = Spacing(0, 0, 0, 0) if styles.box_sizing == "content-box": if has_rule("padding"): - extra += styles.padding.totals - extra += styles.border.spacing.totals + gutter += styles.padding + gutter += styles.border.spacing else: # border-box - extra -= styles.border.spacing.totals + gutter -= styles.border.spacing if has_rule("width"): if styles.width.is_auto: - # extra_width = styles.padding.width + styles.border.spacing.width - width = get_auto_width(container_size, parent_size) + width = get_auto_width(container_size, parent_size, gutter) else: width = styles.width.resolve_dimension(container_size, parent_size) else: @@ -63,8 +64,7 @@ def get_box_model( if has_rule("height"): if styles.height.is_auto: - extra_height = styles.padding.height + styles.border.spacing.height - height = get_auto_height(container_size, parent_size) + extra_height + height = get_auto_height(container_size, parent_size, gutter) else: height = styles.height.resolve_dimension(container_size, parent_size) else: @@ -78,7 +78,7 @@ def get_box_model( max_height = styles.max_height.resolve_dimension(container_size, parent_size) height = min(width, max_height) - size = Size(width, height) + extra + size = Size(width, height) + gutter.totals margin = styles.margin if has_rule("margin") else Spacing(0, 0, 0, 0) return BoxModel(size, margin) diff --git a/src/textual/geometry.py b/src/textual/geometry.py index 3bf05ca6e..babc5c9d9 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -653,5 +653,14 @@ class Spacing(NamedTuple): ) return NotImplemented + def __sub__(self, other: object) -> Spacing: + if isinstance(other, tuple): + top1, right1, bottom1, left1 = self + top2, right2, bottom2, left2 = other + return Spacing( + top1 - top2, right1 - right2, bottom1 - bottom2, left1 - left2 + ) + return NotImplemented + NULL_OFFSET = Offset(0, 0) diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index 0668301f8..87ebd9c58 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -39,7 +39,7 @@ class HorizontalLayout(Layout): x = box_models[0].margin.left for widget, box_model, margin in zip(parent.children, box_models, margins): - content_width, content_height = box_model.content + content_width, content_height = box_model.size offset_y = widget.styles.align_height(content_height, parent_size.height) region = Region(x, offset_y, content_width, content_height) max_height = max(max_height, content_height) diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 49f78ae30..8c0638f2f 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -40,7 +40,7 @@ class VerticalLayout(Layout): y = box_models[0].margin.top for widget, box_model, margin in zip(parent.children, box_models, margins): - content_width, content_height = box_model.content + content_width, content_height = box_model.size offset_x = widget.styles.align_width(content_width, parent_size.width) region = Region(offset_x, y, content_width, content_height) max_height = max(max_height, content_height) diff --git a/src/textual/widget.py b/src/textual/widget.py index 953c7cb10..4dae88aef 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -107,7 +107,7 @@ class Widget(DOMNode): show_vertical_scrollbar = Reactive(False, layout=True) show_horizontal_scrollbar = Reactive(False, layout=True) - def get_box_model(self, container_size, parent_size) -> BoxModel: + def get_box_model(self, container_size: Size, parent_size: Size) -> BoxModel: box_model = get_box_model( self.styles, container_size, @@ -118,14 +118,18 @@ class Widget(DOMNode): self.log(self, self.styles.padding, self.styles.border.spacing) return box_model - def get_content_width(self, container_size: Size, parent_size: Size) -> int: + def get_content_width( + self, container_size: Size, parent_size: Size, gutter: Spacing + ) -> int: console = self.app.console renderable = self.render() measurement = Measurement.get(console, console.options, renderable) return measurement.maximum - def get_content_height(self, container_size: Size, parent_size: Size) -> int: - return container_size.height + def get_content_height( + self, container_size: Size, parent_size: Size, gutter: Spacing + ) -> int: + return container_size.height - gutter.height async def watch_scroll_x(self, new_value: float) -> None: self.horizontal_scrollbar.position = int(new_value) @@ -260,7 +264,7 @@ class Widget(DOMNode): self.scroll_target_x = x if x != self.scroll_x: self.animate( - "scroll_x", self.scroll_target_x, speed=80, easing="lineary" + "scroll_x", self.scroll_target_x, speed=80, easing="out_cubic" ) scrolled_x = True if y is not None: @@ -537,7 +541,9 @@ class Widget(DOMNode): """Render all lines.""" width, height = self.size renderable = self.render_styled() - options = self.console.options.update_dimensions(width, height) + options = self.console.options.update_dimensions(width, height).update( + highlight=False + ) lines = self.console.render_lines(renderable, options) self._render_cache = RenderCache(self.size, lines) self._dirty_regions.clear() From 282371801199cb8efbe262746b485cb76cd60263 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 20 Apr 2022 17:14:57 +0100 Subject: [PATCH 07/16] check for non-existant rules --- src/textual/box_model.py | 8 ++++---- src/textual/css/styles.py | 2 ++ tests/test_geometry.py | 7 +++++++ 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/textual/box_model.py b/src/textual/box_model.py index 027a1ef8a..5259248a6 100644 --- a/src/textual/box_model.py +++ b/src/textual/box_model.py @@ -54,11 +54,11 @@ def get_box_model( else: width = max(0, width - styles.margin.width) - if styles.min_width: + if has_rule("min_width"): min_width = styles.min_width.resolve_dimension(container_size, parent_size) width = max(width, min_width) - if styles.max_width: + if has_rule("max_width"): max_width = styles.max_width.resolve_dimension(container_size, parent_size) width = min(width, max_width) @@ -70,11 +70,11 @@ def get_box_model( else: height = max(0, height - styles.margin.height) - if styles.min_height: + if has_rule("min_height"): min_height = styles.min_height.resolve_dimension(container_size, parent_size) height = max(height, min_height) - if styles.max_height: + if has_rule("max_height"): max_height = styles.max_height.resolve_dimension(container_size, parent_size) height = min(width, max_height) diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index b4f013860..286c0dd2c 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -132,6 +132,7 @@ class RulesMap(TypedDict, total=False): RULE_NAMES = list(RulesMap.__annotations__.keys()) +RULE_NAMES_SET = frozenset(RULE_NAMES) _rule_getter = attrgetter(*RULE_NAMES) @@ -416,6 +417,7 @@ class Styles(StylesBase): return Styles(node=self.node, _rules=self.get_rules(), important=self.important) def has_rule(self, rule: str) -> bool: + assert rule in RULE_NAMES_SET, f"no such rule {rule!r}" return rule in self._rules def clear_rule(self, rule: str) -> bool: diff --git a/tests/test_geometry.py b/tests/test_geometry.py index 46f1517c8..923116251 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -317,6 +317,13 @@ def test_spacing_add(): Spacing(1, 2, 3, 4) + "foo" +def test_spacing_sub(): + assert Spacing(1, 2, 3, 4) - Spacing(5, 6, 7, 8) == Spacing(-4, -4, -4, -4) + + with pytest.raises(TypeError): + Spacing(1, 2, 3, 4) - "foo" + + def test_split(): assert Region(10, 5, 22, 15).split(10, 5) == ( Region(10, 5, 10, 5), From bc637233630155ca26f9cc8784cc91f28103aa86 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 21 Apr 2022 11:43:45 +0100 Subject: [PATCH 08/16] box model fixes and tests --- sandbox/align.css | 27 +++++++------- sandbox/basic.css | 3 +- src/textual/box_model.py | 56 ++++++++++++++-------------- src/textual/css/_style_properties.py | 1 - src/textual/layouts/horizontal.py | 7 ++-- src/textual/layouts/vertical.py | 7 ++-- src/textual/screen.py | 3 +- src/textual/widget.py | 11 ++---- 8 files changed, 56 insertions(+), 59 deletions(-) diff --git a/sandbox/align.css b/sandbox/align.css index a507ea1e0..f05ebabca 100644 --- a/sandbox/align.css +++ b/sandbox/align.css @@ -10,27 +10,27 @@ Widget { } #thing { + width: auto; - height: auto; - + height: 10; background:magenta; margin: 3; - padding: 1; - border: solid white; - box-sizing: content-box; - /* border: solid white; */ + padding: 1; + border: solid white; + box-sizing: border-box; + border: solid white; align-horizontal: center; } #thing2 { - width: auto; - height: 10; - /* border: solid white; */ - outline: heavy blue; - + border: solid white; + /* outline: heavy blue; */ + padding: 1 2; - box-sizing: content-box; + box-sizing: border-box; + + max-height: 100vh; background:green; align-horizontal: center; @@ -39,8 +39,7 @@ Widget { #thing3 { - width: 20; - height: 10; + margin: 1; background:blue; align-horizontal: center; diff --git a/sandbox/basic.css b/sandbox/basic.css index 113d99978..56a6f735a 100644 --- a/sandbox/basic.css +++ b/sandbox/basic.css @@ -201,7 +201,8 @@ Warning { Success { max-width: 80; - height:3; + height:3; + box-sizing: border-box; background: $success-lighten-3; color: $text-success-lighten-3-fade-1; border-top: hkey $success; diff --git a/src/textual/box_model.py b/src/textual/box_model.py index 5259248a6..87f9d116d 100644 --- a/src/textual/box_model.py +++ b/src/textual/box_model.py @@ -1,4 +1,5 @@ from __future__ import annotations +from operator import is_ from typing import Callable, NamedTuple, TYPE_CHECKING @@ -18,8 +19,8 @@ def get_box_model( styles: StylesBase, container_size: Size, parent_size: Size, - get_auto_width: Callable[[Size, Size, Spacing], int], - get_auto_height: Callable[[Size, Size, Spacing], int], + get_content_width: Callable[[Size, Size], int], + get_content_height: Callable[[Size, Size], int], ) -> BoxModel: """Resolve the box model for this Styles. @@ -36,23 +37,30 @@ def get_box_model( has_rule = styles.has_rule width, height = container_size + is_content_box = styles.box_sizing == "content-box" + gutter = styles.padding + styles.border.spacing - gutter = Spacing(0, 0, 0, 0) - if styles.box_sizing == "content-box": - if has_rule("padding"): - gutter += styles.padding - gutter += styles.border.spacing - - else: # border-box - gutter -= styles.border.spacing - - if has_rule("width"): - if styles.width.is_auto: - width = get_auto_width(container_size, parent_size, gutter) - else: - width = styles.width.resolve_dimension(container_size, parent_size) + if not has_rule("width") or styles.width.is_auto: + # When width is auto, we want enough space to always fit the content + width = get_content_width(container_size, parent_size) + if not is_content_box: + # If box sizing is border box we want to enlarge the width so that it + # can accommodate padding + border + width += gutter.width else: - width = max(0, width - styles.margin.width) + width = styles.width.resolve_dimension(container_size, parent_size) + + if not has_rule("height") or styles.height.is_auto: + height = get_content_height(container_size, parent_size) + if not is_content_box: + height += gutter.height + else: + height = styles.height.resolve_dimension(container_size, parent_size) + + if is_content_box: + gutter_width, gutter_height = gutter.totals + width += gutter_width + height += gutter_height if has_rule("min_width"): min_width = styles.min_width.resolve_dimension(container_size, parent_size) @@ -62,23 +70,15 @@ def get_box_model( max_width = styles.max_width.resolve_dimension(container_size, parent_size) width = min(width, max_width) - if has_rule("height"): - if styles.height.is_auto: - height = get_auto_height(container_size, parent_size, gutter) - else: - height = styles.height.resolve_dimension(container_size, parent_size) - else: - height = max(0, height - styles.margin.height) - if has_rule("min_height"): min_height = styles.min_height.resolve_dimension(container_size, parent_size) height = max(height, min_height) if has_rule("max_height"): max_height = styles.max_height.resolve_dimension(container_size, parent_size) - height = min(width, max_height) + height = min(height, max_height) - size = Size(width, height) + gutter.totals - margin = styles.margin if has_rule("margin") else Spacing(0, 0, 0, 0) + size = Size(width, height) + margin = styles.margin return BoxModel(size, margin) diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index b8add4019..ed424b012 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -8,7 +8,6 @@ when setting and getting. """ from __future__ import annotations -from tkinter.tix import AUTO from typing import Iterable, NamedTuple, TYPE_CHECKING, cast diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index 87ebd9c58..36fef9bd7 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -34,9 +34,10 @@ class HorizontalLayout(Layout): max((box1.margin.right, box2.margin.left)) for box1, box2 in zip(box_models, box_models[1:]) ] - margins.append(box_models[-1].margin.right) + if box_models: + margins.append(box_models[-1].margin.right) - x = box_models[0].margin.left + x = box_models[0].margin.left if box_models else 0 for widget, box_model, margin in zip(parent.children, box_models, margins): content_width, content_height = box_model.size @@ -47,7 +48,7 @@ class HorizontalLayout(Layout): x += region.width + margin max_width = x - max_width += margins[-1] + max_width += margins[-1] if margins else 0 total_region = Region(0, 0, max_width, max_height) add_placement(WidgetPlacement(total_region, None, 0)) diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 8c0638f2f..2752c4445 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -35,9 +35,10 @@ class VerticalLayout(Layout): max((box1.margin.bottom, box2.margin.top)) for box1, box2 in zip(box_models, box_models[1:]) ] - margins.append(box_models[-1].margin.bottom) + if box_models: + margins.append(box_models[-1].margin.bottom) - y = box_models[0].margin.top + y = box_models[0].margin.top if box_models else 0 for widget, box_model, margin in zip(parent.children, box_models, margins): content_width, content_height = box_model.size @@ -48,7 +49,7 @@ class VerticalLayout(Layout): y += region.height + margin max_height = y - max_height += margins[-1] + max_height += margins[-1] if margins else 0 total_region = Region(0, 0, max_width, max_height) add_placement(WidgetPlacement(total_region, None, 0)) diff --git a/src/textual/screen.py b/src/textual/screen.py index 2e11ed3ec..e348416fe 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -131,7 +131,8 @@ class Screen(Widget): ) ) except Exception as error: - self.app.panic(error) + self.app.on_exception(error) + return self.app.refresh() self._dirty_widgets.clear() diff --git a/src/textual/widget.py b/src/textual/widget.py index 4dae88aef..6040ddbe2 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -115,21 +115,16 @@ class Widget(DOMNode): self.get_content_width, self.get_content_height, ) - self.log(self, self.styles.padding, self.styles.border.spacing) return box_model - def get_content_width( - self, container_size: Size, parent_size: Size, gutter: Spacing - ) -> int: + def get_content_width(self, container_size: Size, parent_size: Size) -> int: console = self.app.console renderable = self.render() measurement = Measurement.get(console, console.options, renderable) return measurement.maximum - def get_content_height( - self, container_size: Size, parent_size: Size, gutter: Spacing - ) -> int: - return container_size.height - gutter.height + def get_content_height(self, container_size: Size, parent_size: Size) -> int: + return container_size.height async def watch_scroll_x(self, new_value: float) -> None: self.horizontal_scrollbar.position = int(new_value) From b25dbf877192188114cce2285fe0ff2284aa14fe Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 21 Apr 2022 11:51:38 +0100 Subject: [PATCH 09/16] box model tests --- sandbox/basic.css | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/sandbox/basic.css b/sandbox/basic.css index 56a6f735a..5d2446655 100644 --- a/sandbox/basic.css +++ b/sandbox/basic.css @@ -73,7 +73,8 @@ App > Screen { Tweet { height: 12; - max-width: 80; + width: 80; + margin: 1 3; background: $panel; color: $text-panel; @@ -86,6 +87,7 @@ Tweet { } .scrollable { + width: 80; overflow-y: scroll; max-width:80; height: 20; @@ -96,7 +98,7 @@ Tweet { .code { height: 34; - + width: 100%; } @@ -108,6 +110,7 @@ TweetHeader { } TweetBody { + width: 100%; background: $panel; color: $text-panel; height:20; @@ -175,7 +178,7 @@ OptionItem:hover { } Error { - max-width: 80; + width: 80; height:3; background: $error; color: $text-error; @@ -188,7 +191,7 @@ Error { } Warning { - max-width: 80; + width: 80; height:3; background: $warning; color: $text-warning-fade-1; @@ -200,7 +203,7 @@ Warning { } Success { - max-width: 80; + width: 80; height:3; box-sizing: border-box; background: $success-lighten-3; From 49804889735f6927e682dd8cb1763f6395c1c7e9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 21 Apr 2022 11:53:55 +0100 Subject: [PATCH 10/16] width 100% --- sandbox/uber.css | 1 + 1 file changed, 1 insertion(+) diff --git a/sandbox/uber.css b/sandbox/uber.css index 80f234300..70dc86714 100644 --- a/sandbox/uber.css +++ b/sandbox/uber.css @@ -8,6 +8,7 @@ .list-item { height: 8; + width:100%; min-width: 80; background: dark_blue; } From c68d6e8d0ed87851e99207febff287b03262a8c9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 21 Apr 2022 12:16:05 +0100 Subject: [PATCH 11/16] tests for box model --- sandbox/align.css | 4 +- sandbox/uber.css | 2 +- src/textual/box_model.py | 8 +- src/textual/css/styles.py | 8 +- tests/test_box_model.py | 182 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 195 insertions(+), 9 deletions(-) create mode 100644 tests/test_box_model.py diff --git a/sandbox/align.css b/sandbox/align.css index f05ebabca..f976925ed 100644 --- a/sandbox/align.css +++ b/sandbox/align.css @@ -26,7 +26,7 @@ Widget { #thing2 { border: solid white; /* outline: heavy blue; */ - + height: 10; padding: 1 2; box-sizing: border-box; @@ -39,7 +39,7 @@ Widget { #thing3 { - + height: 10; margin: 1; background:blue; align-horizontal: center; diff --git a/sandbox/uber.css b/sandbox/uber.css index 70dc86714..fcc11b51d 100644 --- a/sandbox/uber.css +++ b/sandbox/uber.css @@ -8,7 +8,7 @@ .list-item { height: 8; - width:100%; + min-width: 80; background: dark_blue; } diff --git a/src/textual/box_model.py b/src/textual/box_model.py index 87f9d116d..4d04957d3 100644 --- a/src/textual/box_model.py +++ b/src/textual/box_model.py @@ -40,7 +40,9 @@ def get_box_model( is_content_box = styles.box_sizing == "content-box" gutter = styles.padding + styles.border.spacing - if not has_rule("width") or styles.width.is_auto: + if not has_rule("width"): + width = container_size.width + elif styles.width.is_auto: # When width is auto, we want enough space to always fit the content width = get_content_width(container_size, parent_size) if not is_content_box: @@ -50,7 +52,9 @@ def get_box_model( else: width = styles.width.resolve_dimension(container_size, parent_size) - if not has_rule("height") or styles.height.is_auto: + if not has_rule("height"): + height = container_size.height + elif styles.height.is_auto: height = get_content_height(container_size, parent_size) if not is_content_box: height += gutter.height diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 286c0dd2c..d1ecbdd91 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -194,10 +194,10 @@ class StylesBase(ABC): box_sizing = StringEnumProperty(VALID_BOX_SIZING, "border-box") width = ScalarProperty(percent_unit=Unit.WIDTH) height = ScalarProperty(percent_unit=Unit.HEIGHT) - min_width = ScalarProperty(percent_unit=Unit.WIDTH) - min_height = ScalarProperty(percent_unit=Unit.HEIGHT) - max_width = ScalarProperty(percent_unit=Unit.WIDTH) - max_height = ScalarProperty(percent_unit=Unit.HEIGHT) + min_width = ScalarProperty(percent_unit=Unit.WIDTH, allow_auto=False) + min_height = ScalarProperty(percent_unit=Unit.HEIGHT, allow_auto=False) + max_width = ScalarProperty(percent_unit=Unit.WIDTH, allow_auto=False) + max_height = ScalarProperty(percent_unit=Unit.HEIGHT, allow_auto=False) dock = DockProperty() docks = DocksProperty() diff --git a/tests/test_box_model.py b/tests/test_box_model.py new file mode 100644 index 000000000..7020e3b98 --- /dev/null +++ b/tests/test_box_model.py @@ -0,0 +1,182 @@ +from __future__ import annotations + + +from textual.box_model import BoxModel, get_box_model +from textual.css.styles import Styles +from textual.geometry import Size, Spacing + + +def test_content_box(): + styles = Styles() + styles.width = 10 + styles.height = 8 + styles.padding = 1 + styles.border = ("solid", "red") + + # border-box is default + assert styles.box_sizing == "border-box" + + def get_auto_width(container: Size, parent: Size) -> int: + assert False, "must not be called" + + def get_auto_height(container: Size, parent: Size) -> int: + assert False, "must not be called" + + box_model = get_box_model( + styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height + ) + # Size should be inclusive of padding / border + assert box_model == BoxModel(Size(10, 8), Spacing(0, 0, 0, 0)) + + # Switch to content-box + styles.box_sizing = "content-box" + + box_model = get_box_model( + styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height + ) + # width and height have added padding / border to accommodate content + assert box_model == BoxModel(Size(14, 12), Spacing(0, 0, 0, 0)) + + +def test_width(): + """Test width settings.""" + styles = Styles() + + def get_auto_width(container: Size, parent: Size) -> int: + return 10 + + def get_auto_height(container: Size, parent: Size) -> int: + return 10 + + box_model = get_box_model( + styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height + ) + assert box_model == BoxModel(Size(60, 20), Spacing(0, 0, 0, 0)) + + # Add a margin and check that it is reported + styles.margin = (1, 2, 3, 4) + + box_model = get_box_model( + styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height + ) + assert box_model == BoxModel(Size(60, 20), Spacing(1, 2, 3, 4)) + + styles.width = "auto" + + box_model = get_box_model( + styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height + ) + # Setting width to auto should call get_auto_width + assert box_model == BoxModel(Size(10, 20), Spacing(1, 2, 3, 4)) + + # Set width to 100 vw which should make it the width of the parent + styles.width = "100vw" + + box_model = get_box_model( + styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height + ) + assert box_model == BoxModel(Size(80, 20), Spacing(1, 2, 3, 4)) + + # Set the width to 100% should make it fill the container size + styles.width = "100%" + + box_model = get_box_model( + styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height + ) + assert box_model == BoxModel(Size(60, 20), Spacing(1, 2, 3, 4)) + + styles.width = "100vw" + styles.max_width = "50%" + + box_model = get_box_model( + styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height + ) + assert box_model == BoxModel(Size(30, 20), Spacing(1, 2, 3, 4)) + + +def test_height(): + """Test width settings.""" + styles = Styles() + + def get_auto_width(container: Size, parent: Size) -> int: + return 10 + + def get_auto_height(container: Size, parent: Size) -> int: + return 10 + + box_model = get_box_model( + styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height + ) + assert box_model == BoxModel(Size(60, 20), Spacing(0, 0, 0, 0)) + + # Add a margin and check that it is reported + styles.margin = (1, 2, 3, 4) + + box_model = get_box_model( + styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height + ) + assert box_model == BoxModel(Size(60, 20), Spacing(1, 2, 3, 4)) + + # Set width to 100 vw which should make it the width of the parent + styles.height = "100vh" + + box_model = get_box_model( + styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height + ) + assert box_model == BoxModel(Size(60, 24), Spacing(1, 2, 3, 4)) + + # Set the width to 100% should make it fill the container size + styles.height = "100%" + + box_model = get_box_model( + styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height + ) + assert box_model == BoxModel(Size(60, 20), Spacing(1, 2, 3, 4)) + + styles.height = "100vh" + styles.max_height = "50%" + + box_model = get_box_model( + styles, Size(60, 20), Size(80, 24), get_auto_width, get_auto_height + ) + assert box_model == BoxModel(Size(60, 10), Spacing(1, 2, 3, 4)) + + +def test_max(): + """Check that max_width and max_height are respected.""" + styles = Styles() + styles.width = 100 + styles.height = 80 + styles.max_width = 40 + styles.max_height = 30 + + def get_auto_width(container: Size, parent: Size) -> int: + assert False, "must not be called" + + def get_auto_height(container: Size, parent: Size) -> int: + assert False, "must not be called" + + box_model = get_box_model( + styles, Size(40, 30), Size(80, 24), get_auto_width, get_auto_height + ) + assert box_model == BoxModel(Size(40, 30), Spacing(0, 0, 0, 0)) + + +def test_min(): + """Check that min_width and min_height are respected.""" + styles = Styles() + styles.width = 10 + styles.height = 5 + styles.min_width = 40 + styles.min_height = 30 + + def get_auto_width(container: Size, parent: Size) -> int: + assert False, "must not be called" + + def get_auto_height(container: Size, parent: Size) -> int: + assert False, "must not be called" + + box_model = get_box_model( + styles, Size(40, 30), Size(80, 24), get_auto_width, get_auto_height + ) + assert box_model == BoxModel(Size(40, 30), Spacing(0, 0, 0, 0)) From 1ede68c16992fd7b2f7cac630e0ce7f445ac6ba3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 21 Apr 2022 12:54:39 +0100 Subject: [PATCH 12/16] comment --- tests/test_box_model.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_box_model.py b/tests/test_box_model.py index 7020e3b98..8d11e56e6 100644 --- a/tests/test_box_model.py +++ b/tests/test_box_model.py @@ -61,6 +61,7 @@ def test_width(): ) assert box_model == BoxModel(Size(60, 20), Spacing(1, 2, 3, 4)) + # Set width to auto-detect styles.width = "auto" box_model = get_box_model( From 2dd9f804e41f104945e843d676a7f52ca39ea383 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 21 Apr 2022 13:10:41 +0100 Subject: [PATCH 13/16] fix shutdown --- src/textual/app.py | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 4826c55ec..7b135eb2f 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -9,7 +9,7 @@ import warnings from asyncio import AbstractEventLoop from contextlib import redirect_stdout from time import perf_counter -from typing import Any, Iterable, Type, TYPE_CHECKING +from typing import Any, Iterable, TextIO, Type, TYPE_CHECKING import rich import rich.repr @@ -101,7 +101,9 @@ class App(DOMNode): driver_class (Type[Driver], optional): Driver class, or None to use default. Defaults to None. title (str, optional): Title of the application. Defaults to "Textual Application". """ - self.console = Console(markup=False, highlight=False, emoji=False) + self.console = Console( + file=sys.__stdout__, markup=False, highlight=False, emoji=False + ) self.error_console = Console(markup=False, stderr=True) self._screen = screen self.driver_class = driver_class or self.get_driver_class() @@ -123,6 +125,7 @@ class App(DOMNode): self._title = title self._log_console: Console | None = None + self._log_file: TextIO | None = None if log: self._log_file = open(log, "wt") self._log_console = Console( @@ -132,9 +135,6 @@ class App(DOMNode): highlight=False, width=100, ) - else: - self._log_console = None - self._log_file = None self.log_verbosity = log_verbosity @@ -525,14 +525,10 @@ class App(DOMNode): self.title = self._title self.refresh() await self.animator.start() - 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()"): + + with redirect_stdout(StdoutRedirector(self.devtools, self._log_file)): # type: ignore + await super().process_messages() await self.animator.stop() - with timer("self.close_all()"): await self.close_all() finally: driver.stop_application_mode() @@ -542,6 +538,12 @@ 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() From e847f50ac29ce4381376cb140d51c29f5d0175f6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 22 Apr 2022 11:48:09 +0100 Subject: [PATCH 14/16] Update src/textual/box_model.py Co-authored-by: Darren Burns --- src/textual/box_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/box_model.py b/src/textual/box_model.py index 4d04957d3..ab6e4c0f0 100644 --- a/src/textual/box_model.py +++ b/src/textual/box_model.py @@ -27,7 +27,7 @@ def get_box_model( Args: styles (StylesBase): Styles object. container_size (Size): The size of the widget container. - parent_size (Size): The size widget's parent. + parent_size (Size): The size of the widget's parent. get_auto_width (Callable): A callable which accepts container size and parent size and returns a width. get_auto_height (Callable): A callable which accepts container size and parent size and returns a height. From cacadf544aaff65e2a4db542c00604584c5567ba Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 22 Apr 2022 13:51:08 +0100 Subject: [PATCH 15/16] renames and docstrings --- src/textual/box_model.py | 30 +++++++++++++++--------------- src/textual/widget.py | 15 ++++++++++++--- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/src/textual/box_model.py b/src/textual/box_model.py index ab6e4c0f0..1dc196164 100644 --- a/src/textual/box_model.py +++ b/src/textual/box_model.py @@ -17,8 +17,8 @@ class BoxModel(NamedTuple): def get_box_model( styles: StylesBase, - container_size: Size, - parent_size: Size, + container: Size, + viewport: Size, get_content_width: Callable[[Size, Size], int], get_content_height: Callable[[Size, Size], int], ) -> BoxModel: @@ -26,8 +26,8 @@ def get_box_model( Args: styles (StylesBase): Styles object. - container_size (Size): The size of the widget container. - parent_size (Size): The size of the widget's parent. + container (Size): The size of the widget container. + viewport (Size): The viewport size. get_auto_width (Callable): A callable which accepts container size and parent size and returns a width. get_auto_height (Callable): A callable which accepts container size and parent size and returns a height. @@ -36,30 +36,30 @@ def get_box_model( """ has_rule = styles.has_rule - width, height = container_size + width, height = container is_content_box = styles.box_sizing == "content-box" gutter = styles.padding + styles.border.spacing if not has_rule("width"): - width = container_size.width + width = container.width elif styles.width.is_auto: # When width is auto, we want enough space to always fit the content - width = get_content_width(container_size, parent_size) + width = get_content_width(container, viewport) if not is_content_box: # If box sizing is border box we want to enlarge the width so that it # can accommodate padding + border width += gutter.width else: - width = styles.width.resolve_dimension(container_size, parent_size) + width = styles.width.resolve_dimension(container, viewport) if not has_rule("height"): - height = container_size.height + height = container.height elif styles.height.is_auto: - height = get_content_height(container_size, parent_size) + height = get_content_height(container, viewport) if not is_content_box: height += gutter.height else: - height = styles.height.resolve_dimension(container_size, parent_size) + height = styles.height.resolve_dimension(container, viewport) if is_content_box: gutter_width, gutter_height = gutter.totals @@ -67,19 +67,19 @@ def get_box_model( height += gutter_height if has_rule("min_width"): - min_width = styles.min_width.resolve_dimension(container_size, parent_size) + min_width = styles.min_width.resolve_dimension(container, viewport) width = max(width, min_width) if has_rule("max_width"): - max_width = styles.max_width.resolve_dimension(container_size, parent_size) + max_width = styles.max_width.resolve_dimension(container, viewport) width = min(width, max_width) if has_rule("min_height"): - min_height = styles.min_height.resolve_dimension(container_size, parent_size) + min_height = styles.min_height.resolve_dimension(container, viewport) height = max(height, min_height) if has_rule("max_height"): - max_height = styles.max_height.resolve_dimension(container_size, parent_size) + max_height = styles.max_height.resolve_dimension(container, viewport) height = min(height, max_height) size = Size(width, height) diff --git a/src/textual/widget.py b/src/textual/widget.py index 6040ddbe2..817de899a 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -107,11 +107,20 @@ class Widget(DOMNode): show_vertical_scrollbar = Reactive(False, layout=True) show_horizontal_scrollbar = Reactive(False, layout=True) - def get_box_model(self, container_size: Size, parent_size: Size) -> BoxModel: + def get_box_model(self, container: Size, viewport: Size) -> BoxModel: + """Process the box model for this widget. + + Args: + container (Size): The size of the container widget (with a layout) + viewport (Size): The viewport size. + + Returns: + BoxModel: The size and margin for this widget. + """ box_model = get_box_model( self.styles, - container_size, - parent_size, + container, + viewport, self.get_content_width, self.get_content_height, ) From 74c354da29b310451186a8ab5af056c8ae5ff3e8 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 22 Apr 2022 13:53:23 +0100 Subject: [PATCH 16/16] docstring --- src/textual/css/scalar.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/css/scalar.py b/src/textual/css/scalar.py index 2b6dd719e..f7d193758 100644 --- a/src/textual/css/scalar.py +++ b/src/textual/css/scalar.py @@ -149,10 +149,10 @@ class Scalar(NamedTuple): viewport (tuple[int, int]): Size of the viewport (typically terminal size) Raises: - ScalarResolveError: _description_ + ScalarResolveError: If the unit is unknown. Returns: - float: _description_ + int: A size (in cells) """ value, unit, percent_unit = self