From 678a67ce2d4fa12ef3a0ca262072319f1d6c0d71 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 20 Jan 2022 11:39:56 +0000 Subject: [PATCH 01/23] Invisible widgets now dont render --- examples/dev_sandbox.css | 49 +++++++++++++++++++++++++ examples/dev_sandbox.py | 32 ++++++++++++++++ src/textual/dom.py | 18 ++++++++- src/textual/layout.py | 15 +++++++- src/textual/view.py | 16 +++----- src/textual/widget.py | 79 +++++++++++++++++----------------------- tests/test_widget.py | 28 ++++++++++++++ 7 files changed, 180 insertions(+), 57 deletions(-) create mode 100644 examples/dev_sandbox.css create mode 100644 examples/dev_sandbox.py create mode 100644 tests/test_widget.py diff --git a/examples/dev_sandbox.css b/examples/dev_sandbox.css new file mode 100644 index 000000000..2e348e134 --- /dev/null +++ b/examples/dev_sandbox.css @@ -0,0 +1,49 @@ +/* CSS file for dev_sandbox.py */ + +App > View { + docks: side=left/1; + text: on #20639b; +} + +Widget:hover { + outline: heavy; + text: bold !important; +} + +#sidebar { + text: #09312e on #3caea3; + dock: side; + width: 30; + offset-x: -100%; + transition: offset 500ms in_out_cubic; + border-right: outer #09312e; +} + +#sidebar.-active { + offset-x: 0; +} + +#header { + text: white on #173f5f; + height: 3; + border: hkey; +} + +#header.-visible { + visibility: hidden; +} + +#content { + text: white on #20639b; + border-bottom: hkey #0f2b41; + offset-y: -3; +} + +#content.-content-visible { + visibility: hidden; +} + +#footer { + text: #3a3009 on #f6d55c; + height: 3; +} diff --git a/examples/dev_sandbox.py b/examples/dev_sandbox.py new file mode 100644 index 000000000..d53946fb3 --- /dev/null +++ b/examples/dev_sandbox.py @@ -0,0 +1,32 @@ +from rich.console import RenderableType +from rich.panel import Panel + +from textual.app import App +from textual.widget import Widget + + +class PanelWidget(Widget): + def render(self) -> RenderableType: + return Panel("hello world!", title="Title") + + +class BasicApp(App): + """Sandbox application used for testing/development by Textual developers""" + + def on_load(self): + """Bind keys here.""" + self.bind("tab", "toggle_class('#sidebar', '-active')") + self.bind("a", "toggle_class('#header', '-visible')") + self.bind("c", "toggle_class('#content', '-content-visible')") + + def on_mount(self): + """Build layout here.""" + self.mount( + header=Widget(), + content=PanelWidget(), + footer=Widget(), + sidebar=Widget(), + ) + + +BasicApp.run(css_file="test_app.css", watch_css=True, log="textual.log") diff --git a/src/textual/dom.py b/src/textual/dom.py index 8b17700ed..566f62858 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -9,7 +9,7 @@ from rich.style import Style from rich.tree import Tree from .css._error_tools import friendly_list -from .css.constants import VALID_DISPLAY +from .css.constants import VALID_DISPLAY, VALID_VISIBILITY from .css.errors import StyleValueError from .css.styles import Styles from .message_pump import MessagePump @@ -160,6 +160,22 @@ class DOMNode(MessagePump): f"expected {friendly_list(VALID_DISPLAY)})", ) + @property + def visible(self) -> bool: + return self.styles.visibility != "hidden" + + @visible.setter + def visible(self, new_value: bool) -> None: + if isinstance(new_value, bool): + self.styles.visibility = "visible" if new_value else "hidden" + elif new_value in VALID_VISIBILITY: + self.styles.visibility = new_value + else: + raise StyleValueError( + f"invalid value for visibility (received {new_value!r}, " + f"expected {friendly_list(VALID_VISIBILITY)})" + ) + @property def z(self) -> tuple[int, ...]: """Get the z index tuple for this node. diff --git a/src/textual/layout.py b/src/textual/layout.py index 72f5e1422..28bb1dce5 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -199,6 +199,7 @@ class Layout(ABC): raise NoWidget(f"No widget under screen coordinate ({x}, {y})") def get_style_at(self, x: int, y: int) -> Style: + """Get the Style at the given cell or Style.null()""" try: widget, region = self.get_widget_at(x, y) except NoWidget: @@ -217,6 +218,18 @@ class Layout(ABC): return Style.null() def get_widget_region(self, widget: Widget) -> Region: + """Get the Region of a Widget contained in this Layout. + + Args: + widget: The Widget in this layout you wish to know the Region of. + + Raises: + KeyError: If the Widget is not contained in this Layout. + + Returns: + Region: The Region of the Widget. + + """ try: region, *_ = self.map[widget] except KeyError: @@ -270,7 +283,7 @@ class Layout(ABC): for widget, region, _order, clip in widget_regions: - if not widget.is_visual: + if not (widget.is_visual and widget.visible): continue lines = widget._get_lines() diff --git a/src/textual/view.py b/src/textual/view.py index d6cd03784..c91e8526f 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -1,26 +1,22 @@ from __future__ import annotations -from itertools import chain -from typing import Callable, Iterable, ClassVar, TYPE_CHECKING +from typing import Callable, Iterable, TYPE_CHECKING -from rich.console import RenderableType import rich.repr +from rich.console import RenderableType from rich.style import Style -from . import events from . import errors -from . import log +from . import events from . import messages +from .geometry import Size, Offset, Region from .layout import Layout, NoWidget, WidgetPlacement from .layouts.factory import get_layout -from .geometry import Size, Offset, Region from .reactive import Reactive, watch - -from .widget import Widget, Widget - +from .widget import Widget if TYPE_CHECKING: - from .app import App + pass class LayoutProperty: diff --git a/src/textual/widget.py b/src/textual/widget.py index 51c7e97ab..94d768129 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1,6 +1,6 @@ from __future__ import annotations -from logging import PercentStyle, getLogger +from logging import getLogger from typing import ( Any, Awaitable, @@ -11,35 +11,29 @@ from typing import ( NamedTuple, cast, ) -import rich.repr -from rich import box -from rich.align import Align -from rich.console import Console, RenderableType, ConsoleOptions -from rich.measure import Measurement -from rich.panel import Panel -from rich.padding import Padding -from rich.pretty import Pretty -from rich.segment import Segment -from rich.style import Style, StyleType -from rich.styled import Styled -from rich.text import Text, TextType -from . import events +import rich.repr +from rich.align import Align +from rich.console import Console, RenderableType +from rich.padding import Padding +from rich.style import Style +from rich.styled import Styled +from rich.text import Text + from . import errors +from . import events from ._animator import BoundAnimator -from ._border import Border, BORDER_STYLES +from ._border import Border from ._callback import invoke -from .blank import Blank -from .dom import DOMNode from ._context import active_app -from .geometry import Size, Spacing, SpacingDimensions +from ._types import Lines +from .dom import DOMNode +from .geometry import Size, Spacing from .message import Message from .messages import Layout, Update -from .reactive import Reactive, watch -from ._types import Lines +from .reactive import watch if TYPE_CHECKING: - from .app import App from .view import View log = getLogger("rich") @@ -163,34 +157,29 @@ class Widget(DOMNode): styles = self.styles parent_text_style = self.parent.text_style - if styles.visibility == "hidden": - renderable = Blank(parent_text_style) - else: - text_style = styles.text - renderable_text_style = parent_text_style + text_style - if renderable_text_style: - renderable = Styled(renderable, renderable_text_style) + text_style = styles.text + renderable_text_style = parent_text_style + text_style + if renderable_text_style: + renderable = Styled(renderable, renderable_text_style) - if styles.has_padding: - renderable = Padding( - renderable, styles.padding, style=renderable_text_style - ) + if styles.has_padding: + renderable = Padding( + renderable, styles.padding, style=renderable_text_style + ) - if styles.has_border: - renderable = Border( - renderable, styles.border, style=renderable_text_style - ) + if styles.has_border: + renderable = Border(renderable, styles.border, style=renderable_text_style) - if styles.has_margin: - renderable = Padding(renderable, styles.margin, style=parent_text_style) + if styles.has_margin: + renderable = Padding(renderable, styles.margin, style=parent_text_style) - if styles.has_outline: - renderable = Border( - renderable, - styles.outline, - outline=True, - style=renderable_text_style, - ) + if styles.has_outline: + renderable = Border( + renderable, + styles.outline, + outline=True, + style=renderable_text_style, + ) return renderable diff --git a/tests/test_widget.py b/tests/test_widget.py new file mode 100644 index 000000000..7eee43a54 --- /dev/null +++ b/tests/test_widget.py @@ -0,0 +1,28 @@ +import pytest + +from textual.css.errors import StyleValueError +from textual.widget import Widget + + +@pytest.mark.parametrize( + "set_val, get_val, style_str", [ + [True, True, "visible"], + [False, False, "hidden"], + ["hidden", False, "hidden"], + ["visible", True, "visible"], + ]) +def test_widget_set_visible_true(set_val, get_val, style_str): + widget = Widget() + widget.visible = set_val + + assert widget.visible is get_val + assert widget.styles.visibility == style_str + + +def test_widget_set_visible_invalid_string(): + widget = Widget() + + with pytest.raises(StyleValueError): + widget.visible = "nope! no widget for me!" + + assert widget.visible From 1e92b08e1b67900605fcc3851e93e62b5f7a01f9 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 20 Jan 2022 13:05:58 +0000 Subject: [PATCH 02/23] Docstring improvements --- src/textual/layout.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/textual/layout.py b/src/textual/layout.py index 28bb1dce5..b07a96424 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -199,7 +199,15 @@ class Layout(ABC): raise NoWidget(f"No widget under screen coordinate ({x}, {y})") def get_style_at(self, x: int, y: int) -> Style: - """Get the Style at the given cell or Style.null()""" + """Get the Style at the given cell or Style.null() + + Args: + x (int): X position within the Layout + y (int): Y position within the Layout + + Returns: + Style: The Style at the cell (x, y) within the Layout + """ try: widget, region = self.get_widget_at(x, y) except NoWidget: @@ -221,7 +229,7 @@ class Layout(ABC): """Get the Region of a Widget contained in this Layout. Args: - widget: The Widget in this layout you wish to know the Region of. + widget (Widget): The Widget in this layout you wish to know the Region of. Raises: KeyError: If the Widget is not contained in this Layout. From e2fd92bda228c9f62e49580ac8b5fb1d5f97a154 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 20 Jan 2022 14:47:37 +0000 Subject: [PATCH 03/23] Margin space is now not rendered as solid colour --- src/textual/css/_styles_builder.py | 2 +- src/textual/geometry.py | 18 ++++++++++++++++++ src/textual/view.py | 19 ++++++++++++++++++- src/textual/widget.py | 3 --- tests/test_geometry.py | 8 +++++++- 5 files changed, 44 insertions(+), 6 deletions(-) diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index 17349a04f..871d21f39 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -128,7 +128,7 @@ class StylesBuilder: append = space.append for token in tokens: (token_name, value, _, _, location) = token - if token_name == "scalar": + if token_name in ("number", "scalar"): try: append(int(value)) except ValueError: diff --git a/src/textual/geometry.py b/src/textual/geometry.py index b9fcb2f52..d89075397 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -415,6 +415,24 @@ class Region(NamedTuple): ) return new_region + def shrink(self, margin: Spacing) -> Region: + """Shrink a region by pushing each edge inwards. + + Args: + margin (Spacing): Defines how many cells to shrink the Region by at each edge. + + Returns: + Region: The new, smaller region. + """ + _clamp = clamp + top, right, bottom, left = margin + return Region( + x=_clamp(self.x + left, 0, self.width), + y=_clamp(self.y + top, 0, self.height), + width=_clamp(self.width - left - right, 0, self.width), + height=_clamp(self.height - top - bottom, 0, self.height), + ) + def intersection(self, region: Region) -> Region: """Get that covers both regions. diff --git a/src/textual/view.py b/src/textual/view.py index d6cd03784..2eb589352 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -114,7 +114,24 @@ class View(Widget): cached_size, cached_scroll, arrangement = self._cached_arrangement if cached_size == size and cached_scroll == scroll: return arrangement - arrangement = list(self._layout.arrange(self, size, scroll)) + + arrangement = [] + placements = self._layout.arrange(self, size, scroll) + for placement in placements: + region, widget, order = placement + styles = widget.styles + if styles.has_margin: + margin = styles.margin + arrangement.append( + WidgetPlacement( + region=region.shrink(margin), + widget=widget, + order=order, + ) + ) + else: + arrangement.append(placement) + self._cached_arrangement = (size, scroll, arrangement) return arrangement diff --git a/src/textual/widget.py b/src/textual/widget.py index 51c7e97ab..0f81a7d15 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -181,9 +181,6 @@ class Widget(DOMNode): renderable, styles.border, style=renderable_text_style ) - if styles.has_margin: - renderable = Padding(renderable, styles.margin, style=parent_text_style) - if styles.has_outline: renderable = Border( renderable, diff --git a/tests/test_geometry.py b/tests/test_geometry.py index ffaded07b..2bfbc695d 100644 --- a/tests/test_geometry.py +++ b/tests/test_geometry.py @@ -1,6 +1,6 @@ import pytest -from textual.geometry import clamp, Offset, Size, Region +from textual.geometry import clamp, Offset, Size, Region, Spacing def test_dimensions_region(): @@ -173,6 +173,12 @@ def test_clip(): assert Region(10, 10, 20, 30).clip(20, 25) == Region(10, 10, 10, 15) +def test_region_shrink(): + margin = Spacing(top=1, right=2, bottom=3, left=4) + region = Region(x=10, y=10, width=50, height=50) + assert region.shrink(margin) == Region(x=14, y=11, width=44, height=46) + + def test_region_intersection(): assert Region(0, 0, 100, 50).intersection(Region(10, 10, 10, 10)) == Region( 10, 10, 10, 10 From 8b24a476f097bcc2ec387ed54c2fadd8b451cbc5 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 20 Jan 2022 14:50:35 +0000 Subject: [PATCH 04/23] Remove redundant code, fix incorrect docstring --- src/textual/layout.py | 2 +- src/textual/view.py | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/textual/layout.py b/src/textual/layout.py index b07a96424..8542e5942 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -232,7 +232,7 @@ class Layout(ABC): widget (Widget): The Widget in this layout you wish to know the Region of. Raises: - KeyError: If the Widget is not contained in this Layout. + NoWidget: If the Widget is not contained in this Layout. Returns: Region: The Region of the Widget. diff --git a/src/textual/view.py b/src/textual/view.py index c91e8526f..9c07ca110 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -15,9 +15,6 @@ from .layouts.factory import get_layout from .reactive import Reactive, watch from .widget import Widget -if TYPE_CHECKING: - pass - class LayoutProperty: def __get__(self, obj: View, objtype: type[View] | None = None) -> str: From 13d7580291882c22acbb65c514c2edb42edb2a60 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 20 Jan 2022 15:04:37 +0000 Subject: [PATCH 05/23] Remove unused import --- src/textual/view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/view.py b/src/textual/view.py index 9c07ca110..220551eb7 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Callable, Iterable, TYPE_CHECKING +from typing import Callable, Iterable import rich.repr from rich.console import RenderableType From c59dff5c1577a3b70b7e961c43969c465d3b8d58 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 20 Jan 2022 17:12:00 +0000 Subject: [PATCH 06/23] Move logic for applying margin to widgets into WidgetPlacement --- examples/basic.py | 2 +- src/textual/geometry.py | 9 +++++---- src/textual/layout.py | 11 +++++++++++ src/textual/view.py | 24 ++++++------------------ 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/examples/basic.py b/examples/basic.py index d06ac0765..d16baab6a 100644 --- a/examples/basic.py +++ b/examples/basic.py @@ -13,10 +13,10 @@ class BasicApp(App): def on_mount(self): """Build layout here.""" self.mount( + sidebar=Widget(), header=Widget(), content=Widget(), footer=Widget(), - sidebar=Widget(), ) diff --git a/src/textual/geometry.py b/src/textual/geometry.py index d89075397..004964ba9 100644 --- a/src/textual/geometry.py +++ b/src/textual/geometry.py @@ -426,11 +426,12 @@ class Region(NamedTuple): """ _clamp = clamp top, right, bottom, left = margin + x, y, width, height = self return Region( - x=_clamp(self.x + left, 0, self.width), - y=_clamp(self.y + top, 0, self.height), - width=_clamp(self.width - left - right, 0, self.width), - height=_clamp(self.height - top - bottom, 0, self.height), + x=_clamp(x + left, 0, width), + y=_clamp(y + top, 0, height), + width=_clamp(width - left - right, 0, width), + height=_clamp(height - top - bottom, 0, height), ) def intersection(self, region: Region) -> Region: diff --git a/src/textual/layout.py b/src/textual/layout.py index 72f5e1422..54c1eac32 100644 --- a/src/textual/layout.py +++ b/src/textual/layout.py @@ -55,6 +55,17 @@ class WidgetPlacement(NamedTuple): widget: Widget | None = None order: int = 0 + def apply_margin(self) -> "WidgetPlacement": + region, widget, order = self + styles = widget.styles + if styles.has_margin: + return WidgetPlacement( + region=region.shrink(styles.margin), + widget=widget, + order=order, + ) + return self + @rich.repr.auto class LayoutUpdate: diff --git a/src/textual/view.py b/src/textual/view.py index 2eb589352..b680e4a33 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -115,25 +115,13 @@ class View(Widget): if cached_size == size and cached_scroll == scroll: return arrangement - arrangement = [] - placements = self._layout.arrange(self, size, scroll) - for placement in placements: - region, widget, order = placement - styles = widget.styles - if styles.has_margin: - margin = styles.margin - arrangement.append( - WidgetPlacement( - region=region.shrink(margin), - widget=widget, - order=order, - ) - ) - else: - arrangement.append(placement) + placements = [ + placement.apply_margin() + for placement in self._layout.arrange(self, size, scroll) + ] - self._cached_arrangement = (size, scroll, arrangement) - return arrangement + self._cached_arrangement = (size, scroll, placements) + return placements async def handle_update(self, message: messages.Update) -> None: if self.is_root_view: From 8406718cf3ec41cd6a83929c68f8b8df05c36b89 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Thu, 20 Jan 2022 17:16:26 +0000 Subject: [PATCH 07/23] Revert basic.py back to previous state --- examples/basic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/basic.py b/examples/basic.py index d16baab6a..d06ac0765 100644 --- a/examples/basic.py +++ b/examples/basic.py @@ -13,10 +13,10 @@ class BasicApp(App): def on_mount(self): """Build layout here.""" self.mount( - sidebar=Widget(), header=Widget(), content=Widget(), footer=Widget(), + sidebar=Widget(), ) From 5773e39845839146bef5d1a94c13da2661736ee0 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Fri, 21 Jan 2022 12:33:35 +0000 Subject: [PATCH 08/23] Move LayoutProperty into styles, add some comments --- examples/basic.css | 1 + src/textual/app.py | 2 +- src/textual/css/_style_properties.py | 17 +++++++++++++++++ src/textual/css/styles.py | 7 ++++++- src/textual/css/stylesheet.py | 12 ++++++++++-- src/textual/view.py | 19 ++----------------- 6 files changed, 37 insertions(+), 21 deletions(-) diff --git a/examples/basic.css b/examples/basic.css index efafe797e..279f6e520 100644 --- a/examples/basic.css +++ b/examples/basic.css @@ -1,6 +1,7 @@ /* CSS file for basic.py */ App > View { + layout: dock; docks: side=left/1; text: on #20639b; } diff --git a/src/textual/app.py b/src/textual/app.py index 0e6dee254..6f181cf89 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -510,7 +510,7 @@ class App(DOMNode): # Handle input events that haven't been forwarded # If the event has been forwarded it may have bubbled up back to the App if isinstance(event, events.Mount): - view = View() + view = View(id="_root") self.register(self, view) await self.push_view(view) await super().on_event(event) diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 3ed32ee8b..fdb9b31a9 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -28,6 +28,7 @@ from .constants import NULL_SPACING from .errors import StyleTypeError, StyleValueError from .transition import Transition from ._error_tools import friendly_list +from ..layouts.factory import get_layout if TYPE_CHECKING: from .styles import Styles @@ -290,6 +291,22 @@ class DockProperty: return spacing +class LayoutProperty: + def __set_name__(self, owner: Styles, name: str) -> None: + self._internal_name = f"_rule_{name}" + + def __get__(self, obj: Styles, objtype: type[Styles] | None = None) -> str: + return getattr(obj, self._internal_name) or "" + + def __set__(self, obj: Styles, layout: str | Styles): + obj.refresh(True) + if isinstance(layout, str): + new_layout = get_layout(layout) + else: + new_layout = layout + self._layout = new_layout + + class OffsetProperty: def __set_name__(self, owner: Styles, name: str) -> None: self._internal_name = f"_rule_{name}" diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 533167347..09510ae78 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -42,6 +42,7 @@ from ._style_properties import ( StyleProperty, StyleFlagsProperty, TransitionsProperty, + LayoutProperty, ) from .types import Display, Edge, Visibility @@ -110,7 +111,7 @@ class Styles: display = StringProperty(VALID_DISPLAY, "block") visibility = StringProperty(VALID_VISIBILITY, "visible") - layout = StringProperty(VALID_LAYOUT, "dock") + layout = LayoutProperty() text = StyleProperty() text_color = ColorProperty() @@ -279,6 +280,10 @@ class Styles: ) else: setattr(styles, f"_rule_{key}", value) + + if self.node.id == "_root": + log("_root.styles.layout =", self.node.styles.layout) + if self.node is not None: self.node.on_style_change() diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 05704b782..802c86fbb 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -108,18 +108,24 @@ class Stylesheet: yield selector_set.specificity def apply(self, node: DOMNode) -> None: + # Dictionary of rule attribute names e.g. "text_background" to list of tuples. + # The tuples contain the rule specificity, and the value for that rule. + # We can use this to determine, for a given rule, whether we should apply it + # or not by examining the specificity. If we have two rules for the + # same attribute, then we can choose the most specific rule and use that. rule_attributes: dict[str, list[tuple[Specificity4, object]]] rule_attributes = defaultdict(list) _check_rule = self._check_rule + # TODO: The line below breaks inline styles and animations node.styles.reset() - # Get the default node CSS rules + # Collect default node CSS rules for key, default_specificity, value in node._default_rules: rule_attributes[key].append((default_specificity, value)) - # Apply styles on top of the default node CSS rules + # Collect the rules defined in the stylesheet for rule in self.rules: for specificity in _check_rule(rule, node): for key, rule_specificity, value in rule.styles.extract_rules( @@ -127,12 +133,14 @@ class Stylesheet: ): rule_attributes[key].append((rule_specificity, value)) + # For each rule declared for this node, keep only the most specific one get_first_item = itemgetter(0) node_rules = [ (name, max(specificity_rules, key=get_first_item)[1]) for name, specificity_rules in rule_attributes.items() ] + log(node.id, node_rules) node.styles.apply_rules(node_rules) def update(self, root: DOMNode) -> None: diff --git a/src/textual/view.py b/src/textual/view.py index d6cd03784..f4f17e92f 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -16,26 +16,13 @@ from .layouts.factory import get_layout from .geometry import Size, Offset, Region from .reactive import Reactive, watch -from .widget import Widget, Widget +from .widget import Widget if TYPE_CHECKING: from .app import App -class LayoutProperty: - def __get__(self, obj: View, objtype: type[View] | None = None) -> str: - return obj._layout.name - - def __set__(self, obj: View, layout: str | Layout) -> str: - if isinstance(layout, str): - new_layout = get_layout(layout) - else: - new_layout = layout - self._layout = new_layout - return self._layout.name - - @rich.repr.auto class View(Widget): @@ -45,7 +32,7 @@ class View(Widget): """ def __init__(self, name: str | None = None, id: str | None = None) -> None: - + # TODO: Get rid of this, replace usages with layout from Styles object from .layouts.dock import DockLayout self._layout: Layout = DockLayout() @@ -69,8 +56,6 @@ class View(Widget): cls.layout_factory = layout super().__init_subclass__(**kwargs) - layout = LayoutProperty() - background: Reactive[str] = Reactive("") scroll_x: Reactive[int] = Reactive(0) scroll_y: Reactive[int] = Reactive(0) From c6429e51acfcf805aa39ce962b8b1bd49d739b23 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Fri, 21 Jan 2022 13:18:39 +0000 Subject: [PATCH 09/23] Fixing transitive imports and circular imports --- src/textual/app.py | 43 ++++++++++------------------ src/textual/css/_style_properties.py | 3 +- src/textual/css/model.py | 7 +++-- src/textual/dom.py | 8 ++---- src/textual/layouts/dock.py | 9 +----- src/textual/layouts/factory.py | 15 ++++------ src/textual/layouts/grid.py | 8 ++---- src/textual/layouts/vertical.py | 1 - src/textual/scrollbar.py | 10 +++---- src/textual/view.py | 1 - src/textual/widget.py | 35 ++++++++++------------ src/textual/widgets/_placeholder.py | 3 +- 12 files changed, 56 insertions(+), 87 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 6f181cf89..afdb99892 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1,50 +1,44 @@ from __future__ import annotations -import os +import os import asyncio -from typing import Any, Callable, ClassVar, Iterable, Type, TypeVar import warnings - -from rich.control import Control +from typing import Any, Callable, Iterable, Type, TypeVar import rich.repr -from rich.screen import Screen from rich.console import Console, RenderableType +from rich.control import Control from rich.measure import Measurement - +from rich.screen import Screen from rich.traceback import Traceback - -from . import events from . import actions -from .dom import DOMNode -from ._animator import Animator -from .binding import Bindings, NoBinding -from .geometry import Offset, Region, Size +from . import events from . import log +from . import messages +from ._animator import Animator from ._callback import invoke from ._context import active_app -from .css.stylesheet import Stylesheet, StylesheetParseError, StylesheetError from ._event_broker import extract_handler_actions, NoHandler +from ._linux_driver import LinuxDriver +from ._profile import timer +from .binding import Bindings, NoBinding +from .css.stylesheet import Stylesheet, StylesheetParseError, StylesheetError +from .dom import DOMNode from .driver import Driver from .file_monitor import FileMonitor - +from .geometry import Offset, Region, Size from .layouts.dock import DockLayout, Dock -from ._linux_driver import LinuxDriver -from ._types import MessageTarget -from . import messages from .message_pump import MessagePump -from ._profile import timer +from .reactive import Reactive from .view import View from .views import DockView -from .widget import Widget, Reactive - +from .widget import Widget # asyncio will warn against resources not being cleared warnings.simplefilter("always", ResourceWarning) - LayoutDefinition = "dict[str, Any]" ViewType = TypeVar("ViewType", bound=View) @@ -638,17 +632,11 @@ class App(DOMNode): if __name__ == "__main__": import asyncio - from logging import FileHandler - - from rich.panel import Panel from .widgets import Header from .widgets import Footer from .widgets import Placeholder - from .scrollbar import ScrollBar - - from rich.markdown import Markdown # from .widgets.scroll_view import ScrollView @@ -672,7 +660,6 @@ if __name__ == "__main__": self.show_bar = not self.show_bar async def on_mount(self, event: events.Mount) -> None: - view = await self.push_view(DockView()) header = Header() diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index fdb9b31a9..5b1c11307 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -28,7 +28,6 @@ from .constants import NULL_SPACING from .errors import StyleTypeError, StyleValueError from .transition import Transition from ._error_tools import friendly_list -from ..layouts.factory import get_layout if TYPE_CHECKING: from .styles import Styles @@ -299,6 +298,8 @@ class LayoutProperty: return getattr(obj, self._internal_name) or "" def __set__(self, obj: Styles, layout: str | Styles): + from ..layouts.factory import get_layout + obj.refresh(True) if isinstance(layout, str): new_layout = get_layout(layout) diff --git a/src/textual/css/model.py b/src/textual/css/model.py index 2dd7a5cc0..64dfb4ef6 100644 --- a/src/textual/css/model.py +++ b/src/textual/css/model.py @@ -5,14 +5,15 @@ import rich.repr from dataclasses import dataclass, field from enum import Enum -from typing import Iterable +from typing import Iterable, TYPE_CHECKING -from .. import log -from ..dom import DOMNode from .styles import Styles from .tokenize import Token from .types import Specificity3 +if TYPE_CHECKING: + from ..dom import DOMNode + class SelectorType(Enum): UNIVERSAL = 1 diff --git a/src/textual/dom.py b/src/textual/dom.py index 8b17700ed..50b96675d 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -1,24 +1,22 @@ from __future__ import annotations -from typing import Any, cast, Iterable, Iterator, TYPE_CHECKING +from typing import Iterable, Iterator, TYPE_CHECKING -from rich.highlighter import ReprHighlighter import rich.repr +from rich.highlighter import ReprHighlighter from rich.pretty import Pretty from rich.style import Style from rich.tree import Tree +from ._node_list import NodeList from .css._error_tools import friendly_list from .css.constants import VALID_DISPLAY from .css.errors import StyleValueError from .css.styles import Styles from .message_pump import MessagePump -from ._node_list import NodeList - if TYPE_CHECKING: from .css.query import DOMQuery - from .widget import Widget class NoParent(Exception): diff --git a/src/textual/layouts/dock.py b/src/textual/layouts/dock.py index 16b4f152b..281c53f5f 100644 --- a/src/textual/layouts/dock.py +++ b/src/textual/layouts/dock.py @@ -5,14 +5,10 @@ from collections import defaultdict from dataclasses import dataclass from typing import Iterable, TYPE_CHECKING, NamedTuple, Sequence - -from .. import log -from ..dom import DOMNode from .._layout_resolve import layout_resolve +from ..css.types import Edge from ..geometry import Offset, Region, Size from ..layout import Layout, WidgetPlacement -from ..layout_map import LayoutMap -from ..css.types import Edge from ..widget import Widget if sys.version_info >= (3, 8): @@ -20,12 +16,9 @@ if sys.version_info >= (3, 8): else: from typing_extensions import Literal - if TYPE_CHECKING: - from ..widget import Widget from ..view import View - DockEdge = Literal["top", "right", "bottom", "left"] diff --git a/src/textual/layouts/factory.py b/src/textual/layouts/factory.py index a6718f427..fb491e578 100644 --- a/src/textual/layouts/factory.py +++ b/src/textual/layouts/factory.py @@ -1,19 +1,15 @@ -from __future__ import annotations +from ..layouts.dock import DockLayout +from ..layouts.grid import GridLayout +from ..layouts.vertical import VerticalLayout -from ..layout import Layout -from .dock import DockLayout -from .grid import GridLayout -from .vertical import VerticalLayout +LAYOUT_MAP = {"dock": DockLayout, "grid": GridLayout, "vertical": VerticalLayout} class MissingLayout(Exception): pass -LAYOUT_MAP = {"dock": DockLayout, "grid": GridLayout, "vertical": VerticalLayout} - - -def get_layout(name: str) -> Layout: +def get_layout(name: str): """Get a named layout object. Args: @@ -25,6 +21,7 @@ def get_layout(name: str) -> Layout: Returns: Layout: A layout object. """ + layout_class = LAYOUT_MAP.get(name) if layout_class is None: raise MissingLayout("no layout called {name!r}") diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index f53127190..276cd417f 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -1,18 +1,16 @@ from __future__ import annotations +import sys from collections import defaultdict from dataclasses import dataclass -from operator import itemgetter -from logging import getLogger from itertools import cycle, product -import sys +from logging import getLogger +from operator import itemgetter from typing import Iterable, NamedTuple, TYPE_CHECKING from .._layout_resolve import layout_resolve from ..geometry import Size, Offset, Region from ..layout import Layout, WidgetPlacement -from ..widget import Widget - if TYPE_CHECKING: from ..widget import Widget diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py index 247909c67..75a54df73 100644 --- a/src/textual/layouts/vertical.py +++ b/src/textual/layouts/vertical.py @@ -4,7 +4,6 @@ from typing import Iterable, TYPE_CHECKING from ..geometry import Offset, Region, Size, Spacing, SpacingDimensions from ..layout import Layout, WidgetPlacement -from ..widget import Widget from .._loop import loop_last if TYPE_CHECKING: diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index 3a5590b0f..d932359d7 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -1,17 +1,17 @@ from __future__ import annotations - import rich.repr from rich.color import Color -from rich.console import Console, ConsoleOptions, RenderResult, RenderableType -from rich.segment import Segment, Segments +from rich.console import ConsoleOptions, RenderResult, RenderableType +from rich.segment import Segment from rich.style import Style, StyleType +from textual.reactive import Reactive from . import events -from .geometry import Offset from ._types import MessageTarget +from .geometry import Offset from .message import Message -from .widget import Reactive, Widget +from .widget import Widget @rich.repr.auto diff --git a/src/textual/view.py b/src/textual/view.py index f4f17e92f..3d7df4112 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -12,7 +12,6 @@ from . import errors from . import log from . import messages from .layout import Layout, NoWidget, WidgetPlacement -from .layouts.factory import get_layout from .geometry import Size, Offset, Region from .reactive import Reactive, watch diff --git a/src/textual/widget.py b/src/textual/widget.py index 51c7e97ab..b816abda3 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1,6 +1,6 @@ from __future__ import annotations -from logging import PercentStyle, getLogger +from logging import getLogger from typing import ( Any, Awaitable, @@ -11,35 +11,30 @@ from typing import ( NamedTuple, cast, ) -import rich.repr -from rich import box -from rich.align import Align -from rich.console import Console, RenderableType, ConsoleOptions -from rich.measure import Measurement -from rich.panel import Panel -from rich.padding import Padding -from rich.pretty import Pretty -from rich.segment import Segment -from rich.style import Style, StyleType -from rich.styled import Styled -from rich.text import Text, TextType -from . import events +import rich.repr +from rich.align import Align +from rich.console import Console, RenderableType +from rich.padding import Padding +from rich.style import Style +from rich.styled import Styled +from rich.text import Text + from . import errors +from . import events from ._animator import BoundAnimator -from ._border import Border, BORDER_STYLES +from ._border import Border from ._callback import invoke +from ._context import active_app +from ._types import Lines from .blank import Blank from .dom import DOMNode -from ._context import active_app -from .geometry import Size, Spacing, SpacingDimensions +from .geometry import Size, Spacing from .message import Message from .messages import Layout, Update -from .reactive import Reactive, watch -from ._types import Lines +from .reactive import watch if TYPE_CHECKING: - from .app import App from .view import View log = getLogger("rich") diff --git a/src/textual/widgets/_placeholder.py b/src/textual/widgets/_placeholder.py index a0946b4fc..9fa95bf0d 100644 --- a/src/textual/widgets/_placeholder.py +++ b/src/textual/widgets/_placeholder.py @@ -10,7 +10,8 @@ import rich.repr from logging import getLogger from .. import events -from ..widget import Reactive, Widget +from ..reactive import Reactive +from ..widget import Widget log = getLogger("rich") From 9c2a125c2412c5d011307a80f4552cf9824cc022 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Fri, 21 Jan 2022 13:46:56 +0000 Subject: [PATCH 10/23] Ensuring we get and set Layout as set in view.styles everywhere --- src/textual/css/_style_properties.py | 29 ++++++------ src/textual/css/_styles_builder.py | 3 +- src/textual/css/styles.py | 43 +++++++---------- src/textual/layouts/factory.py | 6 ++- src/textual/view.py | 70 +++++++++++++++------------- src/textual/views/_dock_view.py | 8 ++-- src/textual/views/_window_view.py | 8 ++-- src/textual/widgets/_scroll_view.py | 12 ++--- 8 files changed, 91 insertions(+), 88 deletions(-) diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 5b1c11307..3068fbead 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -15,6 +15,9 @@ import rich.repr from rich.color import Color from rich.style import Style +from ._error_tools import friendly_list +from .constants import NULL_SPACING +from .errors import StyleTypeError, StyleValueError from .scalar import ( get_symbols, UNIT_SYMBOL, @@ -23,15 +26,14 @@ from .scalar import ( ScalarOffset, ScalarParseError, ) -from ..geometry import Spacing, SpacingDimensions -from .constants import NULL_SPACING -from .errors import StyleTypeError, StyleValueError from .transition import Transition -from ._error_tools import friendly_list +from ..geometry import Spacing, SpacingDimensions if TYPE_CHECKING: from .styles import Styles from .styles import DockGroup + from ..layout import Layout + from ..layouts.factory import LayoutName class ScalarProperty: @@ -80,7 +82,6 @@ class ScalarProperty: class BoxProperty: - DEFAULT = ("", Style()) def __set_name__(self, owner: Styles, name: str) -> None: @@ -212,7 +213,6 @@ class BorderProperty: class StyleProperty: - DEFAULT_STYLE = Style() def __set_name__(self, owner: Styles, name: str) -> None: @@ -291,21 +291,23 @@ class DockProperty: class LayoutProperty: + """Descriptor for getting and setting layout.""" + def __set_name__(self, owner: Styles, name: str) -> None: self._internal_name = f"_rule_{name}" - def __get__(self, obj: Styles, objtype: type[Styles] | None = None) -> str: - return getattr(obj, self._internal_name) or "" + def __get__(self, obj: Styles, objtype: type[Styles] | None = None) -> Layout: + return getattr(obj, self._internal_name) - def __set__(self, obj: Styles, layout: str | Styles): + def __set__(self, obj: Styles, layout: LayoutName | Layout): from ..layouts.factory import get_layout obj.refresh(True) - if isinstance(layout, str): - new_layout = get_layout(layout) - else: + if isinstance(layout, Layout): new_layout = layout - self._layout = new_layout + else: + new_layout = get_layout(layout) + setattr(obj, self._internal_name, new_layout) class OffsetProperty: @@ -442,7 +444,6 @@ class ColorProperty: class StyleFlagsProperty: - _VALID_PROPERTIES = { "not", "bold", diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index 17349a04f..ef3a9dba7 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -18,6 +18,7 @@ from .styles import DockGroup, Styles from .types import Edge, Display, Visibility from .tokenize import Token from .transition import Transition +from ..layouts.factory import get_layout class StylesBuilder: @@ -288,7 +289,7 @@ class StylesBuilder: if len(tokens) != 1: self.error(name, tokens[0], "unexpected tokens in declaration") else: - self.styles._rule_layout = tokens[0].value + self.styles._rule_layout = get_layout(tokens[0].value) def process_text(self, name: str, tokens: list[Token], important: bool) -> None: style_definition = " ".join(token.value for token in tokens) diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 09510ae78..18d7eecea 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -1,32 +1,14 @@ from __future__ import annotations +import sys from dataclasses import dataclass, field from functools import lru_cache -import sys from typing import Any, Iterable, NamedTuple, TYPE_CHECKING -from rich import print -from rich.color import Color import rich.repr +from rich.color import Color from rich.style import Style -from .. import log -from .._animator import SimpleAnimation, Animation, EasingFunction -from .._types import MessageTarget -from .errors import StyleValueError -from .. import events -from ._error_tools import friendly_list -from .types import Specificity3, Specificity4 -from .constants import ( - VALID_DISPLAY, - VALID_VISIBILITY, - VALID_LAYOUT, - NULL_SPACING, -) -from .scalar_animation import ScalarAnimation -from ..geometry import NULL_OFFSET, Offset, Spacing -from .scalar import Scalar, ScalarOffset, Unit -from .transition import Transition from ._style_properties import ( BorderProperty, BoxProperty, @@ -44,16 +26,27 @@ from ._style_properties import ( TransitionsProperty, LayoutProperty, ) +from .constants import ( + VALID_DISPLAY, + VALID_VISIBILITY, +) +from .scalar import Scalar, ScalarOffset, Unit +from .scalar_animation import ScalarAnimation +from .transition import Transition from .types import Display, Edge, Visibility - +from .types import Specificity3, Specificity4 +from .. import log +from .._animator import Animation, EasingFunction +from ..geometry import Spacing if sys.version_info >= (3, 8): - from typing import Literal + pass else: - from typing_extensions import Literal + pass if TYPE_CHECKING: + from ..layout import Layout from ..dom import DOMNode @@ -71,7 +64,7 @@ class Styles: _rule_display: Display | None = None _rule_visibility: Visibility | None = None - _rule_layout: str | None = None + _rule_layout: "Layout" | None = None _rule_text_color: Color | None = None _rule_text_background: Color | None = None @@ -434,7 +427,7 @@ if __name__ == "__main__": styles.dock = "bar" styles.layers = "foo bar" - from rich import inspect, print + from rich import print print(styles.text_style) print(styles.text) diff --git a/src/textual/layouts/factory.py b/src/textual/layouts/factory.py index fb491e578..5d74a5f9f 100644 --- a/src/textual/layouts/factory.py +++ b/src/textual/layouts/factory.py @@ -1,7 +1,11 @@ +from typing import Literal + +from ..layout import Layout from ..layouts.dock import DockLayout from ..layouts.grid import GridLayout from ..layouts.vertical import VerticalLayout +LayoutName = Literal["dock", "grid", "vertical"] LAYOUT_MAP = {"dock": DockLayout, "grid": GridLayout, "vertical": VerticalLayout} @@ -9,7 +13,7 @@ class MissingLayout(Exception): pass -def get_layout(name: str): +def get_layout(name: LayoutName) -> Layout: """Get a named layout object. Args: diff --git a/src/textual/view.py b/src/textual/view.py index 3d7df4112..f3463fcd0 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -1,41 +1,26 @@ from __future__ import annotations -from itertools import chain -from typing import Callable, Iterable, ClassVar, TYPE_CHECKING +from typing import Callable, Iterable -from rich.console import RenderableType import rich.repr +from rich.console import RenderableType from rich.style import Style -from . import events -from . import errors -from . import log -from . import messages -from .layout import Layout, NoWidget, WidgetPlacement +from . import errors, events, messages from .geometry import Size, Offset, Region +from .layout import Layout, NoWidget, WidgetPlacement from .reactive import Reactive, watch - from .widget import Widget -if TYPE_CHECKING: - from .app import App - - @rich.repr.auto class View(Widget): - STYLES = """ docks: main=top; """ def __init__(self, name: str | None = None, id: str | None = None) -> None: - # TODO: Get rid of this, replace usages with layout from Styles object - from .layouts.dock import DockLayout - - self._layout: Layout = DockLayout() - self.mouse_over: Widget | None = None self.widgets: set[Widget] = set() self._mouse_style: Style = Style() @@ -61,9 +46,28 @@ class View(Widget): virtual_size = Reactive(Size(0, 0)) async def watch_background(self, value: str) -> None: - self._layout.background = value + self.layout.background = value self.app.refresh() + @property + def layout(self) -> Layout: + """Convenience property for accessing ``view.styles.layout``. + + Returns: The Layout associated with this view + """ + return self.styles.layout + + @layout.setter + def layout(self, new_value: Layout) -> None: + """Convenience property setter for setting ``view.styles.layout``. + Args: + new_value: + + Returns: + + """ + self.styles.layout = new_value + @property def scroll(self) -> Offset: return Offset(self.scroll_x, self.scroll_y) @@ -89,16 +93,16 @@ class View(Widget): return self.app.is_mounted(widget) def render(self) -> RenderableType: - return self._layout + return self.layout def get_offset(self, widget: Widget) -> Offset: - return self._layout.get_offset(widget) + return self.layout.get_offset(widget) def get_arrangement(self, size: Size, scroll: Offset) -> Iterable[WidgetPlacement]: cached_size, cached_scroll, arrangement = self._cached_arrangement if cached_size == size and cached_scroll == scroll: return arrangement - arrangement = list(self._layout.arrange(self, size, scroll)) + arrangement = list(self.layout.arrange(self, size, scroll)) self._cached_arrangement = (size, scroll, arrangement) return arrangement @@ -108,7 +112,7 @@ class View(Widget): widget = message.widget assert isinstance(widget, Widget) - display_update = self._layout.update_widget(self.console, widget) + display_update = self.layout.update_widget(self.console, widget) if display_update is not None: self.app.display(display_update) @@ -125,7 +129,7 @@ class View(Widget): async def refresh_layout(self) -> None: self._cached_arrangement = (Size(), Offset(), []) try: - await self._layout.mount_all(self) + await self.layout.mount_all(self) if not self.is_root_view: await self.app.view.refresh_layout() return @@ -133,8 +137,8 @@ class View(Widget): if not self.size: return - hidden, shown, resized = self._layout.reflow(self, Size(*self.console.size)) - assert self._layout.map is not None + hidden, shown, resized = self.layout.reflow(self, Size(*self.console.size)) + assert self.layout.map is not None for widget in hidden: widget.post_message_no_wait(events.Hide(self)) @@ -144,7 +148,7 @@ class View(Widget): send_resize = shown send_resize.update(resized) - for widget, region, unclipped_region in self._layout: + for widget, region, unclipped_region in self.layout: widget._update_size(unclipped_region.size) if widget in send_resize: widget.post_message_no_wait( @@ -161,13 +165,13 @@ class View(Widget): event.stop() def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]: - return self._layout.get_widget_at(x, y) + return self.layout.get_widget_at(x, y) def get_style_at(self, x: int, y: int) -> Style: - return self._layout.get_style_at(x, y) + return self.layout.get_style_at(x, y) def get_widget_region(self, widget: Widget) -> Region: - return self._layout.get_widget_region(widget) + return self.layout.get_widget_region(widget) async def on_mount(self, event: events.Mount) -> None: async def watch_background(value: str) -> None: @@ -176,8 +180,8 @@ class View(Widget): watch(self.app, "background", watch_background) async def on_idle(self, event: events.Idle) -> None: - if self._layout.check_update(): - self._layout.reset_update() + if self.layout.check_update(): + self.layout.reset_update() await self.refresh_layout() async def _on_mouse_move(self, event: events.MouseMove) -> None: diff --git a/src/textual/views/_dock_view.py b/src/textual/views/_dock_view.py index 842dae397..22d55c93b 100644 --- a/src/textual/views/_dock_view.py +++ b/src/textual/views/_dock_view.py @@ -29,8 +29,8 @@ class DockView(View): ) -> None: dock = Dock(edge, widgets, z) - assert isinstance(self._layout, DockLayout) - self._layout.docks.append(dock) + assert isinstance(self.layout, DockLayout) + self.layout.docks.append(dock) for widget in widgets: if id is not None: widget._id = id @@ -60,8 +60,8 @@ class DockView(View): grid = GridLayout(gap=gap, gutter=gutter, align=align) view = View(layout=grid, id=id, name=name) dock = Dock(edge, (view,), z) - assert isinstance(self._layout, DockLayout) - self._layout.docks.append(dock) + assert isinstance(self.layout, DockLayout) + self.layout.docks.append(dock) if size is not do_not_set: view.layout_size = cast(Optional[int], size) if name is None: diff --git a/src/textual/views/_window_view.py b/src/textual/views/_window_view.py index 92edce916..56ee65e63 100644 --- a/src/textual/views/_window_view.py +++ b/src/textual/views/_window_view.py @@ -32,7 +32,7 @@ class WindowView(View, layout=VerticalLayout): super().__init__(name=name, layout=layout) async def update(self, widget: Widget | RenderableType) -> None: - layout = self._layout + layout = self.layout assert isinstance(layout, VerticalLayout) layout.clear() self.widget = widget if isinstance(widget, Widget) else Static(widget) @@ -46,7 +46,7 @@ class WindowView(View, layout=VerticalLayout): await self.emit(WindowChange(self)) async def handle_layout(self, message: messages.Layout) -> None: - self._layout.require_update() + self.layout.require_update() message.stop() self.refresh() @@ -54,11 +54,11 @@ class WindowView(View, layout=VerticalLayout): await self.emit(WindowChange(self)) async def watch_scroll_x(self, value: int) -> None: - self._layout.require_update() + self.layout.require_update() self.refresh() async def watch_scroll_y(self, value: int) -> None: - self._layout.require_update() + self.layout.require_update() self.refresh() async def on_resize(self, event: events.Resize) -> None: diff --git a/src/textual/widgets/_scroll_view.py b/src/textual/widgets/_scroll_view.py index 210edf740..fd53c1203 100644 --- a/src/textual/widgets/_scroll_view.py +++ b/src/textual/widgets/_scroll_view.py @@ -93,13 +93,13 @@ class ScrollView(View): await self.window.update(renderable) async def on_mount(self, event: events.Mount) -> None: - assert isinstance(self._layout, GridLayout) - self._layout.place( + assert isinstance(self.layout, GridLayout) + self.layout.place( content=self.window, vscroll=self.vscroll, hscroll=self.hscroll, ) - await self._layout.mount_all(self) + await self.layout.mount_all(self) def home(self) -> None: self.x = self.y = 0 @@ -215,10 +215,10 @@ class ScrollView(View): self.vscroll.virtual_size = virtual_height self.vscroll.window_size = height - assert isinstance(self._layout, GridLayout) + assert isinstance(self.layout, GridLayout) - vscroll_change = self._layout.show_column("vscroll", virtual_height > height) - hscroll_change = self._layout.show_row("hscroll", virtual_width > width) + vscroll_change = self.layout.show_column("vscroll", virtual_height > height) + hscroll_change = self.layout.show_row("hscroll", virtual_width > width) if hscroll_change or vscroll_change: self.refresh(layout=True) From d04c66291b3a1e90226b8717f35a83b57bf751b3 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Fri, 21 Jan 2022 14:14:40 +0000 Subject: [PATCH 11/23] Improve error message when layout doesnt exist, add tests for parsing layout from CSS --- src/textual/css/_styles_builder.py | 25 +++++++++++++++++-------- src/textual/layouts/factory.py | 2 +- tests/test_css_parse.py | 21 +++++++++++++++++++++ 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index ef3a9dba7..8a826507a 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -6,19 +6,19 @@ import rich.repr from rich.color import Color from rich.style import Style +from ._error_tools import friendly_list from .constants import VALID_BORDER, VALID_EDGE, VALID_DISPLAY, VALID_VISIBILITY from .errors import DeclarationError -from ._error_tools import friendly_list -from .._duration import _duration_as_seconds -from .._easing import EASING -from ..geometry import Spacing, SpacingDimensions from .model import Declaration from .scalar import Scalar, ScalarOffset, Unit, ScalarError from .styles import DockGroup, Styles -from .types import Edge, Display, Visibility from .tokenize import Token from .transition import Transition -from ..layouts.factory import get_layout +from .types import Edge, Display, Visibility +from .._duration import _duration_as_seconds +from .._easing import EASING +from ..geometry import Spacing, SpacingDimensions +from ..layouts.factory import get_layout, LayoutName, MissingLayout, LAYOUT_MAP class StylesBuilder: @@ -60,7 +60,7 @@ class StylesBuilder: self.styles.important.add(rule_name) try: process_method(declaration.name, tokens, important) - except DeclarationError as error: + except DeclarationError: raise except Exception as error: self.error(declaration.name, declaration.token, str(error)) @@ -289,7 +289,16 @@ class StylesBuilder: if len(tokens) != 1: self.error(name, tokens[0], "unexpected tokens in declaration") else: - self.styles._rule_layout = get_layout(tokens[0].value) + value = tokens[0].value + layout_name = cast(LayoutName, value) + try: + self.styles._rule_layout = get_layout(layout_name) + except MissingLayout: + self.error( + name, + tokens[0], + f"invalid value for layout (received {value!r}, expected {friendly_list(LAYOUT_MAP.keys())})", + ) def process_text(self, name: str, tokens: list[Token], important: bool) -> None: style_definition = " ".join(token.value for token in tokens) diff --git a/src/textual/layouts/factory.py b/src/textual/layouts/factory.py index 5d74a5f9f..03776cdee 100644 --- a/src/textual/layouts/factory.py +++ b/src/textual/layouts/factory.py @@ -28,5 +28,5 @@ def get_layout(name: LayoutName) -> Layout: layout_class = LAYOUT_MAP.get(name) if layout_class is None: - raise MissingLayout("no layout called {name!r}") + raise MissingLayout(f"no layout called {name!r}, valid layouts") return layout_class() diff --git a/tests/test_css_parse.py b/tests/test_css_parse.py index f17a0c3e9..ad03fb34d 100644 --- a/tests/test_css_parse.py +++ b/tests/test_css_parse.py @@ -4,6 +4,27 @@ from rich.color import Color, ColorType from textual.css.scalar import Scalar, Unit from textual.css.stylesheet import Stylesheet, StylesheetParseError from textual.css.transition import Transition +from textual.layouts.dock import DockLayout + + +class TestParseLayout: + def test_valid_layout_name(self): + css = "#some-widget { layout: dock; }" + + stylesheet = Stylesheet() + stylesheet.parse(css) + + styles = stylesheet.rules[0].styles + assert isinstance(styles.layout, DockLayout) + + def test_invalid_layout_name(self): + css = "#some-widget { layout: invalidlayout; }" + + stylesheet = Stylesheet() + with pytest.raises(StylesheetParseError) as ex: + stylesheet.parse(css) + + assert ex.value.errors is not None class TestParseText: From 742104eb2189d4772b6d0996a44ba68c9ae1a5c4 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Fri, 21 Jan 2022 14:15:09 +0000 Subject: [PATCH 12/23] Remove _layout attribute from `App` --- src/textual/app.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index afdb99892..8708bf5ab 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -90,7 +90,6 @@ class App(DOMNode): self._screen = screen self.driver_class = driver_class or LinuxDriver self._title = title - self._layout = DockLayout() self._view_stack: list[View] = [] self.focused: Widget | None = None From 8601cea0faf7bc4cb8cf25987ad466b2d80f4dd6 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Fri, 21 Jan 2022 14:16:52 +0000 Subject: [PATCH 13/23] Remove redundant block of code --- src/textual/css/styles.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 18d7eecea..db374dabf 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -39,11 +39,6 @@ from .. import log from .._animator import Animation, EasingFunction from ..geometry import Spacing -if sys.version_info >= (3, 8): - pass -else: - pass - if TYPE_CHECKING: from ..layout import Layout From 1bc4bf54ae44350fce0144615636d639064c950d Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Fri, 21 Jan 2022 14:27:47 +0000 Subject: [PATCH 14/23] Adding tests for layout factory --- tests/layouts/test_factory.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 tests/layouts/test_factory.py diff --git a/tests/layouts/test_factory.py b/tests/layouts/test_factory.py new file mode 100644 index 000000000..7505902f9 --- /dev/null +++ b/tests/layouts/test_factory.py @@ -0,0 +1,14 @@ +import pytest + +from textual.layouts.dock import DockLayout +from textual.layouts.factory import get_layout, MissingLayout + + +def test_get_layout_valid_layout(): + layout_class = get_layout("dock") + assert type(layout_class) is DockLayout + + +def test_get_layout_invalid_layout(): + with pytest.raises(MissingLayout): + get_layout("invalid") From 837144cc13b36161ab4d05918a93d3eb451af25d Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Fri, 21 Jan 2022 14:28:37 +0000 Subject: [PATCH 15/23] Remove a `log` --- src/textual/css/stylesheet.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 802c86fbb..58c73fee3 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -140,7 +140,6 @@ class Stylesheet: for name, specificity_rules in rule_attributes.items() ] - log(node.id, node_rules) node.styles.apply_rules(node_rules) def update(self, root: DOMNode) -> None: From e6919ac35bc376e0b400975a8a6f5704036deb40 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Fri, 21 Jan 2022 14:36:48 +0000 Subject: [PATCH 16/23] Add docstring to Stylesheet.apply --- src/textual/css/stylesheet.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 58c73fee3..616bcf1b2 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -108,6 +108,18 @@ class Stylesheet: yield selector_set.specificity def apply(self, node: DOMNode) -> None: + """ + Args: + node (DOMNode): The ``DOMNode`` to apply the stylesheet to. + Applies the styles defined in this ``Stylesheet`` to the node. + If the same rule is defined multiple times for the node (e.g. multiple + classes modifying the same CSS property), then only the most specific + rule will be applied. + + Returns: + None + """ + # Dictionary of rule attribute names e.g. "text_background" to list of tuples. # The tuples contain the rule specificity, and the value for that rule. # We can use this to determine, for a given rule, whether we should apply it From 60134b650d42d46313804b2595778e27fe4575a2 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Fri, 21 Jan 2022 14:39:20 +0000 Subject: [PATCH 17/23] Rename variable in a test --- tests/layouts/test_factory.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/layouts/test_factory.py b/tests/layouts/test_factory.py index 7505902f9..8935556a5 100644 --- a/tests/layouts/test_factory.py +++ b/tests/layouts/test_factory.py @@ -5,8 +5,8 @@ from textual.layouts.factory import get_layout, MissingLayout def test_get_layout_valid_layout(): - layout_class = get_layout("dock") - assert type(layout_class) is DockLayout + layout = get_layout("dock") + assert type(layout) is DockLayout def test_get_layout_invalid_layout(): From deb7f1cb54bba2dc4d7b32215aef560bf004ae3d Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Fri, 21 Jan 2022 15:25:15 +0000 Subject: [PATCH 18/23] Use dock layout by default in View node-level CSS --- examples/basic.css | 1 - src/textual/css/_style_properties.py | 15 ++++++++++++++- src/textual/view.py | 2 +- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/examples/basic.css b/examples/basic.css index 279f6e520..efafe797e 100644 --- a/examples/basic.css +++ b/examples/basic.css @@ -1,7 +1,6 @@ /* CSS file for basic.py */ App > View { - layout: dock; docks: side=left/1; text: on #20639b; } diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 3068fbead..4496923f2 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -297,10 +297,23 @@ class LayoutProperty: self._internal_name = f"_rule_{name}" def __get__(self, obj: Styles, objtype: type[Styles] | None = None) -> Layout: + """ + Args: + obj (Styles): The Styles object + objtype (type[Styles]): The Styles class + Returns: + The ``Layout`` object. + """ return getattr(obj, self._internal_name) def __set__(self, obj: Styles, layout: LayoutName | Layout): - from ..layouts.factory import get_layout + """ + Args: + obj (Styles): The Styles object. + layout (LayoutName | Layout): The layout to use. You can supply a ``LayoutName`` + (a string literal such as ``"dock"``) or a ``Layout`` object. + """ + from ..layouts.factory import get_layout # Prevents circular import obj.refresh(True) if isinstance(layout, Layout): diff --git a/src/textual/view.py b/src/textual/view.py index f3463fcd0..9617fc251 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -16,8 +16,8 @@ from .widget import Widget @rich.repr.auto class View(Widget): STYLES = """ + layout: dock; docks: main=top; - """ def __init__(self, name: str | None = None, id: str | None = None) -> None: From 5690f946d1c5e00c4a54842ca988a5808b01a533 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Fri, 21 Jan 2022 15:50:58 +0000 Subject: [PATCH 19/23] Testing to ensure layout can properly be set via view --- src/textual/css/_style_properties.py | 4 ++-- tests/test_view.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 tests/test_view.py diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 4496923f2..12c9075f7 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -30,9 +30,9 @@ from .transition import Transition from ..geometry import Spacing, SpacingDimensions if TYPE_CHECKING: + from ..layout import Layout from .styles import Styles from .styles import DockGroup - from ..layout import Layout from ..layouts.factory import LayoutName @@ -313,7 +313,7 @@ class LayoutProperty: layout (LayoutName | Layout): The layout to use. You can supply a ``LayoutName`` (a string literal such as ``"dock"``) or a ``Layout`` object. """ - from ..layouts.factory import get_layout # Prevents circular import + from ..layouts.factory import get_layout, Layout # Prevents circular import obj.refresh(True) if isinstance(layout, Layout): diff --git a/tests/test_view.py b/tests/test_view.py new file mode 100644 index 000000000..08db68d69 --- /dev/null +++ b/tests/test_view.py @@ -0,0 +1,18 @@ +import pytest + +from textual.layouts.dock import DockLayout +from textual.layouts.grid import GridLayout +from textual.layouts.vertical import VerticalLayout +from textual.view import View + + +@pytest.mark.parametrize("layout_name, layout_type", [ + ["dock", DockLayout], + ["grid", GridLayout], + ["vertical", VerticalLayout], +]) +def test_view_layout_get_and_set(layout_name, layout_type): + view = View() + view.layout = layout_name + assert type(view.layout) is layout_type + assert view.styles.layout is view.layout From a8cd64964f098277cb3346b9d7e3514e608ec7ac Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Fri, 21 Jan 2022 15:54:13 +0000 Subject: [PATCH 20/23] Fix a docstring --- src/textual/view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/view.py b/src/textual/view.py index 9617fc251..b16645f10 100644 --- a/src/textual/view.py +++ b/src/textual/view.py @@ -64,7 +64,7 @@ class View(Widget): new_value: Returns: - + None """ self.styles.layout = new_value From 89524e301ebdf1b4dca482caafe479739d2df632 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Fri, 21 Jan 2022 16:00:47 +0000 Subject: [PATCH 21/23] Remove some unused logging --- src/textual/css/styles.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index db374dabf..51d6035cb 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -269,9 +269,6 @@ class Styles: else: setattr(styles, f"_rule_{key}", value) - if self.node.id == "_root": - log("_root.styles.layout =", self.node.styles.layout) - if self.node is not None: self.node.on_style_change() From cea331ef4d8ff56a0e358056f1bf8dc01a1f4199 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Fri, 21 Jan 2022 16:04:29 +0000 Subject: [PATCH 22/23] Ensure Python 3.7 support using typing_extensions --- src/textual/layouts/factory.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/textual/layouts/factory.py b/src/textual/layouts/factory.py index 03776cdee..4953fb50a 100644 --- a/src/textual/layouts/factory.py +++ b/src/textual/layouts/factory.py @@ -1,10 +1,15 @@ -from typing import Literal +import sys from ..layout import Layout from ..layouts.dock import DockLayout from ..layouts.grid import GridLayout from ..layouts.vertical import VerticalLayout +if sys.version_info >= (3, 8): + from typing import Literal +else: + from typing_extensions import Literal + LayoutName = Literal["dock", "grid", "vertical"] LAYOUT_MAP = {"dock": DockLayout, "grid": GridLayout, "vertical": VerticalLayout} From 8c3bc4135ec58ccac19ead529ea563d1f2583665 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 25 Jan 2022 14:20:32 +0000 Subject: [PATCH 23/23] Add missing docstring, use explicit kwargs, remove id=_root --- src/textual/app.py | 2 +- src/textual/css/_style_properties.py | 14 +++++++------- src/textual/css/stylesheet.py | 3 ++- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 8708bf5ab..96ca521e4 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -503,7 +503,7 @@ class App(DOMNode): # Handle input events that haven't been forwarded # If the event has been forwarded it may have bubbled up back to the App if isinstance(event, events.Mount): - view = View(id="_root") + view = View() self.register(self, view) await self.push_view(view) await super().on_event(event) diff --git a/src/textual/css/_style_properties.py b/src/textual/css/_style_properties.py index 12c9075f7..ff673fa10 100644 --- a/src/textual/css/_style_properties.py +++ b/src/textual/css/_style_properties.py @@ -257,7 +257,7 @@ class SpacingProperty: return getattr(obj, self._internal_name) or NULL_SPACING def __set__(self, obj: Styles, spacing: SpacingDimensions) -> Spacing: - obj.refresh(True) + obj.refresh(layout=True) spacing = Spacing.unpack(spacing) setattr(obj, self._internal_name, spacing) return spacing @@ -272,7 +272,7 @@ class DocksProperty: def __set__( self, obj: Styles, docks: Iterable[DockGroup] | None ) -> Iterable[DockGroup] | None: - obj.refresh(True) + obj.refresh(layout=True) if docks is None: obj._rule_docks = None else: @@ -285,7 +285,7 @@ class DockProperty: return obj._rule_dock or "" def __set__(self, obj: Styles, spacing: str | None) -> str | None: - obj.refresh(True) + obj.refresh(layout=True) obj._rule_dock = spacing return spacing @@ -315,7 +315,7 @@ class LayoutProperty: """ from ..layouts.factory import get_layout, Layout # Prevents circular import - obj.refresh(True) + obj.refresh(layout=True) if isinstance(layout, Layout): new_layout = layout else: @@ -335,7 +335,7 @@ class OffsetProperty: def __set__( self, obj: Styles, offset: tuple[int | str, int | str] | ScalarOffset ) -> tuple[int | str, int | str] | ScalarOffset: - obj.refresh(True) + obj.refresh(layout=True) if isinstance(offset, ScalarOffset): setattr(obj, self._internal_name, offset) return offset @@ -403,7 +403,7 @@ class NameProperty: return getattr(obj, self._internal_name) or "" def __set__(self, obj: Styles, name: str | None) -> str | None: - obj.refresh(True) + obj.refresh(layout=True) if not isinstance(name, str): raise StyleTypeError(f"{self._name} must be a str") setattr(obj, self._internal_name, name) @@ -423,7 +423,7 @@ class NameListProperty: def __set__( self, obj: Styles, names: str | tuple[str] | None = None ) -> str | tuple[str] | None: - obj.refresh(True) + obj.refresh(layout=True) names_value: tuple[str, ...] | None = None if isinstance(names, str): names_value = tuple(name.strip().lower() for name in names.split(" ")) diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 616bcf1b2..8c6075011 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -108,7 +108,8 @@ class Stylesheet: yield selector_set.specificity def apply(self, node: DOMNode) -> None: - """ + """Apply the stylesheet to a DOM node. + Args: node (DOMNode): The ``DOMNode`` to apply the stylesheet to. Applies the styles defined in this ``Stylesheet`` to the node.