From f495870b08fe90ca345dd4be61c57cdca2ec77d9 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 13 Feb 2023 10:12:49 +0000 Subject: [PATCH 001/113] Remove the disabled styling from `Input` This seems to be a hangover from the early days of the development of `Input`, and the styles do nothing as there's nothing else in the `Input` code that makes use of the class that's involved. Removed in anticipation of #1748 taking care of this. --- src/textual/widgets/_input.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index 8528e225a..f11dd7df8 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -110,9 +110,6 @@ class Input(Widget, can_focus=True): height: 1; min-height: 1; } - Input.-disabled { - opacity: 0.6; - } Input:focus { border: tall $accent; } @@ -121,9 +118,6 @@ class Input(Widget, can_focus=True): color: $text; text-style: reverse; } - Input>.input--placeholder { - color: $text-disabled; - } """ cursor_blink = reactive(True) From 04c207627cb520e6a1887afa832e7b1ed80e53a1 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 13 Feb 2023 10:49:31 +0000 Subject: [PATCH 002/113] Put the placeholder styling background f495870b08fe90ca345dd4be61c57cdca2ec77d9 got a little too carried away. --- src/textual/widgets/_input.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index f11dd7df8..c652da9a5 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -118,6 +118,9 @@ class Input(Widget, can_focus=True): color: $text; text-style: reverse; } + Input>.input--placeholder { + color: $text-disabled; + } """ cursor_blink = reactive(True) From 7aaf8842823b98472618e57cf7fedb9087a850fa Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 13 Feb 2023 12:40:37 +0000 Subject: [PATCH 003/113] Move disabled out of `Button` and into `Widget` This doesn't go close to what #1748 is intending to do, but moves `disabled` to where I want it and keeps `Button` working as before. --- src/textual/widget.py | 2 ++ src/textual/widgets/_button.py | 3 --- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index 197eb6147..53fe29983 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -225,6 +225,8 @@ class Widget(DOMNode): """Rich renderable may shrink.""" auto_links = Reactive(True) """Widget will highlight links automatically.""" + disabled = Reactive(False) + """The disabled state of the widget. `True` if disabled, `False if not.""" hover_style: Reactive[Style] = Reactive(Style, repaint=False) highlight_link_id: Reactive[str] = Reactive("") diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index fbeb60645..a06248b53 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -156,9 +156,6 @@ class Button(Static, can_focus=True): variant = reactive("default") """The variant name for the button.""" - disabled = reactive(False) - """The disabled state of the button; `True` if disabled, `False` if not.""" - class Pressed(Message, bubble=True): """Event sent when a `Button` is pressed. From 257fe7b30ae62d661924a7bda9a5500700f2a031 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 13 Feb 2023 12:56:52 +0000 Subject: [PATCH 004/113] Add enabled and disabled pseudo-classes Note that this doesn't touch the application of stylesheets yet, in terms of things like specificity; this just makes sure that the classes exist and can be seen. --- docs/guide/CSS.md | 2 ++ src/textual/widget.py | 1 + 2 files changed, 3 insertions(+) diff --git a/docs/guide/CSS.md b/docs/guide/CSS.md index 80753ddbd..985c4b41f 100644 --- a/docs/guide/CSS.md +++ b/docs/guide/CSS.md @@ -315,6 +315,8 @@ The `background: green` is only applied to the Button underneath the mouse curso Here are some other pseudo classes: +- `:disabled` Matches widgets which are in a disabled state. +- `:enabled` Matches widgets which are in an enabled state. - `:focus` Matches widgets which have input focus. - `:focus-within` Matches widgets with a focused a child widget. diff --git a/src/textual/widget.py b/src/textual/widget.py index 53fe29983..5cb278389 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2084,6 +2084,7 @@ class Widget(DOMNode): Names of the pseudo classes. """ + yield "disabled" if self.disabled else "enabled" if self.mouse_over: yield "hover" if self.has_focus: From a0a83e96adad1b5437829a36c53ae4b3242308f9 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 13 Feb 2023 13:04:01 +0000 Subject: [PATCH 005/113] Correct docstring typo --- src/textual/widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index 5cb278389..759da55a3 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -226,7 +226,7 @@ class Widget(DOMNode): auto_links = Reactive(True) """Widget will highlight links automatically.""" disabled = Reactive(False) - """The disabled state of the widget. `True` if disabled, `False if not.""" + """The disabled state of the widget. `True` if disabled, `False` if not.""" hover_style: Reactive[Style] = Reactive(Style, repaint=False) highlight_link_id: Reactive[str] = Reactive("") From d3104a92c9e16eb6ebf2c5b498b211df35e06387 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 13 Feb 2023 13:04:59 +0000 Subject: [PATCH 006/113] Add `disabled` as a widget construction keyword argument --- src/textual/widget.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/textual/widget.py b/src/textual/widget.py index 759da55a3..1913d6e84 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -237,6 +237,7 @@ class Widget(DOMNode): name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool | None = None, ) -> None: self._size = Size(0, 0) self._container_size = Size(0, 0) @@ -245,6 +246,7 @@ class Widget(DOMNode): self._default_layout = VerticalLayout() self._animate: BoundAnimator | None = None self.highlight_style: Style | None = None + self.disabled = bool(disabled) self._vertical_scrollbar: ScrollBar | None = None self._horizontal_scrollbar: ScrollBar | None = None From 821a6ff7c1d2e127d2c3d3bd6786cd80c2f8c0a6 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 13 Feb 2023 13:09:12 +0000 Subject: [PATCH 007/113] Simplify the default for disabled on a Widget There was no need to default to None and then convert to a bool, defaulting to False is just fine. --- src/textual/widget.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index 1913d6e84..ee9492fba 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -237,7 +237,7 @@ class Widget(DOMNode): name: str | None = None, id: str | None = None, classes: str | None = None, - disabled: bool | None = None, + disabled: bool = False, ) -> None: self._size = Size(0, 0) self._container_size = Size(0, 0) @@ -246,7 +246,7 @@ class Widget(DOMNode): self._default_layout = VerticalLayout() self._animate: BoundAnimator | None = None self.highlight_style: Style | None = None - self.disabled = bool(disabled) + self.disabled = disabled self._vertical_scrollbar: ScrollBar | None = None self._horizontal_scrollbar: ScrollBar | None = None From 3707f80aa4b8f6cb56e7624d2f23f8716fca7a5a Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 13 Feb 2023 14:19:36 +0000 Subject: [PATCH 008/113] Move duplicate safe calls to update_styles into a single method It's done as an internal, but can be called from child classes of course. This is intended to be a single central method of asking the app to update styles while also not caring if there is no active app available just yet. --- src/textual/dom.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index 1ed204aa9..447ab3cf2 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -874,6 +874,16 @@ class DOMNode(MessagePump): else: self.remove_class(*class_names) + def _update_styles(self) -> None: + """Request an update of this node's styles. + + Should be called whenever CSS classes / pseudo classes change. + """ + try: + self.app.update_styles(self) + except NoActiveAppError: + pass + def add_class(self, *class_names: str) -> None: """Add class names to this Node. @@ -886,10 +896,7 @@ class DOMNode(MessagePump): self._classes.update(class_names) if old_classes == self._classes: return - try: - self.app.update_styles(self) - except NoActiveAppError: - pass + self._update_styles() def remove_class(self, *class_names: str) -> None: """Remove class names from this Node. @@ -903,10 +910,7 @@ class DOMNode(MessagePump): self._classes.difference_update(class_names) if old_classes == self._classes: return - try: - self.app.update_styles(self) - except NoActiveAppError: - pass + self._update_styles() def toggle_class(self, *class_names: str) -> None: """Toggle class names on this Node. @@ -920,10 +924,7 @@ class DOMNode(MessagePump): self._classes.symmetric_difference_update(class_names) if old_classes == self._classes: return - try: - self.app.update_styles(self) - except NoActiveAppError: - pass + self._update_styles() def has_pseudo_class(self, *class_names: str) -> bool: """Check for pseudo class (such as hover, focus etc)""" From d37895dfdd855a71ed6c7718f41ee0562f47e58d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 13 Feb 2023 14:23:20 +0000 Subject: [PATCH 009/113] Convert some app.update_styles calls into _update_styles calls --- src/textual/widget.py | 8 ++++---- src/textual/widgets/_button.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index ee9492fba..7f3486264 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2134,11 +2134,11 @@ class Widget(DOMNode): def watch_mouse_over(self, value: bool) -> None: """Update from CSS if mouse over state changes.""" if self._has_hover_style: - self.app.update_styles(self) + self._update_styles() def watch_has_focus(self, value: bool) -> None: """Update from CSS if has focus state changes.""" - self.app.update_styles(self) + self._update_styles() def _size_updated( self, size: Size, virtual_size: Size, container_size: Size @@ -2486,11 +2486,11 @@ class Widget(DOMNode): def _on_descendant_blur(self, event: events.DescendantBlur) -> None: if self._has_focus_within: - self.app.update_styles(self) + self._update_styles() def _on_descendant_focus(self, event: events.DescendantBlur) -> None: if self._has_focus_within: - self.app.update_styles(self) + self._update_styles() def _on_mouse_scroll_down(self, event: events.MouseScrollDown) -> None: if event.ctrl or event.shift: diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index a06248b53..ceccb8fc7 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -211,7 +211,7 @@ class Button(Static, can_focus=True): def watch_mouse_over(self, value: bool) -> None: """Update from CSS if mouse over state changes.""" if self._has_hover_style and not self.disabled: - self.app.update_styles(self) + self._update_styles() def validate_variant(self, variant: str) -> str: if variant not in _VALID_BUTTON_VARIANTS: From 1097fb267d7882ba3812126efd0bbf82f04daac7 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 13 Feb 2023 14:25:40 +0000 Subject: [PATCH 010/113] Set the initial disabled state later in __init__ I was going too early with setting this; it needs to happen after pretty much everything else is set up *and* after the super's __init__ has been called. --- src/textual/widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index 7f3486264..6efc732b8 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -246,7 +246,6 @@ class Widget(DOMNode): self._default_layout = VerticalLayout() self._animate: BoundAnimator | None = None self.highlight_style: Style | None = None - self.disabled = disabled self._vertical_scrollbar: ScrollBar | None = None self._horizontal_scrollbar: ScrollBar | None = None @@ -281,6 +280,7 @@ class Widget(DOMNode): raise WidgetError("A widget can't be its own parent") self._add_children(*children) + self.disabled = disabled virtual_size = Reactive(Size(0, 0), layout=True) auto_width = Reactive(True) From bbdc70a620a615c1018adb25afae3d515cdcba2a Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 13 Feb 2023 14:28:10 +0000 Subject: [PATCH 011/113] Move the main handling of disabled up to `Widget` There's still a bit to do here, but this migrates the main work up to the `Widget`. At this point `Button` is pretty much built expressed as a function of what `Widget` provides in terms of things being disabled. Focus can still move into disabled controls (or in this case right now, into a disabled `Button`). The next step is to add something that works alongside `can_focus` to say if a control is currently capable of receiving focus (in other words, it's `not disabled and can_focus`). --- src/textual/widget.py | 4 ++++ src/textual/widgets/_button.py | 30 +++++++++++++----------------- src/textual/widgets/_static.py | 3 ++- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index 6efc732b8..bd32de442 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2140,6 +2140,10 @@ class Widget(DOMNode): """Update from CSS if has focus state changes.""" self._update_styles() + def watch_disabled(self) -> None: + # self.can_focus = not self.disabled + self._update_styles() + def _size_updated( self, size: Size, virtual_size: Size, container_size: Size ) -> None: diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index ceccb8fc7..01877054a 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -39,7 +39,7 @@ class Button(Static, can_focus=True): text-style: bold; } - Button.-disabled { + Button:disabled { opacity: 0.4; text-opacity: 0.7; } @@ -173,34 +173,30 @@ class Button(Static, can_focus=True): def __init__( self, label: TextType | None = None, - disabled: bool = False, variant: ButtonVariant = "default", *, name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ): """Create a Button widget. Args: label: The text that appears within the button. - disabled: Whether the button is disabled or not. variant: The variant of the button. name: The name of the button. id: The ID of the button in the DOM. classes: The CSS classes of the button. + disabled: Whether the button is disabled or not. """ - super().__init__(name=name, id=id, classes=classes) + super().__init__(name=name, id=id, classes=classes, disabled=disabled) if label is None: label = self.css_identifier_styled self.label = self.validate_label(label) - self.disabled = disabled - if disabled: - self.add_class("-disabled") - self.variant = self.validate_variant(variant) def __rich_repr__(self) -> rich.repr.Result: @@ -224,9 +220,9 @@ class Button(Static, can_focus=True): self.remove_class(f"-{old_variant}") self.add_class(f"-{variant}") - def watch_disabled(self, disabled: bool) -> None: - self.set_class(disabled, "-disabled") - self.can_focus = not disabled + # def watch_disabled(self, disabled: bool) -> None: + # self.set_class(disabled, "-disabled") + # self.can_focus = not disabled def validate_label(self, label: RenderableType) -> RenderableType: """Parse markup for self.label""" @@ -269,11 +265,11 @@ class Button(Static, can_focus=True): def success( cls, label: TextType | None = None, - disabled: bool = False, *, name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> Button: """Utility constructor for creating a success Button variant. @@ -289,22 +285,22 @@ class Button(Static, can_focus=True): """ return Button( label=label, - disabled=disabled, variant="success", name=name, id=id, classes=classes, + disabled=disabled, ) @classmethod def warning( cls, label: TextType | None = None, - disabled: bool = False, *, name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> Button: """Utility constructor for creating a warning Button variant. @@ -320,22 +316,22 @@ class Button(Static, can_focus=True): """ return Button( label=label, - disabled=disabled, variant="warning", name=name, id=id, classes=classes, + disabled=disabled, ) @classmethod def error( cls, label: TextType | None = None, - disabled: bool = False, *, name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> Button: """Utility constructor for creating an error Button variant. @@ -351,9 +347,9 @@ class Button(Static, can_focus=True): """ return Button( label=label, - disabled=disabled, variant="error", name=name, id=id, classes=classes, + disabled=disabled, ) diff --git a/src/textual/widgets/_static.py b/src/textual/widgets/_static.py index a9698a954..d2c2edab2 100644 --- a/src/textual/widgets/_static.py +++ b/src/textual/widgets/_static.py @@ -56,8 +56,9 @@ class Static(Widget, inherit_bindings=False): name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> None: - super().__init__(name=name, id=id, classes=classes) + super().__init__(name=name, id=id, classes=classes, disabled=disabled) self.expand = expand self.shrink = shrink self.markup = markup From 682c4de06d0369efff367eb77d10e627ee8d41ff Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 13 Feb 2023 15:11:33 +0000 Subject: [PATCH 012/113] Remove unnecessary comment Don't use comments as version control! Or, really, I don't need this note to self any more about the code as it's being handled elsewhere. --- src/textual/widget.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index bd32de442..188d80ec4 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2141,7 +2141,6 @@ class Widget(DOMNode): self._update_styles() def watch_disabled(self) -> None: - # self.can_focus = not self.disabled self._update_styles() def _size_updated( From 14f83a0a4abc03ffbd4a3edcc974b9eebc0d7f04 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 13 Feb 2023 15:39:19 +0000 Subject: [PATCH 013/113] Remove commented out code from Button Some hangover from the work to migrate `disabled` out of `Button` and into `Widget`, that I forgot to remove. --- src/textual/widget.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/textual/widget.py b/src/textual/widget.py index 188d80ec4..9849bc924 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1177,6 +1177,11 @@ class Widget(DOMNode): """ return self.virtual_region.grow(self.styles.margin) + @property + def focusable(self) -> bool: + """Can this widget currently receive focus?""" + return self.can_focus and not self.disabled + @property def focusable_children(self) -> list[Widget]: """Get the children which may be focused. From 500458e5f386923f05061cea6b71a25cd9f10a3a Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 13 Feb 2023 15:39:19 +0000 Subject: [PATCH 014/113] Add a focusable property We want to maintain `can_focus` as "this is a thing that can, at some point, receive focus, assuming it isn't disabled". So `can_focus` is now very much about the ability to receive focus at all. The new `focusable` property becomes about "can this widget receive focus right now?". [This is a rewording of a previous commit -- I get the wrong thing] --- src/textual/widget.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/textual/widget.py b/src/textual/widget.py index 188d80ec4..9849bc924 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1177,6 +1177,11 @@ class Widget(DOMNode): """ return self.virtual_region.grow(self.styles.margin) + @property + def focusable(self) -> bool: + """Can this widget currently receive focus?""" + return self.can_focus and not self.disabled + @property def focusable_children(self) -> list[Widget]: """Get the children which may be focused. From 8379945b60a89be47ebe29630966cc1dbf69864f Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 13 Feb 2023 15:43:40 +0000 Subject: [PATCH 015/113] Remove commented out code from Button Some hangover from the work to migrate `disabled` out of `Button` and into `Widget`, that I forgot to remove. --- src/textual/widgets/_button.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 01877054a..fbe49279c 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -220,10 +220,6 @@ class Button(Static, can_focus=True): self.remove_class(f"-{old_variant}") self.add_class(f"-{variant}") - # def watch_disabled(self, disabled: bool) -> None: - # self.set_class(disabled, "-disabled") - # self.can_focus = not disabled - def validate_label(self, label: RenderableType) -> RenderableType: """Parse markup for self.label""" if isinstance(label, str): From 0171ad7c85d8b81bd148503e486ed64b603af83f Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 13 Feb 2023 15:44:02 +0000 Subject: [PATCH 016/113] Build the focus chain from focusable widgets Rather than it being about widgets where `can_focus` is `True`, have it be about widgets that are currently able to receive focus. Before this change widgets with a positive `can_focus` but which were disabled would still end up in the chain. --- src/textual/screen.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/screen.py b/src/textual/screen.py index d1354a106..33848f529 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -177,7 +177,7 @@ class Screen(Widget): else: if node.is_container and node.can_focus_children: push(iter(node.focusable_children)) - if node.can_focus: + if node.focusable: add_widget(node) return widgets From 850c140a05d5174fc4dceb719d7f6ec87008eb7f Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 13 Feb 2023 16:10:28 +0000 Subject: [PATCH 017/113] Swap `set_focus` over to checking focusable --- src/textual/screen.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/screen.py b/src/textual/screen.py index 33848f529..c8ccb884e 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -351,7 +351,7 @@ class Screen(Widget): self.focused.post_message_no_wait(events.Blur(self)) self.focused = None self.log.debug("focus was removed") - elif widget.can_focus: + elif widget.focusable: if self.focused != widget: if self.focused is not None: # Blur currently focused widget From 813c89c1ef3453f98947cfbe8a6234ae99077ec2 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 13 Feb 2023 16:12:28 +0000 Subject: [PATCH 018/113] Swap _forward_event over to focsuable (from can_focus) --- src/textual/screen.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/screen.py b/src/textual/screen.py index c8ccb884e..34c3db79c 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -547,7 +547,7 @@ class Screen(Widget): except errors.NoWidget: self.set_focus(None) else: - if isinstance(event, events.MouseUp) and widget.can_focus: + if isinstance(event, events.MouseUp) and widget.focusable: if self.focused is not widget: self.set_focus(widget) event.stop() From d148dd6237ed728cfef5220210f6b6415e0e53b8 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 13 Feb 2023 16:14:13 +0000 Subject: [PATCH 019/113] Swap _reset_focus over to focsuable (from can_focus) --- src/textual/screen.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/screen.py b/src/textual/screen.py index 34c3db79c..34fdad3d0 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -314,7 +314,7 @@ class Screen(Widget): # It may have been made invisible # Move to a sibling if possible for sibling in widget.visible_siblings: - if sibling not in avoiding and sibling.can_focus: + if sibling not in avoiding and sibling.focusable: self.set_focus(sibling) break else: From 525455bf76b4685738a6f51097e93a15217f14ec Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 13 Feb 2023 16:19:38 +0000 Subject: [PATCH 020/113] Bring the focus_chain docstring in line with out newer guidelines --- src/textual/screen.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/textual/screen.py b/src/textual/screen.py index 34fdad3d0..ef73c8a63 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -159,11 +159,7 @@ class Screen(Widget): @property def focus_chain(self) -> list[Widget]: - """Get widgets that may receive focus, in focus order. - - Returns: - List of Widgets in focus order. - """ + """A list of widgets that may receive focus, in focus order.""" widgets: list[Widget] = [] add_widget = widgets.append stack: list[Iterator[Widget]] = [iter(self.focusable_children)] From ea8470ee7a8600d3f2f1e4777f0dd0385c3ac0d5 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 13 Feb 2023 16:55:08 +0000 Subject: [PATCH 021/113] Stop mouse events going to a widget that can't receive focus right now --- src/textual/widget.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/textual/widget.py b/src/textual/widget.py index 9849bc924..80f32c581 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2436,6 +2436,14 @@ class Widget(DOMNode): """ self.app.capture_mouse(None) + def check_message_enabled(self, message: Message) -> bool: + # Do the normal checking and get out if that fails. + if not super().check_message_enabled(message): + return False + # Otherwise, if this is a mouse event the widget receiving the event + # must be capable of being focused right at this moment. + return self.focusable if isinstance(message, events.MouseEvent) else True + async def broker_event(self, event_name: str, event: events.Event) -> bool: return await self.app._broker_event(event_name, event, default_namespace=self) From c7990d990d3eb176e353e470e5b08e4c511ebb0a Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 14 Feb 2023 09:56:09 +0000 Subject: [PATCH 022/113] Add support for disabling a widget and all of its children --- src/textual/widget.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index 80f32c581..83f853bb8 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1177,10 +1177,19 @@ class Widget(DOMNode): """ return self.virtual_region.grow(self.styles.margin) + @property + def _self_or_ancestors_disabled(self) -> bool: + """Is this widget or any of its ancestors disabled?""" + return any( + node.disabled + for node in self.ancestors_with_self + if isinstance(node, Widget) + ) + @property def focusable(self) -> bool: """Can this widget currently receive focus?""" - return self.can_focus and not self.disabled + return self.can_focus and not self._self_or_ancestors_disabled @property def focusable_children(self) -> list[Widget]: @@ -2091,7 +2100,7 @@ class Widget(DOMNode): Names of the pseudo classes. """ - yield "disabled" if self.disabled else "enabled" + yield "disabled" if self._self_or_ancestors_disabled else "enabled" if self.mouse_over: yield "hover" if self.has_focus: @@ -2146,7 +2155,8 @@ class Widget(DOMNode): self._update_styles() def watch_disabled(self) -> None: - self._update_styles() + for node in self.walk_children(with_self=True): + node._update_styles() def _size_updated( self, size: Size, virtual_size: Size, container_size: Size From 674ee26b88f5fcbbf16f84f9e0496fbfc76c9670 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 14 Feb 2023 10:40:17 +0000 Subject: [PATCH 023/113] Add a docstring to watch_disabled --- src/textual/widget.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/widget.py b/src/textual/widget.py index 5c817fd6a..cf1c4f0a9 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2153,6 +2153,7 @@ class Widget(DOMNode): self._update_styles() def watch_disabled(self) -> None: + """Update the styles of the widget and its children when disabled is toggled.""" for node in self.walk_children(with_self=True): node._update_styles() From d27d9f81affc1e9a185cf4be13f000a401dac314 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 14 Feb 2023 10:51:32 +0000 Subject: [PATCH 024/113] Only block mouse events if disabled, not if not focusable --- src/textual/widget.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index cf1c4f0a9..53c1585f9 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2449,9 +2449,13 @@ class Widget(DOMNode): # Do the normal checking and get out if that fails. if not super().check_message_enabled(message): return False - # Otherwise, if this is a mouse event the widget receiving the event - # must be capable of being focused right at this moment. - return self.focusable if isinstance(message, events.MouseEvent) else True + # Otherwise, if this is a mouse event, the widget receiving the + # event must not be disabled at this moment. + return ( + not self._self_or_ancestors_disabled + if isinstance(message, events.MouseEvent) + else True + ) async def broker_event(self, event_name: str, event: events.Event) -> bool: return await self.app._broker_event(event_name, event, default_namespace=self) From 7c81247a95538160aa3b638da617c1969c8c2fcd Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 14 Feb 2023 11:22:25 +0000 Subject: [PATCH 025/113] Remove Button.watch_mouse_over This isn't needed any more. --- src/textual/widgets/_button.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index fbe49279c..fdae0128b 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -204,11 +204,6 @@ class Button(Static, can_focus=True): yield "variant", self.variant, "default" yield "disabled", self.disabled, False - def watch_mouse_over(self, value: bool) -> None: - """Update from CSS if mouse over state changes.""" - if self._has_hover_style and not self.disabled: - self._update_styles() - def validate_variant(self, variant: str) -> str: if variant not in _VALID_BUTTON_VARIANTS: raise InvalidButtonVariant( From 068aa07a5e8cc04eeeaea7cff0fb541eeda4ba20 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 14 Feb 2023 11:28:31 +0000 Subject: [PATCH 026/113] Only enable Enter and Leave if not disabled Until now I was just removing all events that inherited from MouseEvent when checking if an event was enabled for a given widget, in relation to the widget itself being disabled. Enter and Leave also need to be taken into account; they don't inherit from MouseEvent (they're more reactions to mouse events) but should be handled too. --- src/textual/widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index 53c1585f9..eaf537ade 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2453,7 +2453,7 @@ class Widget(DOMNode): # event must not be disabled at this moment. return ( not self._self_or_ancestors_disabled - if isinstance(message, events.MouseEvent) + if isinstance(message, (events.MouseEvent, events.Enter, events.Leave)) else True ) From cccf7afccbc91ad091cf549c32f87f1083acf6b6 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 14 Feb 2023 11:30:55 +0000 Subject: [PATCH 027/113] Remove :disabled styling from the `Button` This will move up to the App's default styles. --- src/textual/widgets/_button.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index fdae0128b..d9da388f5 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -39,11 +39,6 @@ class Button(Static, can_focus=True): text-style: bold; } - Button:disabled { - opacity: 0.4; - text-opacity: 0.7; - } - Button:focus { text-style: bold reverse; } From c65278d0388a1e4682aadb8512edd8c945199a6d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 14 Feb 2023 11:31:31 +0000 Subject: [PATCH 028/113] Add default disabled styling for the whole application --- src/textual/app.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/textual/app.py b/src/textual/app.py index 46a11d9bc..0a425c839 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -241,6 +241,11 @@ class App(Generic[ReturnType], DOMNode): background: $background; color: $text; } + + *:disabled { + opacity: 0.6; + text-opacity: 0.8; + } """ SCREENS: dict[str, Screen | Callable[[], Screen]] = {} From 3c8f4648183af5e742d7d1e2b3e785d334ff38df Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 14 Feb 2023 11:44:36 +0000 Subject: [PATCH 029/113] Update the snapshots The disabled styling has changed slightly, and there is a snapshot test for buttons which is now thrown off. This updates that. --- .../__snapshots__/test_snapshots.ambr | 172 +++++++++--------- 1 file changed, 86 insertions(+), 86 deletions(-) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index a9f31e2aa..c1f1dfb46 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -181,162 +181,162 @@ font-weight: 700; } - .terminal-3615181303-matrix { + .terminal-2059425018-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3615181303-title { + .terminal-2059425018-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3615181303-r1 { fill: #e1e1e1 } - .terminal-3615181303-r2 { fill: #c5c8c6 } - .terminal-3615181303-r3 { fill: #e1e1e1;font-weight: bold } - .terminal-3615181303-r4 { fill: #454a50 } - .terminal-3615181303-r5 { fill: #292b2e } - .terminal-3615181303-r6 { fill: #24292f;font-weight: bold } - .terminal-3615181303-r7 { fill: #555657;font-weight: bold } - .terminal-3615181303-r8 { fill: #000000 } - .terminal-3615181303-r9 { fill: #161617 } - .terminal-3615181303-r10 { fill: #507bb3 } - .terminal-3615181303-r11 { fill: #283c52 } - .terminal-3615181303-r12 { fill: #dde6ed;font-weight: bold } - .terminal-3615181303-r13 { fill: #4f5a62;font-weight: bold } - .terminal-3615181303-r14 { fill: #001541 } - .terminal-3615181303-r15 { fill: #122032 } - .terminal-3615181303-r16 { fill: #7ae998 } - .terminal-3615181303-r17 { fill: #3d6a4a } - .terminal-3615181303-r18 { fill: #0a180e;font-weight: bold } - .terminal-3615181303-r19 { fill: #1e2f23;font-weight: bold } - .terminal-3615181303-r20 { fill: #008139 } - .terminal-3615181303-r21 { fill: #1b4c2f } - .terminal-3615181303-r22 { fill: #ffcf56 } - .terminal-3615181303-r23 { fill: #775f2f } - .terminal-3615181303-r24 { fill: #211505;font-weight: bold } - .terminal-3615181303-r25 { fill: #392b18;font-weight: bold } - .terminal-3615181303-r26 { fill: #b86b00 } - .terminal-3615181303-r27 { fill: #644316 } - .terminal-3615181303-r28 { fill: #e76580 } - .terminal-3615181303-r29 { fill: #683540 } - .terminal-3615181303-r30 { fill: #f5e5e9;font-weight: bold } - .terminal-3615181303-r31 { fill: #6c595e;font-weight: bold } - .terminal-3615181303-r32 { fill: #780028 } - .terminal-3615181303-r33 { fill: #491928 } + .terminal-2059425018-r1 { fill: #e1e1e1 } + .terminal-2059425018-r2 { fill: #c5c8c6 } + .terminal-2059425018-r3 { fill: #e1e1e1;font-weight: bold } + .terminal-2059425018-r4 { fill: #454a50 } + .terminal-2059425018-r5 { fill: #313437 } + .terminal-2059425018-r6 { fill: #24292f;font-weight: bold } + .terminal-2059425018-r7 { fill: #7c7d7e;font-weight: bold } + .terminal-2059425018-r8 { fill: #000000 } + .terminal-2059425018-r9 { fill: #101011 } + .terminal-2059425018-r10 { fill: #507bb3 } + .terminal-2059425018-r11 { fill: #324f70 } + .terminal-2059425018-r12 { fill: #dde6ed;font-weight: bold } + .terminal-2059425018-r13 { fill: #75828b;font-weight: bold } + .terminal-2059425018-r14 { fill: #001541 } + .terminal-2059425018-r15 { fill: #0c1e39 } + .terminal-2059425018-r16 { fill: #7ae998 } + .terminal-2059425018-r17 { fill: #4f9262 } + .terminal-2059425018-r18 { fill: #0a180e;font-weight: bold } + .terminal-2059425018-r19 { fill: #192e1f;font-weight: bold } + .terminal-2059425018-r20 { fill: #008139 } + .terminal-2059425018-r21 { fill: #156034 } + .terminal-2059425018-r22 { fill: #ffcf56 } + .terminal-2059425018-r23 { fill: #a4823a } + .terminal-2059425018-r24 { fill: #211505;font-weight: bold } + .terminal-2059425018-r25 { fill: #3a2a13;font-weight: bold } + .terminal-2059425018-r26 { fill: #b86b00 } + .terminal-2059425018-r27 { fill: #825210 } + .terminal-2059425018-r28 { fill: #e76580 } + .terminal-2059425018-r29 { fill: #904354 } + .terminal-2059425018-r30 { fill: #f5e5e9;font-weight: bold } + .terminal-2059425018-r31 { fill: #978186;font-weight: bold } + .terminal-2059425018-r32 { fill: #780028 } + .terminal-2059425018-r33 { fill: #5b132a } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ButtonsApp + ButtonsApp - - - - - Standard ButtonsDisabled Buttons - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - DefaultDefault - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Primary!Primary! - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Success!Success! - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Warning!Warning! - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Error!Error! - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - + + + + + Standard ButtonsDisabled Buttons + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + DefaultDefault + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Primary!Primary! + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Success!Success! + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Warning!Warning! + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Error!Error! + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + From 9e4d4aae5e7bb7d98bf41b7acceb6af4f72005d4 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 14 Feb 2023 12:50:58 +0000 Subject: [PATCH 030/113] Add a disabled keyword argument to the Switch constructor --- src/textual/widgets/_switch.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_switch.py b/src/textual/widgets/_switch.py index 45e856dd1..97e3e2223 100644 --- a/src/textual/widgets/_switch.py +++ b/src/textual/widgets/_switch.py @@ -100,6 +100,7 @@ class Switch(Widget, can_focus=True): name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ): """Initialise the switch. @@ -109,8 +110,9 @@ class Switch(Widget, can_focus=True): name: The name of the switch. id: The ID of the switch in the DOM. classes: The CSS classes of the switch. + disabled: Whether the switch is disabled or not. """ - super().__init__(name=name, id=id, classes=classes) + super().__init__(name=name, id=id, classes=classes, disabled=disabled) if value: self.slider_pos = 1.0 self._reactive_value = value From e1a60d9225b6eec52f2ec3ec62c342939c8ab418 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 14 Feb 2023 12:53:56 +0000 Subject: [PATCH 031/113] Add a disabled keyword argument to the Input constructor --- src/textual/widgets/_input.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index c652da9a5..eb9471889 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -176,6 +176,7 @@ class Input(Widget, can_focus=True): name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> None: """Initialise the `Input` widget. @@ -187,8 +188,9 @@ class Input(Widget, can_focus=True): name: Optional name for the input widget. id: Optional ID for the widget. classes: Optional initial classes for the widget. + disabled: Whether the input is disabled or not. """ - super().__init__(name=name, id=id, classes=classes) + super().__init__(name=name, id=id, classes=classes, disabled=disabled) if value is not None: self.value = value self.placeholder = placeholder From 20aaf0f20507bd67acbfa9d65e328234e3d65de8 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 14 Feb 2023 13:04:44 +0000 Subject: [PATCH 032/113] Add a disabled keyword argument to the ListView constructor --- src/textual/widgets/_list_view.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_list_view.py b/src/textual/widgets/_list_view.py index 76c0fdf05..295934feb 100644 --- a/src/textual/widgets/_list_view.py +++ b/src/textual/widgets/_list_view.py @@ -71,6 +71,7 @@ class ListView(Vertical, can_focus=True, can_focus_children=False): name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> None: """ Args: @@ -79,8 +80,11 @@ class ListView(Vertical, can_focus=True, can_focus_children=False): name: The name of the widget. id: The unique ID of the widget used in CSS/query selection. classes: The CSS classes of the widget. + disabled: Whether the ListView is disabled or not. """ - super().__init__(*children, name=name, id=id, classes=classes) + super().__init__( + *children, name=name, id=id, classes=classes, disabled=disabled + ) self._index = initial_index def on_mount(self) -> None: From 69af2bfd5783ef81fee66ccf7090fca353412632 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 14 Feb 2023 13:11:44 +0000 Subject: [PATCH 033/113] Add a disabled keyword argument to the Tree constructor --- src/textual/widgets/_tree.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 5117d10dc..1a99fd9db 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -467,8 +467,9 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> None: - super().__init__(name=name, id=id, classes=classes) + super().__init__(name=name, id=id, classes=classes, disabled=disabled) text_label = self.process_label(label) From 67b57d8a286346ee8d716d9322e1426441f85251 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 14 Feb 2023 13:14:52 +0000 Subject: [PATCH 034/113] Add a disabled keyword argument to the DirectoryTree constructor --- src/textual/widgets/_directory_tree.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 9b3d33fc5..8b481a552 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -29,6 +29,7 @@ class DirectoryTree(Tree[DirEntry]): name: The name of the widget, or None for no name. Defaults to None. id: The ID of the widget in the DOM, or None for no ID. Defaults to None. classes: A space-separated list of classes, or None for no classes. Defaults to None. + disabled: Whether the directory tree is disabled or not. """ COMPONENT_CLASSES: ClassVar[set[str]] = { @@ -87,6 +88,7 @@ class DirectoryTree(Tree[DirEntry]): name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> None: self.path = path super().__init__( @@ -95,6 +97,7 @@ class DirectoryTree(Tree[DirEntry]): name=name, id=id, classes=classes, + disabled=disabled, ) def process_label(self, label: TextType): From 7c5020ddd3e580417091fe187d7c20475f839c5c Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 14 Feb 2023 13:27:44 +0000 Subject: [PATCH 035/113] Add a disabled keyword argument to the TextLog constructor --- src/textual/widgets/_text_log.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_text_log.py b/src/textual/widgets/_text_log.py index f78934e70..c7efee07a 100644 --- a/src/textual/widgets/_text_log.py +++ b/src/textual/widgets/_text_log.py @@ -43,8 +43,9 @@ class TextLog(ScrollView, can_focus=True): name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> None: - super().__init__(name=name, id=id, classes=classes) + super().__init__(name=name, id=id, classes=classes, disabled=disabled) self.max_lines = max_lines self._start_line: int = 0 self.lines: list[Strip] = [] From 5e0190c43fec6b896df76c3406310482c77da8d2 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 14 Feb 2023 14:47:57 +0000 Subject: [PATCH 036/113] Add unit tests for disabled property and pseudoclass --- tests/test_disabled.py | 80 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 tests/test_disabled.py diff --git a/tests/test_disabled.py b/tests/test_disabled.py new file mode 100644 index 000000000..2f59fee71 --- /dev/null +++ b/tests/test_disabled.py @@ -0,0 +1,80 @@ +"""Test Widget.disabled.""" + +from textual.app import App, ComposeResult +from textual.containers import Vertical +from textual.widgets import ( + Button, + DataTable, + DirectoryTree, + Input, + ListView, + Switch, + TextLog, + Tree, +) + + +class DisableApp(App[None]): + """Application for testing Widget.disable.""" + + def compose(self) -> ComposeResult: + """Compose the child widgets.""" + yield Vertical( + Button(), + DataTable(), + DirectoryTree("."), + Input(), + ListView(), + Switch(), + TextLog(), + Tree("Test"), + id="test-container", + ) + + +async def test_all_initially_enabled() -> None: + """All widgets should start out enabled.""" + async with DisableApp().run_test() as pilot: + assert all( + not node.disabled for node in pilot.app.screen.query("#test-container > *") + ) + + +async def test_enabled_widgets_have_enabled_pseudo_class() -> None: + """All enabled widgets should have the :enabled pseudoclass.""" + async with DisableApp().run_test() as pilot: + assert all( + node.has_pseudo_class("enabled") and not node.has_pseudo_class("disabled") + for node in pilot.app.screen.query("#test-container > *") + ) + + +async def test_all_individually_disabled() -> None: + """Post-disable all widgets should report being disabled.""" + async with DisableApp().run_test() as pilot: + for node in pilot.app.screen.query("Vertical > *"): + node.disabled = True + assert all( + node.disabled for node in pilot.app.screen.query("#test-container > *") + ) + + +async def test_disabled_widgets_have_disabled_pseudo_class() -> None: + """All disabled widgets should have the :disabled pseudoclass.""" + async with DisableApp().run_test() as pilot: + for node in pilot.app.screen.query("#test-container > *"): + node.disabled = True + assert all( + node.has_pseudo_class("disabled") and not node.has_pseudo_class("enabled") + for node in pilot.app.screen.query("#test-container > *") + ) + + +async def test_disable_via_container() -> None: + """All child widgets should appear (to CSS) as disabled by a container being disabled.""" + async with DisableApp().run_test() as pilot: + pilot.app.screen.query_one("#test-container", Vertical).disabled = True + assert all( + node.has_pseudo_class("disabled") and not node.has_pseudo_class("enabled") + for node in pilot.app.screen.query("#test-container > *") + ) From e26e75a9d1ec1cb2be335d0f0a5c82f0730a5dc8 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 14 Feb 2023 14:59:53 +0000 Subject: [PATCH 037/113] Swap `game_playable` to use the simplified disable ability --- examples/five_by_five.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/five_by_five.py b/examples/five_by_five.py index f3aada19f..b79deef8a 100644 --- a/examples/five_by_five.py +++ b/examples/five_by_five.py @@ -194,8 +194,7 @@ class Game(Screen): Args: playable (bool): Should the game currently be playable? """ - for cell in self.query(GameCell): - cell.disabled = not playable + self.query_one(GameGrid).disabled = not playable def cell(self, row: int, col: int) -> GameCell: """Get the cell at a given location. From 7763020f53b3fa906620529cce749cda87395d5f Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 14 Feb 2023 15:00:57 +0000 Subject: [PATCH 038/113] Move future import to after the module docstring Looks like someone added this but placed it before the module docstring. Fixing it as I notice it. --- examples/five_by_five.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/five_by_five.py b/examples/five_by_five.py index b79deef8a..d986175a5 100644 --- a/examples/five_by_five.py +++ b/examples/five_by_five.py @@ -1,7 +1,7 @@ -from __future__ import annotations - """Simple version of 5x5, developed for/with Textual.""" +from __future__ import annotations + from pathlib import Path from typing import TYPE_CHECKING, cast From fbd871c3d7baaafac4c519da7c4dfc59182472fc Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 14 Feb 2023 15:52:52 +0000 Subject: [PATCH 039/113] Start a snapshot test for disabled widgets Eventually this should likely have every user-interactive widget within it. Perhaps every widget. --- .../snapshot_apps/disable_widgets.py | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 tests/snapshot_tests/snapshot_apps/disable_widgets.py diff --git a/tests/snapshot_tests/snapshot_apps/disable_widgets.py b/tests/snapshot_tests/snapshot_apps/disable_widgets.py new file mode 100644 index 000000000..8a9515427 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/disable_widgets.py @@ -0,0 +1,58 @@ +from textual.app import App, ComposeResult +from textual.containers import Vertical +from textual.widgets import ( + Header, Footer, Button, DataTable, Input, ListView, ListItem, Label, Tree, TextLog +) + +class WidgetDisableTestApp( App[ None ] ): + + CSS = """ + DataTable, ListView, Tree { + height: 2; + } + """ + + @property + def data_table(self) -> DataTable: + data_table = DataTable() + data_table.add_columns( "Column 1", "Column 2", "Column 3", "Column 4" ) + data_table.add_rows( + [ ( str( n ), str( n * 10 ), str( n * 100 ), str( n * 1000 ) ) for n in range( 100 ) ] + ) + return data_table + + @property + def list_view( self ) -> ListView: + return ListView( + *[ ListItem( Label( f"This is list item {n}" ) ) for n in range( 20 ) ] + ) + + @property + def test_tree( self ) -> Tree: + tree = Tree[None]( label="This is a test tree" ) + for n in range( 10 ): + tree.root.add_leaf( f"Leaf {n}" ) + tree.root.expand() + return tree + + def compose( self ) -> ComposeResult: + yield Header() + yield Vertical( + Button(), + self.data_table, + self.list_view, + self.test_tree, + TextLog(), + Input(), + Input(placeholder="This is an empty input with a placeholder"), + Input("This is some text in an input"), + id="test-container", + ) + yield Footer() + + def on_mount(self) -> None: + self.query_one(TextLog).write("Hello, World!") + self.query_one("#test-container", Vertical).disabled = True + +if __name__ == "__main__": + WidgetDisableTestApp().run() From b4f8a6b7788ead9328f0e8149865f7a71b856fb3 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 14 Feb 2023 15:53:45 +0000 Subject: [PATCH 040/113] Add disabled snapshot test to the general snapshot tests --- .../__snapshots__/test_snapshots.ambr | 171 ++++++++++++++++++ tests/snapshot_tests/test_snapshots.py | 4 + 2 files changed, 175 insertions(+) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index c1f1dfb46..7448ad637 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -10656,6 +10656,177 @@ ''' # --- +# name: test_disabled_widgets + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + WidgetDisableTestApp + + + + + + + + + + WidgetDisableTestApp + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Button + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +  Column 1  Column 2  Column 3  Column 4  +  0         0         0         0         + This is list item 0 + This is list item 1 + ▼ This is a test tree + ├── Leaf 0 + Hello, World! + + + ▇▇ + + + + + + + + + + + + + + + ''' +# --- # name: test_dock_layout_sidebar ''' diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index c77025d4d..1eed7ab77 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -218,3 +218,7 @@ def test_auto_width_input(snap_compare): def test_screen_switch(snap_compare): assert snap_compare(SNAPSHOT_APPS_DIR / "screen_switch.py", press=["a", "b"]) + + +def test_disabled_widgets(snap_compare): + assert snap_compare(SNAPSHOT_APPS_DIR / "disable_widgets.py") From 26df6aeb008936c79e8200cc86e951993860cd30 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 14 Feb 2023 16:20:52 +0000 Subject: [PATCH 041/113] Tidy up the disabled snapshot test --- .../snapshot_apps/disable_widgets.py | 50 +++++++++++++------ 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/tests/snapshot_tests/snapshot_apps/disable_widgets.py b/tests/snapshot_tests/snapshot_apps/disable_widgets.py index 8a9515427..809f0c7c5 100644 --- a/tests/snapshot_tests/snapshot_apps/disable_widgets.py +++ b/tests/snapshot_tests/snapshot_apps/disable_widgets.py @@ -1,13 +1,26 @@ from textual.app import App, ComposeResult -from textual.containers import Vertical +from textual.containers import Vertical, Horizontal from textual.widgets import ( - Header, Footer, Button, DataTable, Input, ListView, ListItem, Label, Tree, TextLog + Header, + Footer, + Button, + DataTable, + Input, + ListView, + ListItem, + Label, + Tree, + TextLog, ) -class WidgetDisableTestApp( App[ None ] ): + +class WidgetDisableTestApp(App[None]): CSS = """ - DataTable, ListView, Tree { + Horizontal { + height: auto; + } + DataTable, ListView, Tree, TextLog { height: 2; } """ @@ -15,30 +28,34 @@ class WidgetDisableTestApp( App[ None ] ): @property def data_table(self) -> DataTable: data_table = DataTable() - data_table.add_columns( "Column 1", "Column 2", "Column 3", "Column 4" ) + data_table.add_columns("Column 1", "Column 2", "Column 3", "Column 4") data_table.add_rows( - [ ( str( n ), str( n * 10 ), str( n * 100 ), str( n * 1000 ) ) for n in range( 100 ) ] + [(str(n), str(n * 10), str(n * 100), str(n * 1000)) for n in range(100)] ) return data_table @property - def list_view( self ) -> ListView: - return ListView( - *[ ListItem( Label( f"This is list item {n}" ) ) for n in range( 20 ) ] - ) + def list_view(self) -> ListView: + return ListView(*[ListItem(Label(f"This is list item {n}")) for n in range(20)]) @property - def test_tree( self ) -> Tree: - tree = Tree[None]( label="This is a test tree" ) - for n in range( 10 ): - tree.root.add_leaf( f"Leaf {n}" ) + def test_tree(self) -> Tree: + tree = Tree[None](label="This is a test tree") + for n in range(10): + tree.root.add_leaf(f"Leaf {n}") tree.root.expand() return tree - def compose( self ) -> ComposeResult: + def compose(self) -> ComposeResult: yield Header() yield Vertical( - Button(), + Horizontal( + Button(), + Button(variant="primary"), + Button(variant="success"), + Button(variant="warning"), + Button(variant="error"), + ), self.data_table, self.list_view, self.test_tree, @@ -54,5 +71,6 @@ class WidgetDisableTestApp( App[ None ] ): self.query_one(TextLog).write("Hello, World!") self.query_one("#test-container", Vertical).disabled = True + if __name__ == "__main__": WidgetDisableTestApp().run() From 8ebb70efac6419aa9d4520bf04cceae90120d9f0 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 14 Feb 2023 16:22:15 +0000 Subject: [PATCH 042/113] Refresh the snapshots --- .../__snapshots__/test_snapshots.ambr | 157 ++++++++++-------- 1 file changed, 86 insertions(+), 71 deletions(-) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 7448ad637..3d9516f10 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -10679,147 +10679,162 @@ font-weight: 700; } - .terminal-751021020-matrix { + .terminal-2139170380-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-751021020-title { + .terminal-2139170380-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-751021020-r1 { fill: #c5c8c6 } - .terminal-751021020-r2 { fill: #e3e3e3 } - .terminal-751021020-r3 { fill: #313437 } - .terminal-751021020-r4 { fill: #e1e1e1 } - .terminal-751021020-r5 { fill: #7c7d7e;font-weight: bold } - .terminal-751021020-r6 { fill: #101011 } - .terminal-751021020-r7 { fill: #75828b;font-weight: bold } - .terminal-751021020-r8 { fill: #7b7b7b } - .terminal-751021020-r9 { fill: #3a2a13 } - .terminal-751021020-r10 { fill: #78838b } - .terminal-751021020-r11 { fill: #7f8081 } - .terminal-751021020-r12 { fill: #7c7d7e } - .terminal-751021020-r13 { fill: #31220c;font-weight: bold } - .terminal-751021020-r14 { fill: #e2e3e3 } - .terminal-751021020-r15 { fill: #104e2d } - .terminal-751021020-r16 { fill: #7a7b7b } - .terminal-751021020-r17 { fill: #14191f } - .terminal-751021020-r18 { fill: #ddedf9 } + .terminal-2139170380-r1 { fill: #c5c8c6 } + .terminal-2139170380-r2 { fill: #e3e3e3 } + .terminal-2139170380-r3 { fill: #313437 } + .terminal-2139170380-r4 { fill: #324f70 } + .terminal-2139170380-r5 { fill: #4f9262 } + .terminal-2139170380-r6 { fill: #a4823a } + .terminal-2139170380-r7 { fill: #904354 } + .terminal-2139170380-r8 { fill: #7c7d7e;font-weight: bold } + .terminal-2139170380-r9 { fill: #75828b;font-weight: bold } + .terminal-2139170380-r10 { fill: #192e1f;font-weight: bold } + .terminal-2139170380-r11 { fill: #3a2a13;font-weight: bold } + .terminal-2139170380-r12 { fill: #978186;font-weight: bold } + .terminal-2139170380-r13 { fill: #101011 } + .terminal-2139170380-r14 { fill: #0c1e39 } + .terminal-2139170380-r15 { fill: #156034 } + .terminal-2139170380-r16 { fill: #825210 } + .terminal-2139170380-r17 { fill: #5b132a } + .terminal-2139170380-r18 { fill: #7b7b7b } + .terminal-2139170380-r19 { fill: #e1e1e1 } + .terminal-2139170380-r20 { fill: #3a2a13 } + .terminal-2139170380-r21 { fill: #78838b } + .terminal-2139170380-r22 { fill: #7f8081 } + .terminal-2139170380-r23 { fill: #7c7d7e } + .terminal-2139170380-r24 { fill: #31220c;font-weight: bold } + .terminal-2139170380-r25 { fill: #e2e3e3 } + .terminal-2139170380-r26 { fill: #104e2d } + .terminal-2139170380-r27 { fill: #7a7b7b } + .terminal-2139170380-r28 { fill: #1c1c1c } + .terminal-2139170380-r29 { fill: #191919 } + .terminal-2139170380-r30 { fill: #181818 } + .terminal-2139170380-r31 { fill: #7c7c7c } + .terminal-2139170380-r32 { fill: #494949 } + .terminal-2139170380-r33 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - WidgetDisableTestApp + WidgetDisableTestApp - - - - WidgetDisableTestApp - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Button - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ -  Column 1  Column 2  Column 3  Column 4  -  0         0         0         0         - This is list item 0 - This is list item 1 - ▼ This is a test tree - ├── Leaf 0 - Hello, World! - - - ▇▇ - - - - - - - - - + + + + WidgetDisableTestApp + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ButtonButtonButtonButtonButton + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +  Column 1  Column 2  Column 3  Column 4  +  0         0         0         0         + This is list item 0 + This is list item 1 + ▼ This is a test tree + ├── Leaf 0 + Hello, World! + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + This is an empty input with a placeholder + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + This is some text in an input + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + From 3ccfb5370f74ac6376897afbf9b86e3b1e5baaa1 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 15 Feb 2023 11:16:23 +0000 Subject: [PATCH 043/113] Add Markdown and MarkdownViewer to the disabled property test --- tests/test_disabled.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_disabled.py b/tests/test_disabled.py index 2f59fee71..8ad3d0e09 100644 --- a/tests/test_disabled.py +++ b/tests/test_disabled.py @@ -8,6 +8,8 @@ from textual.widgets import ( DirectoryTree, Input, ListView, + Markdown, + MarkdownViewer, Switch, TextLog, Tree, @@ -28,6 +30,8 @@ class DisableApp(App[None]): Switch(), TextLog(), Tree("Test"), + Markdown(), + MarkdownViewer(), id="test-container", ) From 2379bb46e06fa38780f21b56822d2f932b2585c1 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 15 Feb 2023 11:22:58 +0000 Subject: [PATCH 044/113] Add type annotation to the DataTable Now that the new DataTable is merged in. --- tests/snapshot_tests/snapshot_apps/disable_widgets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/snapshot_tests/snapshot_apps/disable_widgets.py b/tests/snapshot_tests/snapshot_apps/disable_widgets.py index 809f0c7c5..321e94f7b 100644 --- a/tests/snapshot_tests/snapshot_apps/disable_widgets.py +++ b/tests/snapshot_tests/snapshot_apps/disable_widgets.py @@ -27,7 +27,7 @@ class WidgetDisableTestApp(App[None]): @property def data_table(self) -> DataTable: - data_table = DataTable() + data_table = DataTable[str]() data_table.add_columns("Column 1", "Column 2", "Column 3", "Column 4") data_table.add_rows( [(str(n), str(n * 10), str(n * 100), str(n * 1000)) for n in range(100)] From d90cae3a92965daa4a13dcbf3f701d5e3b38096f Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 15 Feb 2023 11:26:58 +0000 Subject: [PATCH 045/113] Add Markdown and MarkdownViewer to the disabled snapshot test --- .../__snapshots__/test_snapshots.ambr | 173 +++++++++--------- .../snapshot_apps/disable_widgets.py | 8 + 2 files changed, 95 insertions(+), 86 deletions(-) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 01de5b3e8..088f6aba7 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -10836,162 +10836,163 @@ font-weight: 700; } - .terminal-2139170380-matrix { + .terminal-1429392340-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2139170380-title { + .terminal-1429392340-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2139170380-r1 { fill: #c5c8c6 } - .terminal-2139170380-r2 { fill: #e3e3e3 } - .terminal-2139170380-r3 { fill: #313437 } - .terminal-2139170380-r4 { fill: #324f70 } - .terminal-2139170380-r5 { fill: #4f9262 } - .terminal-2139170380-r6 { fill: #a4823a } - .terminal-2139170380-r7 { fill: #904354 } - .terminal-2139170380-r8 { fill: #7c7d7e;font-weight: bold } - .terminal-2139170380-r9 { fill: #75828b;font-weight: bold } - .terminal-2139170380-r10 { fill: #192e1f;font-weight: bold } - .terminal-2139170380-r11 { fill: #3a2a13;font-weight: bold } - .terminal-2139170380-r12 { fill: #978186;font-weight: bold } - .terminal-2139170380-r13 { fill: #101011 } - .terminal-2139170380-r14 { fill: #0c1e39 } - .terminal-2139170380-r15 { fill: #156034 } - .terminal-2139170380-r16 { fill: #825210 } - .terminal-2139170380-r17 { fill: #5b132a } - .terminal-2139170380-r18 { fill: #7b7b7b } - .terminal-2139170380-r19 { fill: #e1e1e1 } - .terminal-2139170380-r20 { fill: #3a2a13 } - .terminal-2139170380-r21 { fill: #78838b } - .terminal-2139170380-r22 { fill: #7f8081 } - .terminal-2139170380-r23 { fill: #7c7d7e } - .terminal-2139170380-r24 { fill: #31220c;font-weight: bold } - .terminal-2139170380-r25 { fill: #e2e3e3 } - .terminal-2139170380-r26 { fill: #104e2d } - .terminal-2139170380-r27 { fill: #7a7b7b } - .terminal-2139170380-r28 { fill: #1c1c1c } - .terminal-2139170380-r29 { fill: #191919 } - .terminal-2139170380-r30 { fill: #181818 } - .terminal-2139170380-r31 { fill: #7c7c7c } - .terminal-2139170380-r32 { fill: #494949 } - .terminal-2139170380-r33 { fill: #ddedf9 } + .terminal-1429392340-r1 { fill: #c5c8c6 } + .terminal-1429392340-r2 { fill: #e3e3e3 } + .terminal-1429392340-r3 { fill: #313437 } + .terminal-1429392340-r4 { fill: #324f70 } + .terminal-1429392340-r5 { fill: #4f9262 } + .terminal-1429392340-r6 { fill: #a4823a } + .terminal-1429392340-r7 { fill: #904354 } + .terminal-1429392340-r8 { fill: #e1e1e1 } + .terminal-1429392340-r9 { fill: #7c7d7e;font-weight: bold } + .terminal-1429392340-r10 { fill: #75828b;font-weight: bold } + .terminal-1429392340-r11 { fill: #192e1f;font-weight: bold } + .terminal-1429392340-r12 { fill: #3a2a13;font-weight: bold } + .terminal-1429392340-r13 { fill: #978186;font-weight: bold } + .terminal-1429392340-r14 { fill: #101011 } + .terminal-1429392340-r15 { fill: #0c1e39 } + .terminal-1429392340-r16 { fill: #156034 } + .terminal-1429392340-r17 { fill: #825210 } + .terminal-1429392340-r18 { fill: #5b132a } + .terminal-1429392340-r19 { fill: #7b7b7b } + .terminal-1429392340-r20 { fill: #3a2a13 } + .terminal-1429392340-r21 { fill: #78838b } + .terminal-1429392340-r22 { fill: #7f8081 } + .terminal-1429392340-r23 { fill: #7c7d7e } + .terminal-1429392340-r24 { fill: #31220c;font-weight: bold } + .terminal-1429392340-r25 { fill: #e2e3e3 } + .terminal-1429392340-r26 { fill: #104e2d } + .terminal-1429392340-r27 { fill: #7a7b7b } + .terminal-1429392340-r28 { fill: #1c1c1c } + .terminal-1429392340-r29 { fill: #191919 } + .terminal-1429392340-r30 { fill: #181818 } + .terminal-1429392340-r31 { fill: #7c7c7c } + .terminal-1429392340-r32 { fill: #494949 } + .terminal-1429392340-r33 { fill: #14191f } + .terminal-1429392340-r34 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - WidgetDisableTestApp + WidgetDisableTestApp - - - - WidgetDisableTestApp - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ButtonButtonButtonButtonButton - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ -  Column 1  Column 2  Column 3  Column 4  -  0         0         0         0         - This is list item 0 - This is list item 1 - ▼ This is a test tree - ├── Leaf 0 - Hello, World! - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - This is an empty input with a placeholder - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - This is some text in an input - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - + + + + WidgetDisableTestApp + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ButtonButtonButtonButtonButton + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +  Column 1  Column 2  Column 3  Column 4  +  0         0         0         0         + This is list item 0 + This is list item 1 + ▼ This is a test tree + ├── Leaf 0 + Hello, World! + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + This is an empty input with a placeholder + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + This is some text in an input + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▇▇ diff --git a/tests/snapshot_tests/snapshot_apps/disable_widgets.py b/tests/snapshot_tests/snapshot_apps/disable_widgets.py index 321e94f7b..7a241e914 100644 --- a/tests/snapshot_tests/snapshot_apps/disable_widgets.py +++ b/tests/snapshot_tests/snapshot_apps/disable_widgets.py @@ -9,6 +9,8 @@ from textual.widgets import ( ListView, ListItem, Label, + Markdown, + MarkdownViewer, Tree, TextLog, ) @@ -23,6 +25,10 @@ class WidgetDisableTestApp(App[None]): DataTable, ListView, Tree, TextLog { height: 2; } + + Markdown, MarkdownViewer { + height: 1fr; + } """ @property @@ -63,6 +69,8 @@ class WidgetDisableTestApp(App[None]): Input(), Input(placeholder="This is an empty input with a placeholder"), Input("This is some text in an input"), + Markdown("# Hello, World!"), + MarkdownViewer("# Hello, World!"), id="test-container", ) yield Footer() From 148f9c15439822afee3831cee8556f423a46bf4b Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 15 Feb 2023 14:05:15 +0000 Subject: [PATCH 046/113] Update the ChangeLog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b83ccf89..d945ff4d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [0.12.0] - Unreleased + +### Added + +- Added `Widget.disabled` https://github.com/Textualize/textual/pull/1785 + ## [0.11.0] - 2023-02-15 ### Added From 6f0a7541a415e9ac44cd0136a2a2dc7beeed697e Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 15 Feb 2023 14:13:50 +0000 Subject: [PATCH 047/113] Add a disabled keyword to DataTable --- src/textual/widgets/_data_table.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 3a3d5e100..096913578 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -473,8 +473,9 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): name: str | None = None, id: str | None = None, classes: str | None = None, + disabled: bool = False, ) -> None: - super().__init__(name=name, id=id, classes=classes) + super().__init__(name=name, id=id, classes=classes, disabled=disabled) self._data: dict[RowKey, dict[ColumnKey, CellType]] = {} """Contains the cells of the table, indexed by row key and column key. The final positioning of a cell on screen cannot be determined solely by this From 5ceccc53d221ab242bbde9bb3b7619283bb8f308 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 15 Feb 2023 18:21:48 +0000 Subject: [PATCH 048/113] profile --- src/textual/_arrange.py | 2 +- src/textual/_compositor.py | 8 ++++---- src/textual/_layout.py | 17 +++++++++++++++-- src/textual/widget.py | 1 + 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py index d14f0ddc4..f1035688e 100644 --- a/src/textual/_arrange.py +++ b/src/textual/_arrange.py @@ -128,4 +128,4 @@ def arrange( placements.extend(layout_placements) - return placements, arrange_widgets, scroll_spacing + return DockArrangeResult(placements, arrange_widgets, scroll_spacing) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 5489c8687..a81f0b99f 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -25,6 +25,7 @@ from rich.style import Style from . import errors from ._cells import cell_len from ._loop import loop_last +from ._profile import timer from .geometry import NULL_OFFSET, Offset, Region, Size from .strip import Strip @@ -389,12 +390,11 @@ class Compositor: if widget.is_container: # Arrange the layout - placements, arranged_widgets, spacing = widget._arrange( - child_region.size - ) + arrange_result = widget._arrange(child_region.size) + placements, arranged_widgets, spacing = arrange_result.unpack() widgets.update(arranged_widgets) - if placements: + with timer("placements"): # An offset added to all placements placement_offset = container_region.offset placement_scroll_offset = ( diff --git a/src/textual/_layout.py b/src/textual/_layout.py index 5123d832e..f1e698d4d 100644 --- a/src/textual/_layout.py +++ b/src/textual/_layout.py @@ -1,9 +1,12 @@ from __future__ import annotations from abc import ABC, abstractmethod +from dataclasses import dataclass, field from typing import TYPE_CHECKING, ClassVar, NamedTuple -from .geometry import Region, Size, Spacing +from ._cache import FIFOCache +from ._profile import timer +from .geometry import Offset, Region, Size, Spacing if TYPE_CHECKING: from typing_extensions import TypeAlias @@ -11,7 +14,17 @@ if TYPE_CHECKING: from .widget import Widget ArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget]]" -DockArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget], Spacing]" +# DockArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget], Spacing]" + + +@dataclass +class DockArrangeResult: + placements: list[WidgetPlacement] + widgets: set[Widget] + spacing: Spacing + + def unpack(self) -> tuple[list[WidgetPlacement], set[Widget], Spacing]: + return (self.placements, self.widgets, self.spacing) class WidgetPlacement(NamedTuple): diff --git a/src/textual/widget.py b/src/textual/widget.py index 0842aff93..a866c5c02 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -41,6 +41,7 @@ from ._asyncio import create_task from ._context import active_app from ._easing import DEFAULT_SCROLL_EASING from ._layout import Layout +from ._profile import timer from ._segment_tools import align_lines from ._styles_cache import StylesCache from .actions import SkipAction From 8dbc97553dbd240b44ae5780a5e2f4fb09b73434 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 16 Feb 2023 09:49:33 +0000 Subject: [PATCH 049/113] Shave a function call off get_pseudo_classes See https://github.com/Textualize/textual/pull/1785#discussion_r1108208063 for the background. --- src/textual/widget.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index eaf537ade..da2590560 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1180,6 +1180,8 @@ class Widget(DOMNode): @property def _self_or_ancestors_disabled(self) -> bool: """Is this widget or any of its ancestors disabled?""" + # NOTE: Please see the copy of this code in get_pseudo_classes. I + # you change this, change that too. return any( node.disabled for node in self.ancestors_with_self @@ -2098,7 +2100,16 @@ class Widget(DOMNode): Names of the pseudo classes. """ - yield "disabled" if self._self_or_ancestors_disabled else "enabled" + # NOTE: The heart of this yield is a direct copy of + # _self_or_ancestors_disabled. Because this method is called so + # much, here we save one function call as a very small but long-term + # useful optimisation. If _self_or_ancestors_disabled ever changes, + # be sure to reflect that change here! + yield "disabled" if any( + node.disabled + for node in self.ancestors_with_self + if isinstance(node, Widget) + ) else "enabled" if self.mouse_over: yield "hover" if self.has_focus: From c35277aab798450157c4259cd963b59094435944 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 16 Feb 2023 10:07:18 +0000 Subject: [PATCH 050/113] Simplify how the styles are updated See https://github.com/Textualize/textual/pull/1785#discussion_r1108210336 --- src/textual/widget.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index da2590560..3a6af140f 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2165,8 +2165,7 @@ class Widget(DOMNode): def watch_disabled(self) -> None: """Update the styles of the widget and its children when disabled is toggled.""" - for node in self.walk_children(with_self=True): - node._update_styles() + self._update_styles() def _size_updated( self, size: Size, virtual_size: Size, container_size: Size From 3d43d18486b6a73cdc52f3d8a77b497b80066fee Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 16 Feb 2023 10:12:46 +0000 Subject: [PATCH 051/113] Remove disabled from the rich repr of Button It's not specific to Button now. --- src/textual/widgets/_button.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index d9da388f5..ec143323c 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -197,7 +197,6 @@ class Button(Static, can_focus=True): def __rich_repr__(self) -> rich.repr.Result: yield from super().__rich_repr__() yield "variant", self.variant, "default" - yield "disabled", self.disabled, False def validate_variant(self, variant: str) -> str: if variant not in _VALID_BUTTON_VARIANTS: From 8762866b27e0b0889403cb6837f067ca849c5072 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 16 Feb 2023 10:16:56 +0000 Subject: [PATCH 052/113] Fix comment typo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I typo. Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- src/textual/widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index 3a6af140f..d62ef7cc4 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1180,7 +1180,7 @@ class Widget(DOMNode): @property def _self_or_ancestors_disabled(self) -> bool: """Is this widget or any of its ancestors disabled?""" - # NOTE: Please see the copy of this code in get_pseudo_classes. I + # NOTE: Please see the copy of this code in get_pseudo_classes. If # you change this, change that too. return any( node.disabled From 3964a108ca5dd9d3fda620c67bf4713e2969d3e3 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 16 Feb 2023 11:34:11 +0000 Subject: [PATCH 053/113] Add missing parameter to the Static docstring --- src/textual/widgets/_static.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/widgets/_static.py b/src/textual/widgets/_static.py index d2c2edab2..5007f1b1d 100644 --- a/src/textual/widgets/_static.py +++ b/src/textual/widgets/_static.py @@ -36,6 +36,7 @@ class Static(Widget, inherit_bindings=False): name: Name of widget. Defaults to None. id: ID of Widget. Defaults to None. classes: Space separated list of class names. Defaults to None. + disabled: Whether the static is disabled or not. """ DEFAULT_CSS = """ From 7ba64c71999c148bc1a5ea1c508199b96781ad7c Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 16 Feb 2023 11:40:52 +0000 Subject: [PATCH 054/113] Fix docstring typo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- tests/test_disabled.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_disabled.py b/tests/test_disabled.py index 8ad3d0e09..850fcf7c7 100644 --- a/tests/test_disabled.py +++ b/tests/test_disabled.py @@ -17,7 +17,7 @@ from textual.widgets import ( class DisableApp(App[None]): - """Application for testing Widget.disable.""" + """Application for testing Widget.disabled.""" def compose(self) -> ComposeResult: """Compose the child widgets.""" From 53fa0920afe8a262ee2d0af7d53c658446847322 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 16 Feb 2023 13:13:43 +0000 Subject: [PATCH 055/113] Further simplify the en/disabled pseudo-class calculation --- src/textual/widget.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index 3a6af140f..a16a6bf60 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1180,8 +1180,6 @@ class Widget(DOMNode): @property def _self_or_ancestors_disabled(self) -> bool: """Is this widget or any of its ancestors disabled?""" - # NOTE: Please see the copy of this code in get_pseudo_classes. I - # you change this, change that too. return any( node.disabled for node in self.ancestors_with_self @@ -2100,16 +2098,14 @@ class Widget(DOMNode): Names of the pseudo classes. """ - # NOTE: The heart of this yield is a direct copy of - # _self_or_ancestors_disabled. Because this method is called so - # much, here we save one function call as a very small but long-term - # useful optimisation. If _self_or_ancestors_disabled ever changes, - # be sure to reflect that change here! - yield "disabled" if any( - node.disabled - for node in self.ancestors_with_self - if isinstance(node, Widget) - ) else "enabled" + node = self + while isinstance(node, Widget): + if node.disabled: + yield "disabled" + break + node = node._parent + else: + yield "enabled" if self.mouse_over: yield "hover" if self.has_focus: From 35c07fc668635bc50c935cf0aa0ee72d064faa79 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 16 Feb 2023 13:47:03 +0000 Subject: [PATCH 056/113] spatial map --- src/textual/_compositor.py | 42 +++++++++++++++++++++++--------------- src/textual/_layout.py | 37 +++++++++++++++++++++++++++------ src/textual/widget.py | 31 ++++++++++++++-------------- 3 files changed, 73 insertions(+), 37 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index a81f0b99f..38db0f49c 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -391,9 +391,16 @@ class Compositor: if widget.is_container: # Arrange the layout arrange_result = widget._arrange(child_region.size) - placements, arranged_widgets, spacing = arrange_result.unpack() + arranged_widgets = arrange_result.widgets + spacing = arrange_result.spacing widgets.update(arranged_widgets) + placements, visible_placements = arrange_result.get_placements( + widget.size.region + widget.scroll_offset + ) + print("len(placements)", len(placements)) + total_region = total_region.union(arrange_result.total_region) + with timer("placements"): # An offset added to all placements placement_offset = container_region.offset @@ -409,9 +416,10 @@ class Compositor: get_layer_index = layers_to_index.get # Add all the widgets - for sub_region, margin, sub_widget, z, fixed in reversed( - placements - ): + for placement in reversed(placements): + sub_region, margin, sub_widget, z, fixed = placement + if placement not in visible_placements: + continue # Combine regions with children to calculate the "virtual size" if fixed: widget_region = sub_region + placement_offset @@ -435,23 +443,25 @@ class Compositor: widget_order, layer_order, sub_clip, - visible, + visible and placement in visible_placements, ) + layer_order -= 1 if visible: # Add any scrollbars - for chrome_widget, chrome_region in widget._arrange_scrollbars( - container_region - ): - map[chrome_widget] = _MapGeometry( - chrome_region + layout_offset, - order, - clip, - container_size, - container_size, - chrome_region, - ) + if any(widget.scrollbars_enabled): + for chrome_widget, chrome_region in widget._arrange_scrollbars( + container_region + ): + map[chrome_widget] = _MapGeometry( + chrome_region + layout_offset, + order, + clip, + container_size, + container_size, + chrome_region, + ) map[widget] = _MapGeometry( region + layout_offset, diff --git a/src/textual/_layout.py b/src/textual/_layout.py index f1e698d4d..2d3ab6b9a 100644 --- a/src/textual/_layout.py +++ b/src/textual/_layout.py @@ -4,9 +4,9 @@ from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import TYPE_CHECKING, ClassVar, NamedTuple -from ._cache import FIFOCache from ._profile import timer -from .geometry import Offset, Region, Size, Spacing +from ._spatial_map import SpatialMap +from .geometry import Region, Size, Spacing if TYPE_CHECKING: from typing_extensions import TypeAlias @@ -23,8 +23,33 @@ class DockArrangeResult: widgets: set[Widget] spacing: Spacing - def unpack(self) -> tuple[list[WidgetPlacement], set[Widget], Spacing]: - return (self.placements, self.widgets, self.spacing) + _spatial_map: SpatialMap[WidgetPlacement] | None = None + + @property + def spatial_map(self) -> SpatialMap[WidgetPlacement]: + if self._spatial_map is None: + self._spatial_map = SpatialMap() + with timer("insert many"): + self._spatial_map.insert_many( + ( + placement.region.grow(placement.margin), + placement.fixed, + placement, + ) + for placement in self.placements + ) + + return self._spatial_map + + @property + def total_region(self) -> Region: + return self.spatial_map.total_region + + def get_placements( + self, region: Region + ) -> tuple[list[WidgetPlacement], set[WidgetPlacement]]: + visible_placements = self.spatial_map.get_values_in_region(region) + return self.placements, visible_placements class WidgetPlacement(NamedTuple): @@ -74,7 +99,7 @@ class Layout(ABC): width = 0 else: # Use a size of 0, 0 to ignore relative sizes, since those are flexible anyway - placements, _, _ = widget._arrange(Size(0, 0)) + placements = widget._arrange(Size(0, 0)).placements width = max( [ placement.region.right + placement.margin.right @@ -102,7 +127,7 @@ class Layout(ABC): height = 0 else: # Use a height of zero to ignore relative heights - placements, _, _ = widget._arrange(Size(width, 0)) + placements = widget._arrange(Size(width, 0)).placements height = max( [ placement.region.bottom + placement.margin.bottom diff --git a/src/textual/widget.py b/src/textual/widget.py index a866c5c02..22165bd01 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -38,6 +38,7 @@ from . import errors, events, messages from ._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction from ._arrange import DockArrangeResult, arrange from ._asyncio import create_task +from ._cache import FIFOCache from ._context import active_app from ._easing import DEFAULT_SCROLL_EASING from ._layout import Layout @@ -260,8 +261,9 @@ class Widget(DOMNode): self._content_width_cache: tuple[object, int] = (None, 0) self._content_height_cache: tuple[object, int] = (None, 0) - self._arrangement_cache_key: tuple[Size, int] = (Size(), -1) - self._cached_arrangement: DockArrangeResult | None = None + self._arrangement_cache: FIFOCache[ + tuple[Size, int], DockArrangeResult + ] = FIFOCache(4) self._styles_cache = StylesCache() self._rich_style_cache: dict[str, tuple[Style, Style]] = {} @@ -279,7 +281,7 @@ class Widget(DOMNode): self._add_children(*children) - virtual_size = Reactive(Size(0, 0), layout=True) + virtual_size = Reactive(Size(0, 0), repaint=False, layout=True) auto_width = Reactive(True) auto_height = Reactive(True) has_focus = Reactive(False) @@ -474,22 +476,18 @@ class Widget(DOMNode): assert self.is_container cache_key = (size, self._nodes._updates) - if ( - self._arrangement_cache_key == cache_key - and self._cached_arrangement is not None - ): - return self._cached_arrangement + cached_result = self._arrangement_cache.get(cache_key) + if cached_result is not None: + return cached_result - self._arrangement_cache_key = cache_key - arrangement = self._cached_arrangement = arrange( - self, self._nodes, size, self.screen.size - ) + arrangement = arrange(self, self._nodes, size, self.screen.size) + self._arrangement_cache[cache_key] = arrangement return arrangement def _clear_arrangement_cache(self) -> None: """Clear arrangement cache, forcing a new arrange operation.""" - self._cached_arrangement = None + self._arrangement_cache.clear() def _get_virtual_dom(self) -> Iterable[Widget]: """Get widgets not part of the DOM. @@ -2149,10 +2147,11 @@ class Widget(DOMNode): or self.virtual_size != virtual_size or self._container_size != container_size ): + old_virtual_size = self.virtual_size self._size = size - self.virtual_size = virtual_size + self._reactive_virtual_size = virtual_size self._container_size = container_size - if self.is_scrollable: + if self.is_scrollable and old_virtual_size != self.virtual_size: self._scroll_update(virtual_size) self.refresh() @@ -2295,6 +2294,8 @@ class Widget(DOMNode): if layout: self._layout_required = True + + print("LAYOUT") for ancestor in self.ancestors: if not isinstance(ancestor, Widget): break From 5d88807131897b1ff38a585c543417638406bf7c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 16 Feb 2023 17:24:36 +0000 Subject: [PATCH 057/113] no hide --- src/textual/_compositor.py | 122 +++++++++++++++++++++---------------- src/textual/_layout.py | 35 +++++++---- src/textual/app.py | 1 - src/textual/screen.py | 19 +++--- src/textual/widget.py | 4 +- 5 files changed, 106 insertions(+), 75 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 38db0f49c..c183676e5 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -168,6 +168,7 @@ class Compositor: def __init__(self) -> None: # A mapping of Widget on to its "render location" (absolute position / depth) self.map: CompositorMap = {} + self._full_map: CompositorMap | None = None self._layers: list[tuple[Widget, MapGeometry]] | None = None # All widgets considered in the arrangement @@ -248,6 +249,7 @@ class Compositor: self._layers = None self._layers_visible = None self._visible_widgets = None + self._full_map = None self.root = parent self.size = size @@ -255,7 +257,8 @@ class Compositor: old_map = self.map.copy() old_widgets = old_map.keys() - map, widgets = self._arrange_root(parent, size) + map, widgets = self._arrange_root(parent, size, visible_only=True) + new_widgets = map.keys() # Newly visible widgets @@ -293,11 +296,21 @@ class Compositor: self._dirty_regions.update(regions) return ReflowResult( - hidden=hidden_widgets, + hidden=set(), shown=shown_widgets, resized=resized_widgets, ) + @property + def full_map(self) -> CompositorMap: + if self.root is None or not self.map: + return {} + if self._full_map is None: + map, widgets = self._arrange_root(self.root, self.size, visible_only=False) + self._full_map = map + + return self._full_map + @property def visible_widgets(self) -> dict[Widget, tuple[Region, Region]]: """Get a mapping of widgets on to region and clip. @@ -323,7 +336,7 @@ class Compositor: return self._visible_widgets def _arrange_root( - self, root: Widget, size: Size + self, root: Widget, size: Size, visible_only: bool = True ) -> tuple[CompositorMap, set[Widget]]: """Arrange a widgets children based on its layout attribute. @@ -395,58 +408,55 @@ class Compositor: spacing = arrange_result.spacing widgets.update(arranged_widgets) - placements, visible_placements = arrange_result.get_placements( - widget.size.region + widget.scroll_offset - ) - print("len(placements)", len(placements)) + if visible_only: + placements = arrange_result.get_visible_placements( + widget.size.region + widget.scroll_offset + ) + else: + placements = arrange_result.placements total_region = total_region.union(arrange_result.total_region) - with timer("placements"): - # An offset added to all placements - placement_offset = container_region.offset - placement_scroll_offset = ( - placement_offset - widget.scroll_offset + # An offset added to all placements + placement_offset = container_region.offset + placement_scroll_offset = placement_offset - widget.scroll_offset + + _layers = widget.layers + layers_to_index = { + layer_name: index for index, layer_name in enumerate(_layers) + } + get_layer_index = layers_to_index.get + + # Add all the widgets + for sub_region, margin, sub_widget, z, fixed in reversed( + placements + ): + # Combine regions with children to calculate the "virtual size" + if fixed: + widget_region = sub_region + placement_offset + else: + total_region = total_region.union( + sub_region.grow(spacing + margin) + ) + widget_region = sub_region + placement_scroll_offset + + widget_order = ( + *order, + get_layer_index(sub_widget.layer, 0), + z, + layer_order, ) - _layers = widget.layers - layers_to_index = { - layer_name: index - for index, layer_name in enumerate(_layers) - } - get_layer_index = layers_to_index.get + add_widget( + sub_widget, + sub_region, + widget_region, + widget_order, + layer_order, + sub_clip, + visible, + ) - # Add all the widgets - for placement in reversed(placements): - sub_region, margin, sub_widget, z, fixed = placement - if placement not in visible_placements: - continue - # Combine regions with children to calculate the "virtual size" - if fixed: - widget_region = sub_region + placement_offset - else: - total_region = total_region.union( - sub_region.grow(spacing + margin) - ) - widget_region = sub_region + placement_scroll_offset - - widget_order = ( - *order, - get_layer_index(sub_widget.layer, 0), - z, - layer_order, - ) - - add_widget( - sub_widget, - sub_region, - widget_region, - widget_order, - layer_order, - sub_clip, - visible and placement in visible_placements, - ) - - layer_order -= 1 + layer_order -= 1 if visible: # Add any scrollbars @@ -529,7 +539,10 @@ class Compositor: try: return self.map[widget].region.offset except KeyError: - raise errors.NoWidget("Widget is not in layout") + try: + return self.full_map[widget].region.offset + except KeyError: + raise errors.NoWidget("Widget is not in layout") def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]: """Get the widget under a given coordinate. @@ -611,10 +624,15 @@ class Compositor: Widget's composition information. """ + if self.root is None or not self.map: + raise errors.NoWidget("Widget is not in layout") try: region = self.map[widget] except KeyError: - raise errors.NoWidget("Widget is not in layout") + try: + return self.full_map[widget] + except KeyError: + raise errors.NoWidget("Widget is not in layout") else: return region diff --git a/src/textual/_layout.py b/src/textual/_layout.py index 2d3ab6b9a..eb3b1967f 100644 --- a/src/textual/_layout.py +++ b/src/textual/_layout.py @@ -27,29 +27,40 @@ class DockArrangeResult: @property def spatial_map(self) -> SpatialMap[WidgetPlacement]: + """A lazy-calculated spatial map.""" if self._spatial_map is None: self._spatial_map = SpatialMap() - with timer("insert many"): - self._spatial_map.insert_many( - ( - placement.region.grow(placement.margin), - placement.fixed, - placement, - ) - for placement in self.placements + self._spatial_map.insert_many( + ( + placement.region.grow(placement.margin), + placement.fixed, + placement, ) + for placement in self.placements + ) return self._spatial_map @property def total_region(self) -> Region: + """The total area occupied by the arrangement. + + Returns: + A Region. + """ return self.spatial_map.total_region - def get_placements( - self, region: Region - ) -> tuple[list[WidgetPlacement], set[WidgetPlacement]]: + def get_visible_placements(self, region: Region) -> list[WidgetPlacement]: + """Get the placements visible within the given region. + + Args: + region: A region. + + Returns: + Set of placements. + """ visible_placements = self.spatial_map.get_values_in_region(region) - return self.placements, visible_placements + return visible_placements class WidgetPlacement(NamedTuple): diff --git a/src/textual/app.py b/src/textual/app.py index bf33e07cd..33b887e09 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1903,7 +1903,6 @@ class App(Generic[ReturnType], 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.Compose): - self.log(event) screen = Screen(id="_default") self._register(self, screen) self._screen_stack.append(screen) diff --git a/src/textual/screen.py b/src/textual/screen.py index d1354a106..c768aab63 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -80,15 +80,15 @@ class Screen(Widget): ) return self._update_timer - @property - def widgets(self) -> list[Widget]: - """Get all widgets.""" - return list(self._compositor.map.keys()) + # @property + # def widgets(self) -> list[Widget]: + # """Get all widgets.""" + # return list(self._compositor.map.keys()) - @property - def visible_widgets(self) -> list[Widget]: - """Get a list of visible widgets.""" - return list(self._compositor.visible_widgets) + # @property + # def visible_widgets(self) -> list[Widget]: + # """Get a list of visible widgets.""" + # return list(self._compositor.visible_widgets) def render(self) -> RenderableType: background = self.styles.background @@ -363,6 +363,9 @@ class Screen(Widget): self.screen.scroll_to_widget(widget) widget.post_message_no_wait(events.Focus(self)) self.log.debug(widget, "was focused") + import traceback + + traceback.print_stack() async def _on_idle(self, event: events.Idle) -> None: # Check for any widgets marked as 'dirty' (needs a repaint) diff --git a/src/textual/widget.py b/src/textual/widget.py index 22165bd01..bbf134cec 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -480,7 +480,7 @@ class Widget(DOMNode): if cached_result is not None: return cached_result - arrangement = arrange(self, self._nodes, size, self.screen.size) + arrangement = arrange(self, self._nodes, size, size) self._arrangement_cache[cache_key] = arrangement return arrangement @@ -1741,7 +1741,7 @@ class Widget(DOMNode): """ return self.scroll_to( - y=self.scroll_target_y + self.container_size.height, + y=self.scroll_y + self.container_size.height, animate=animate, speed=speed, duration=duration, From 50c8d07beaa2ddfcd09d174bda8008dda9dac6cc Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 16 Feb 2023 18:00:15 +0000 Subject: [PATCH 058/113] restore hidden --- src/textual/_compositor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index c183676e5..f6df38018 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -254,7 +254,7 @@ class Compositor: self.size = size # Keep a copy of the old map because we're going to compare it with the update - old_map = self.map.copy() + old_map = self.map old_widgets = old_map.keys() map, widgets = self._arrange_root(parent, size, visible_only=True) @@ -264,7 +264,7 @@ class Compositor: # Newly visible widgets shown_widgets = new_widgets - old_widgets # Newly hidden widgets - hidden_widgets = old_widgets - new_widgets + hidden_widgets = self.widgets - widgets # Replace map and widgets self.map = map @@ -296,7 +296,7 @@ class Compositor: self._dirty_regions.update(regions) return ReflowResult( - hidden=set(), + hidden=hidden_widgets, shown=shown_widgets, resized=resized_widgets, ) From ba49907f1014a974af95e3e29bbdc0f4912d9293 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 16 Feb 2023 18:01:31 +0000 Subject: [PATCH 059/113] Rename insert many to insert --- src/textual/_layout.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/_layout.py b/src/textual/_layout.py index eb3b1967f..fe2086008 100644 --- a/src/textual/_layout.py +++ b/src/textual/_layout.py @@ -30,7 +30,7 @@ class DockArrangeResult: """A lazy-calculated spatial map.""" if self._spatial_map is None: self._spatial_map = SpatialMap() - self._spatial_map.insert_many( + self._spatial_map.insert( ( placement.region.grow(placement.margin), placement.fixed, From 06fa8d7e8e1d424b7a5fbecb370362847ec236d3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 09:35:39 +0000 Subject: [PATCH 060/113] layout cache --- src/textual/widget.py | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index bbf134cec..0842aff93 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -38,11 +38,9 @@ from . import errors, events, messages from ._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction from ._arrange import DockArrangeResult, arrange from ._asyncio import create_task -from ._cache import FIFOCache from ._context import active_app from ._easing import DEFAULT_SCROLL_EASING from ._layout import Layout -from ._profile import timer from ._segment_tools import align_lines from ._styles_cache import StylesCache from .actions import SkipAction @@ -261,9 +259,8 @@ class Widget(DOMNode): self._content_width_cache: tuple[object, int] = (None, 0) self._content_height_cache: tuple[object, int] = (None, 0) - self._arrangement_cache: FIFOCache[ - tuple[Size, int], DockArrangeResult - ] = FIFOCache(4) + self._arrangement_cache_key: tuple[Size, int] = (Size(), -1) + self._cached_arrangement: DockArrangeResult | None = None self._styles_cache = StylesCache() self._rich_style_cache: dict[str, tuple[Style, Style]] = {} @@ -281,7 +278,7 @@ class Widget(DOMNode): self._add_children(*children) - virtual_size = Reactive(Size(0, 0), repaint=False, layout=True) + virtual_size = Reactive(Size(0, 0), layout=True) auto_width = Reactive(True) auto_height = Reactive(True) has_focus = Reactive(False) @@ -476,18 +473,22 @@ class Widget(DOMNode): assert self.is_container cache_key = (size, self._nodes._updates) - cached_result = self._arrangement_cache.get(cache_key) - if cached_result is not None: - return cached_result + if ( + self._arrangement_cache_key == cache_key + and self._cached_arrangement is not None + ): + return self._cached_arrangement - arrangement = arrange(self, self._nodes, size, size) - self._arrangement_cache[cache_key] = arrangement + self._arrangement_cache_key = cache_key + arrangement = self._cached_arrangement = arrange( + self, self._nodes, size, self.screen.size + ) return arrangement def _clear_arrangement_cache(self) -> None: """Clear arrangement cache, forcing a new arrange operation.""" - self._arrangement_cache.clear() + self._cached_arrangement = None def _get_virtual_dom(self) -> Iterable[Widget]: """Get widgets not part of the DOM. @@ -1741,7 +1742,7 @@ class Widget(DOMNode): """ return self.scroll_to( - y=self.scroll_y + self.container_size.height, + y=self.scroll_target_y + self.container_size.height, animate=animate, speed=speed, duration=duration, @@ -2147,11 +2148,10 @@ class Widget(DOMNode): or self.virtual_size != virtual_size or self._container_size != container_size ): - old_virtual_size = self.virtual_size self._size = size - self._reactive_virtual_size = virtual_size + self.virtual_size = virtual_size self._container_size = container_size - if self.is_scrollable and old_virtual_size != self.virtual_size: + if self.is_scrollable: self._scroll_update(virtual_size) self.refresh() @@ -2294,8 +2294,6 @@ class Widget(DOMNode): if layout: self._layout_required = True - - print("LAYOUT") for ancestor in self.ancestors: if not isinstance(ancestor, Widget): break From 11d10db1ab9cc723a5f12bf333d22f9b00d5e9a3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 10:42:42 +0000 Subject: [PATCH 061/113] fast path for scrolling --- CHANGELOG.md | 10 +++ docs/examples/styles/height_comparison.css | 2 +- src/textual/_compositor.py | 68 +++++++++++++---- src/textual/_spatial_map.py | 87 ++++++++++++++++++++++ src/textual/messages.py | 6 ++ src/textual/screen.py | 76 ++++++++++--------- src/textual/widget.py | 14 ++-- tests/test_arrange.py | 49 ++++++------ tests/test_spatial_map.py | 8 ++ tests/test_visibility_change.py | 3 - 10 files changed, 237 insertions(+), 86 deletions(-) create mode 100644 src/textual/_spatial_map.py create mode 100644 tests/test_spatial_map.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b83ccf89..3513cba79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [0.12.0] - Unreleased + +### Changed + +- Scrolling by page now adds to current position. + +### Removed + +- Removed `screen.visible_widgets` and `screen.widgets` + ## [0.11.0] - 2023-02-15 ### Added diff --git a/docs/examples/styles/height_comparison.css b/docs/examples/styles/height_comparison.css index 10902dda5..d5da04f78 100644 --- a/docs/examples/styles/height_comparison.css +++ b/docs/examples/styles/height_comparison.css @@ -28,12 +28,12 @@ Screen { layers: ruler; + overflow: hidden; } Ruler { layer: ruler; dock: right; - overflow: hidden; width: 1; background: $accent; } diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index f6df38018..5502675bf 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -243,7 +243,7 @@ class Compositor: size: Size of the area to be filled. Returns: - Hidden shown and resized widgets. + Hidden, shown and resized widgets. """ self._cuts = None self._layers = None @@ -257,15 +257,10 @@ class Compositor: old_map = self.map old_widgets = old_map.keys() - map, widgets = self._arrange_root(parent, size, visible_only=True) + map, widgets = self._arrange_root(parent, size) new_widgets = map.keys() - # Newly visible widgets - shown_widgets = new_widgets - old_widgets - # Newly hidden widgets - hidden_widgets = self.widgets - widgets - # Replace map and widgets self.map = map self.widgets = widgets @@ -276,13 +271,7 @@ class Compositor: # Widgets in both new and old common_widgets = old_widgets & new_widgets - # Widgets with changed size - resized_widgets = { - widget - for widget, (region, *_) in changes - if (widget in common_widgets and old_map[widget].region[2:] != region[2:]) - } - + # Mark dirty regions. screen_region = size.region if screen_region not in self._dirty_regions: regions = { @@ -295,12 +284,63 @@ class Compositor: } self._dirty_regions.update(regions) + resized_widgets = { + widget + for widget, (region, *_) in changes + if (widget in common_widgets and old_map[widget].region[2:] != region[2:]) + } + # Newly visible widgets + shown_widgets = new_widgets - old_widgets + # Newly hidden widgets + hidden_widgets = self.widgets - widgets return ReflowResult( hidden=hidden_widgets, shown=shown_widgets, resized=resized_widgets, ) + def reflow_visible(self, parent: Widget, size: Size) -> None: + """Reflow only the visible children. + + This is a fast-path for scrolling. + + Args: + parent: The root widget. + size: Size of the area to be filled. + + """ + self._cuts = None + self._layers = None + self._layers_visible = None + self._visible_widgets = None + self._full_map = None + self.root = parent + self.size = size + + # Keep a copy of the old map because we're going to compare it with the update + old_map = self.map + map, widgets = self._arrange_root(parent, size, visible_only=True) + + # Replace map and widgets + self.map = map + self.widgets = widgets + + # Contains widgets + geometry for every widget that changed (added, removed, or updated) + changes = map.items() ^ old_map.items() + + # Mark dirty regions. + screen_region = size.region + if screen_region not in self._dirty_regions: + regions = { + region + for region in ( + map_geometry.clip.intersection(map_geometry.region) + for _, map_geometry in changes + ) + if region + } + self._dirty_regions.update(regions) + @property def full_map(self) -> CompositorMap: if self.root is None or not self.map: diff --git a/src/textual/_spatial_map.py b/src/textual/_spatial_map.py new file mode 100644 index 000000000..480e665ad --- /dev/null +++ b/src/textual/_spatial_map.py @@ -0,0 +1,87 @@ +from collections import defaultdict +from itertools import product +from typing import Generic, Iterable, TypeVar + +from .geometry import Region + +ValueType = TypeVar("ValueType") + + +class SpatialMap(Generic[ValueType]): + """A spatial map allows for data to be associated with a rectangular regions + in Euclidean space, and efficiently queried. + + """ + + def __init__(self, grid_width: int = 100, grid_height: int = 20) -> None: + """Create a spatial map with the given grid size. + + Args: + grid_width: Width of a grid square. + grid_height: Height of a grid square. + """ + self._grid_size = (grid_width, grid_height) + self.total_region = Region() + self._map: defaultdict[tuple[int, int], list[ValueType]] = defaultdict(list) + self._fixed: list[ValueType] = [] + + def _region_to_grid(self, region: Region) -> Iterable[tuple[int, int]]: + """Get the grid squares under a region. + + Args: + region: A region. + + Returns: + Iterable of grid squares (tuple of 2 values). + """ + x1, y1, width, height = region + x2 = x1 + width + y2 = y1 + height + grid_width, grid_height = self._grid_size + + return product( + range(x1 // grid_width, 1 + x2 // grid_width), + range(y1 // grid_height, 1 + y2 // grid_height), + ) + + def insert( + self, regions_and_values: Iterable[tuple[Region, bool, ValueType]] + ) -> None: + """Insert values in to the Spatial map. + + Args: + regions_and_values: An iterable of Regions and values. + """ + append_fixed = self._fixed.append + get_grid_list = self._map.__getitem__ + _region_to_grid = self._region_to_grid + total_region = self.total_region + for region, fixed, value in regions_and_values: + total_region = total_region.union(region) + if fixed: + append_fixed(value) + else: + for grid in _region_to_grid(region): + get_grid_list(grid).append(value) + self.total_region = total_region + + def get_values_in_region(self, region: Region) -> list[ValueType]: + """Get a set of values that are under a given region. + + Note that this may return some false positives. + + Args: + region: A region. + + Returns: + A set of values under the region. + """ + results: list[ValueType] = self._fixed.copy() + add_results = results.extend + get_grid_values = self._map.get + for grid in self._region_to_grid(region): + grid_values = get_grid_values(grid) + if grid_values is not None: + add_results(grid_values) + unique_values = list(dict.fromkeys(results)) + return unique_values diff --git a/src/textual/messages.py b/src/textual/messages.py index fcfe2ad2c..4e14c8902 100644 --- a/src/textual/messages.py +++ b/src/textual/messages.py @@ -49,6 +49,12 @@ class Layout(Message, verbose=True): return isinstance(message, Layout) +@rich.repr.auto +class UpdateScroll(Message, verbose=True): + def can_replace(self, message: Message) -> bool: + return isinstance(message, UpdateScroll) + + @rich.repr.auto class InvokeLater(Message, verbose=True, bubble=False): def __init__(self, sender: MessagePump, callback: CallbackType) -> None: diff --git a/src/textual/screen.py b/src/textual/screen.py index c768aab63..59e622caf 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -80,16 +80,6 @@ class Screen(Widget): ) return self._update_timer - # @property - # def widgets(self) -> list[Widget]: - # """Get all widgets.""" - # return list(self._compositor.map.keys()) - - # @property - # def visible_widgets(self) -> list[Widget]: - # """Get a list of visible widgets.""" - # return list(self._compositor.visible_widgets) - def render(self) -> RenderableType: background = self.styles.background if background.is_transparent: @@ -377,7 +367,12 @@ class Screen(Widget): if self._layout_required: self._refresh_layout() self._layout_required = False + self._scroll_required = False self._dirty_widgets.clear() + elif self._scroll_required: + self._refresh_layout(scroll=True) + self._scroll_required = False + if self._repaint_required: self._dirty_widgets.clear() self._dirty_widgets.add(self) @@ -426,7 +421,9 @@ class Screen(Widget): self._callbacks.append(callback) self.check_idle() - def _refresh_layout(self, size: Size | None = None, full: bool = False) -> None: + def _refresh_layout( + self, size: Size | None = None, full: bool = False, scroll: bool = False + ) -> None: """Refresh the layout (can change size and positions of widgets).""" size = self.outer_size if size is None else size if not size: @@ -435,34 +432,37 @@ class Screen(Widget): self._compositor.update_widgets(self._dirty_widgets) self.update_timer.pause() try: - hidden, shown, resized = self._compositor.reflow(self, size) - Hide = events.Hide - Show = events.Show + if scroll: + self._compositor.reflow_visible(self, size) + else: + hidden, shown, resized = self._compositor.reflow(self, size) + Hide = events.Hide + Show = events.Show - for widget in hidden: - widget.post_message_no_wait(Hide(self)) + for widget in hidden: + widget.post_message_no_wait(Hide(self)) - # We want to send a resize event to widgets that were just added or change since last layout - send_resize = shown | resized - ResizeEvent = events.Resize + # We want to send a resize event to widgets that were just added or change since last layout + send_resize = shown | resized + ResizeEvent = events.Resize - layers = self._compositor.layers - for widget, ( - region, - _order, - _clip, - virtual_size, - container_size, - _, - ) in layers: - widget._size_updated(region.size, virtual_size, container_size) - if widget in send_resize: - widget.post_message_no_wait( - ResizeEvent(self, region.size, virtual_size, container_size) - ) + layers = self._compositor.layers + for widget, ( + region, + _order, + _clip, + virtual_size, + container_size, + _, + ) in layers: + widget._size_updated(region.size, virtual_size, container_size) + if widget in send_resize: + widget.post_message_no_wait( + ResizeEvent(self, region.size, virtual_size, container_size) + ) - for widget in shown: - widget.post_message_no_wait(Show(self)) + for widget in shown: + widget.post_message_no_wait(Show(self)) except Exception as error: self.app._handle_exception(error) @@ -487,6 +487,12 @@ class Screen(Widget): self._layout_required = True self.check_idle() + async def _on_update_scroll(self, message: messages.UpdateScroll) -> None: + message.stop() + message.prevent_default() + self._scroll_required = True + self.check_idle() + def _screen_resized(self, size: Size): """Called by App when the screen is resized.""" self._refresh_layout(size, full=True) diff --git a/src/textual/widget.py b/src/textual/widget.py index 0842aff93..165cd4ca4 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -240,6 +240,7 @@ class Widget(DOMNode): self._container_size = Size(0, 0) self._layout_required = False self._repaint_required = False + self._scroll_required = False self._default_layout = VerticalLayout() self._animate: BoundAnimator | None = None self.highlight_style: Style | None = None @@ -1710,7 +1711,7 @@ class Widget(DOMNode): """ return self.scroll_to( - y=self.scroll_target_y - self.container_size.height, + y=self.scroll_y - self.container_size.height, animate=animate, speed=speed, duration=duration, @@ -1742,7 +1743,7 @@ class Widget(DOMNode): """ return self.scroll_to( - y=self.scroll_target_y + self.container_size.height, + y=self.scroll_y + self.container_size.height, animate=animate, speed=speed, duration=duration, @@ -1776,7 +1777,7 @@ class Widget(DOMNode): if speed is None and duration is None: duration = 0.3 return self.scroll_to( - x=self.scroll_target_x - self.container_size.width, + x=self.scroll_x - self.container_size.width, animate=animate, speed=speed, duration=duration, @@ -1810,7 +1811,7 @@ class Widget(DOMNode): if speed is None and duration is None: duration = 0.3 return self.scroll_to( - x=self.scroll_target_x + self.container_size.width, + x=self.scroll_x + self.container_size.width, animate=animate, speed=speed, duration=duration, @@ -2264,7 +2265,7 @@ class Widget(DOMNode): def _refresh_scroll(self) -> None: """Refreshes the scroll position.""" - self._layout_required = True + self._scroll_required = True self.check_idle() def refresh( @@ -2373,6 +2374,9 @@ class Widget(DOMNode): except NoScreen: pass else: + if self._scroll_required: + self._scroll_required = False + screen.post_message_no_wait(messages.UpdateScroll(self)) if self._repaint_required: self._repaint_required = False screen.post_message_no_wait(messages.Update(self, self)) diff --git a/tests/test_arrange.py b/tests/test_arrange.py index 31e030b1b..582f54bc1 100644 --- a/tests/test_arrange.py +++ b/tests/test_arrange.py @@ -9,10 +9,10 @@ from textual.widget import Widget def test_arrange_empty(): container = Widget(id="container") - placements, widgets, spacing = arrange(container, [], Size(80, 24), Size(80, 24)) - assert placements == [] - assert widgets == set() - assert spacing == Spacing(0, 0, 0, 0) + result = arrange(container, [], Size(80, 24), Size(80, 24)) + assert result.placements == [] + assert result.widgets == set() + assert result.spacing == Spacing(0, 0, 0, 0) def test_arrange_dock_top(): @@ -22,17 +22,16 @@ def test_arrange_dock_top(): header.styles.dock = "top" header.styles.height = "1" - placements, widgets, spacing = arrange( - container, [child, header], Size(80, 24), Size(80, 24) - ) - assert placements == [ + result = arrange(container, [child, header], Size(80, 24), Size(80, 24)) + + assert result.placements == [ WidgetPlacement( Region(0, 0, 80, 1), Spacing(), header, order=TOP_Z, fixed=True ), WidgetPlacement(Region(0, 1, 80, 23), Spacing(), child, order=0, fixed=False), ] - assert widgets == {child, header} - assert spacing == Spacing(1, 0, 0, 0) + assert result.widgets == {child, header} + assert result.spacing == Spacing(1, 0, 0, 0) def test_arrange_dock_left(): @@ -42,17 +41,15 @@ def test_arrange_dock_left(): header.styles.dock = "left" header.styles.width = "10" - placements, widgets, spacing = arrange( - container, [child, header], Size(80, 24), Size(80, 24) - ) - assert placements == [ + result = arrange(container, [child, header], Size(80, 24), Size(80, 24)) + assert result.placements == [ WidgetPlacement( Region(0, 0, 10, 24), Spacing(), header, order=TOP_Z, fixed=True ), WidgetPlacement(Region(10, 0, 70, 24), Spacing(), child, order=0, fixed=False), ] - assert widgets == {child, header} - assert spacing == Spacing(0, 0, 0, 10) + assert result.widgets == {child, header} + assert result.spacing == Spacing(0, 0, 0, 10) def test_arrange_dock_right(): @@ -62,17 +59,15 @@ def test_arrange_dock_right(): header.styles.dock = "right" header.styles.width = "10" - placements, widgets, spacing = arrange( - container, [child, header], Size(80, 24), Size(80, 24) - ) - assert placements == [ + result = arrange(container, [child, header], Size(80, 24), Size(80, 24)) + assert result.placements == [ WidgetPlacement( Region(70, 0, 10, 24), Spacing(), header, order=TOP_Z, fixed=True ), WidgetPlacement(Region(0, 0, 70, 24), Spacing(), child, order=0, fixed=False), ] - assert widgets == {child, header} - assert spacing == Spacing(0, 10, 0, 0) + assert result.widgets == {child, header} + assert result.spacing == Spacing(0, 10, 0, 0) def test_arrange_dock_bottom(): @@ -82,17 +77,15 @@ def test_arrange_dock_bottom(): header.styles.dock = "bottom" header.styles.height = "1" - placements, widgets, spacing = arrange( - container, [child, header], Size(80, 24), Size(80, 24) - ) - assert placements == [ + result = arrange(container, [child, header], Size(80, 24), Size(80, 24)) + assert result.placements == [ WidgetPlacement( Region(0, 23, 80, 1), Spacing(), header, order=TOP_Z, fixed=True ), WidgetPlacement(Region(0, 0, 80, 23), Spacing(), child, order=0, fixed=False), ] - assert widgets == {child, header} - assert spacing == Spacing(0, 0, 1, 0) + assert result.widgets == {child, header} + assert result.spacing == Spacing(0, 0, 1, 0) def test_arrange_dock_badly(): diff --git a/tests/test_spatial_map.py b/tests/test_spatial_map.py new file mode 100644 index 000000000..18fd16648 --- /dev/null +++ b/tests/test_spatial_map.py @@ -0,0 +1,8 @@ +from textual._spatial_map import SpatialMap +from textual.geometry import Region + + +def test_region_to_grid(): + spatial_map = SpatialMap() + + assert list(spatial_map._region_to_grid(Region(0, 0, 10, 10))) == [(0, 0)] diff --git a/tests/test_visibility_change.py b/tests/test_visibility_change.py index b06ea0e17..f79f18a6f 100644 --- a/tests/test_visibility_change.py +++ b/tests/test_visibility_change.py @@ -26,21 +26,18 @@ class VisibleTester(App[None]): async def test_visibility_changes() -> None: """Test changing visibility via code and CSS.""" async with VisibleTester().run_test() as pilot: - assert len(pilot.app.screen.visible_widgets) == 5 assert pilot.app.query_one("#keep").visible is True assert pilot.app.query_one("#hide-via-code").visible is True assert pilot.app.query_one("#hide-via-css").visible is True pilot.app.query_one("#hide-via-code").styles.visibility = "hidden" await pilot.pause(0) - assert len(pilot.app.screen.visible_widgets) == 4 assert pilot.app.query_one("#keep").visible is True assert pilot.app.query_one("#hide-via-code").visible is False assert pilot.app.query_one("#hide-via-css").visible is True pilot.app.query_one("#hide-via-css").set_class(True, "hidden") await pilot.pause(0) - assert len(pilot.app.screen.visible_widgets) == 3 assert pilot.app.query_one("#keep").visible is True assert pilot.app.query_one("#hide-via-code").visible is False assert pilot.app.query_one("#hide-via-css").visible is False From edf00a7d0b9349ecae074b1d82783490fd10d76f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 11:36:01 +0000 Subject: [PATCH 062/113] fix for spatial calculation --- src/textual/_compositor.py | 2 +- src/textual/_spatial_map.py | 8 ++++---- tests/test_spatial_map.py | 31 ++++++++++++++++++++++++++++--- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 5502675bf..6282402a8 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -450,7 +450,7 @@ class Compositor: if visible_only: placements = arrange_result.get_visible_placements( - widget.size.region + widget.scroll_offset + container_size.region + widget.scroll_offset ) else: placements = arrange_result.placements diff --git a/src/textual/_spatial_map.py b/src/textual/_spatial_map.py index 480e665ad..574a72bf2 100644 --- a/src/textual/_spatial_map.py +++ b/src/textual/_spatial_map.py @@ -35,13 +35,13 @@ class SpatialMap(Generic[ValueType]): Iterable of grid squares (tuple of 2 values). """ x1, y1, width, height = region - x2 = x1 + width - y2 = y1 + height + x2 = x1 + width - 1 + y2 = y1 + height - 1 grid_width, grid_height = self._grid_size return product( - range(x1 // grid_width, 1 + x2 // grid_width), - range(y1 // grid_height, 1 + y2 // grid_height), + range(x1 // grid_width, x2 // grid_width + 1), + range(y1 // grid_height, y2 // grid_height + 1), ) def insert( diff --git a/tests/test_spatial_map.py b/tests/test_spatial_map.py index 18fd16648..adbbb9330 100644 --- a/tests/test_spatial_map.py +++ b/tests/test_spatial_map.py @@ -1,8 +1,33 @@ +import pytest + from textual._spatial_map import SpatialMap from textual.geometry import Region -def test_region_to_grid(): - spatial_map = SpatialMap() +@pytest.mark.parametrize( + "region,grid", + [ + ( + Region(0, 0, 10, 10), + [ + (0, 0), + ], + ), + ( + Region(0, 0, 11, 11), + [(0, 0), (0, 1), (1, 0), (1, 1)], + ), + ( + Region(5, 5, 15, 3), + [(0, 0), (1, 0)], + ), + ( + Region(5, 5, 2, 15), + [(0, 0), (0, 1)], + ), + ], +) +def test_region_to_grid(region, grid): + spatial_map = SpatialMap(10, 10) - assert list(spatial_map._region_to_grid(Region(0, 0, 10, 10))) == [(0, 0)] + assert list(spatial_map._region_to_grid(region)) == grid From c65e52be53217986a3050b9c925c410e136c92b8 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 12:36:35 +0000 Subject: [PATCH 063/113] tests --- src/textual/_spatial_map.py | 2 +- tests/test_spatial_map.py | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/textual/_spatial_map.py b/src/textual/_spatial_map.py index 574a72bf2..bdfb94d57 100644 --- a/src/textual/_spatial_map.py +++ b/src/textual/_spatial_map.py @@ -68,7 +68,7 @@ class SpatialMap(Generic[ValueType]): def get_values_in_region(self, region: Region) -> list[ValueType]: """Get a set of values that are under a given region. - Note that this may return some false positives. + Note that this may return false positives. Args: region: A region. diff --git a/tests/test_spatial_map.py b/tests/test_spatial_map.py index adbbb9330..ddbf890fb 100644 --- a/tests/test_spatial_map.py +++ b/tests/test_spatial_map.py @@ -13,6 +13,12 @@ from textual.geometry import Region (0, 0), ], ), + ( + Region(10, 10, 10, 10), + [ + (1, 1), + ], + ), ( Region(0, 0, 11, 11), [(0, 0), (0, 1), (1, 0), (1, 1)], @@ -31,3 +37,20 @@ def test_region_to_grid(region, grid): spatial_map = SpatialMap(10, 10) assert list(spatial_map._region_to_grid(region)) == grid + + +def test_get_values_in_region() -> None: + spatial_map: SpatialMap[str] = SpatialMap(20, 10) + + spatial_map.insert( + [ + (Region(10, 5, 5, 5), False, "foo"), + (Region(5, 20, 5, 5), False, "bar"), + ] + ) + + assert spatial_map.get_values_in_region(Region(0, 0, 10, 5)) == ["foo"] + assert spatial_map.get_values_in_region(Region(0, 1, 10, 5)) == ["foo"] + assert spatial_map.get_values_in_region(Region(0, 10, 10, 5)) == [] + assert spatial_map.get_values_in_region(Region(0, 20, 10, 5)) == ["bar"] + assert spatial_map.get_values_in_region(Region(5, 5, 50, 50)) == ["foo", "bar"] From 29ce098fc8c95ee736b99339eb884d8149f122d4 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 12:47:26 +0000 Subject: [PATCH 064/113] docstrings on events --- src/textual/_compositor.py | 3 ++- src/textual/_layout.py | 1 - src/textual/messages.py | 6 ++++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 6282402a8..3f8b8347c 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -249,7 +249,6 @@ class Compositor: self._layers = None self._layers_visible = None self._visible_widgets = None - self._full_map = None self.root = parent self.size = size @@ -263,6 +262,7 @@ class Compositor: # Replace map and widgets self.map = map + self._full_map = map self.widgets = widgets # Contains widgets + geometry for every widget that changed (added, removed, or updated) @@ -343,6 +343,7 @@ class Compositor: @property def full_map(self) -> CompositorMap: + """Lazily built compositor map that covers all widgets.""" if self.root is None or not self.map: return {} if self._full_map is None: diff --git a/src/textual/_layout.py b/src/textual/_layout.py index fe2086008..12e97d7d9 100644 --- a/src/textual/_layout.py +++ b/src/textual/_layout.py @@ -14,7 +14,6 @@ if TYPE_CHECKING: from .widget import Widget ArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget]]" -# DockArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget], Spacing]" @dataclass diff --git a/src/textual/messages.py b/src/textual/messages.py index 4e14c8902..882fe887a 100644 --- a/src/textual/messages.py +++ b/src/textual/messages.py @@ -45,18 +45,24 @@ class Update(Message, verbose=True): @rich.repr.auto class Layout(Message, verbose=True): + """Sent by Textual when a layout is required.""" + def can_replace(self, message: Message) -> bool: return isinstance(message, Layout) @rich.repr.auto class UpdateScroll(Message, verbose=True): + """Sent by Textual when a scroll update is required.""" + def can_replace(self, message: Message) -> bool: return isinstance(message, UpdateScroll) @rich.repr.auto class InvokeLater(Message, verbose=True, bubble=False): + """Sent by Textual to invoke a callback.""" + def __init__(self, sender: MessagePump, callback: CallbackType) -> None: self.callback = callback super().__init__(sender) From 79aee7612ef6dab4d22b1c1efc0ec0d7b6f7ae1c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 12:47:58 +0000 Subject: [PATCH 065/113] remove debug --- src/textual/screen.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/textual/screen.py b/src/textual/screen.py index 59e622caf..c5cb91061 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -353,9 +353,6 @@ class Screen(Widget): self.screen.scroll_to_widget(widget) widget.post_message_no_wait(events.Focus(self)) self.log.debug(widget, "was focused") - import traceback - - traceback.print_stack() async def _on_idle(self, event: events.Idle) -> None: # Check for any widgets marked as 'dirty' (needs a repaint) From a140730abef9b5255563e8f52dae31527db6a36a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 12:51:57 +0000 Subject: [PATCH 066/113] unused imports --- src/textual/_compositor.py | 3 +-- src/textual/_layout.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 3f8b8347c..d655c5f39 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -25,7 +25,6 @@ from rich.style import Style from . import errors from ._cells import cell_len from ._loop import loop_last -from ._profile import timer from .geometry import NULL_OFFSET, Offset, Region, Size from .strip import Strip @@ -243,7 +242,7 @@ class Compositor: size: Size of the area to be filled. Returns: - Hidden, shown and resized widgets. + Hidden, shown, and resized widgets. """ self._cuts = None self._layers = None diff --git a/src/textual/_layout.py b/src/textual/_layout.py index 12e97d7d9..f65541bae 100644 --- a/src/textual/_layout.py +++ b/src/textual/_layout.py @@ -1,10 +1,9 @@ from __future__ import annotations from abc import ABC, abstractmethod -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import TYPE_CHECKING, ClassVar, NamedTuple -from ._profile import timer from ._spatial_map import SpatialMap from .geometry import Region, Size, Spacing From 4912360d76eed459037316763dd8b7851d8e4af6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 12:56:01 +0000 Subject: [PATCH 067/113] docs --- src/textual/_spatial_map.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/textual/_spatial_map.py b/src/textual/_spatial_map.py index bdfb94d57..68f646bf5 100644 --- a/src/textual/_spatial_map.py +++ b/src/textual/_spatial_map.py @@ -34,6 +34,8 @@ class SpatialMap(Generic[ValueType]): Returns: Iterable of grid squares (tuple of 2 values). """ + # (x1, y1) is the coordinate of the top left cell + # (x2, y2) is the coordinate of the bottom right cell x1, y1, width, height = region x2 = x1 + width - 1 y2 = y1 + height - 1 @@ -49,8 +51,11 @@ class SpatialMap(Generic[ValueType]): ) -> None: """Insert values in to the Spatial map. + Values are associated with their region in Euclidean space, and a boolean that + indicates fixed regions. Fixed regions don't scroll and are always visible. + Args: - regions_and_values: An iterable of Regions and values. + regions_and_values: An iterable of (REGION, FIXED, VALUE). """ append_fixed = self._fixed.append get_grid_list = self._map.__getitem__ @@ -74,7 +79,7 @@ class SpatialMap(Generic[ValueType]): region: A region. Returns: - A set of values under the region. + Values under the region. """ results: list[ValueType] = self._fixed.copy() add_results = results.extend From 9310d3dc785e71be484e92c97d05b68dbfe00509 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 13:23:39 +0000 Subject: [PATCH 068/113] annotations --- src/textual/_spatial_map.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/textual/_spatial_map.py b/src/textual/_spatial_map.py index 68f646bf5..a231e07af 100644 --- a/src/textual/_spatial_map.py +++ b/src/textual/_spatial_map.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from collections import defaultdict from itertools import product from typing import Generic, Iterable, TypeVar From 9df43e20ceab4dbdb40ab3f618b4083a6993f2d0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 14:09:39 +0000 Subject: [PATCH 069/113] Fix merge error --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 557540cb4..f36d87b03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Removed `screen.visible_widgets` and `screen.widgets` ## [0.11.1] - 2023-02-17 ->>>>>>> main ### Fixed From 17c4e40f7c8fd127faae9aafbba5d8d2984022d4 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 17:38:30 +0000 Subject: [PATCH 070/113] Update src/textual/_compositor.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- src/textual/_compositor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index d655c5f39..2027b9fb2 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -378,7 +378,7 @@ class Compositor: def _arrange_root( self, root: Widget, size: Size, visible_only: bool = True ) -> tuple[CompositorMap, set[Widget]]: - """Arrange a widgets children based on its layout attribute. + """Arrange a widget's children based on its layout attribute. Args: root: Top level widget. From af554ca1c4d08768aee24a6da5ea165a4a8ad60f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 17:40:22 +0000 Subject: [PATCH 071/113] Update src/textual/_spatial_map.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- src/textual/_spatial_map.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/_spatial_map.py b/src/textual/_spatial_map.py index a231e07af..bbd814b70 100644 --- a/src/textual/_spatial_map.py +++ b/src/textual/_spatial_map.py @@ -51,7 +51,7 @@ class SpatialMap(Generic[ValueType]): def insert( self, regions_and_values: Iterable[tuple[Region, bool, ValueType]] ) -> None: - """Insert values in to the Spatial map. + """Insert values into the Spatial map. Values are associated with their region in Euclidean space, and a boolean that indicates fixed regions. Fixed regions don't scroll and are always visible. From c61e3bab1a2385e7091646afcad06d68a772e126 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 17:54:11 +0000 Subject: [PATCH 072/113] Update src/textual/_spatial_map.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- src/textual/_spatial_map.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/_spatial_map.py b/src/textual/_spatial_map.py index bbd814b70..835cc9904 100644 --- a/src/textual/_spatial_map.py +++ b/src/textual/_spatial_map.py @@ -73,7 +73,7 @@ class SpatialMap(Generic[ValueType]): self.total_region = total_region def get_values_in_region(self, region: Region) -> list[ValueType]: - """Get a set of values that are under a given region. + """Get a superset of all the values that intersect with a given region. Note that this may return false positives. From c0c49978bdeb67c55da98eafe89c465721b1441e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 17:43:02 +0000 Subject: [PATCH 073/113] scrollbar fixes --- src/textual/_compositor.py | 5 ++++- src/textual/_spatial_map.py | 5 ++++- src/textual/screen.py | 18 ++++++++++++++++-- src/textual/widget.py | 19 +++++++++---------- 4 files changed, 33 insertions(+), 14 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 2027b9fb2..f85f7fbaa 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -298,7 +298,7 @@ class Compositor: resized=resized_widgets, ) - def reflow_visible(self, parent: Widget, size: Size) -> None: + def reflow_visible(self, parent: Widget, size: Size) -> set[Widget]: """Reflow only the visible children. This is a fast-path for scrolling. @@ -320,6 +320,7 @@ class Compositor: old_map = self.map map, widgets = self._arrange_root(parent, size, visible_only=True) + exposed_widgets = widgets - self.widgets # Replace map and widgets self.map = map self.widgets = widgets @@ -340,6 +341,8 @@ class Compositor: } self._dirty_regions.update(regions) + return exposed_widgets + @property def full_map(self) -> CompositorMap: """Lazily built compositor map that covers all widgets.""" diff --git a/src/textual/_spatial_map.py b/src/textual/_spatial_map.py index 835cc9904..916602fbe 100644 --- a/src/textual/_spatial_map.py +++ b/src/textual/_spatial_map.py @@ -10,9 +10,12 @@ ValueType = TypeVar("ValueType") class SpatialMap(Generic[ValueType]): - """A spatial map allows for data to be associated with a rectangular regions + """A spatial map allows for data to be associated with rectangular regions in Euclidean space, and efficiently queried. + When the SpatialMap is populated, a reference to each value is placed in a bucket associated + with a regular grid that covers 2D space. + """ def __init__(self, grid_width: int = 100, grid_height: int = 20) -> None: diff --git a/src/textual/screen.py b/src/textual/screen.py index c5cb91061..6f19a75aa 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -428,9 +428,24 @@ class Screen(Widget): self._compositor.update_widgets(self._dirty_widgets) self.update_timer.pause() + ResizeEvent = events.Resize try: if scroll: - self._compositor.reflow_visible(self, size) + exposed_widgets = self._compositor.reflow_visible(self, size) + if exposed_widgets: + layers = self._compositor.layers + for widget, ( + region, + _order, + _clip, + virtual_size, + container_size, + _, + ) in layers: + widget._size_updated(region.size, virtual_size, container_size) + widget.post_message_no_wait( + ResizeEvent(self, region.size, virtual_size, container_size) + ) else: hidden, shown, resized = self._compositor.reflow(self, size) Hide = events.Hide @@ -441,7 +456,6 @@ class Screen(Widget): # We want to send a resize event to widgets that were just added or change since last layout send_resize = shown | resized - ResizeEvent = events.Resize layers = self._compositor.layers for widget, ( diff --git a/src/textual/widget.py b/src/textual/widget.py index 165cd4ca4..ca98dc51e 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -38,6 +38,7 @@ from . import errors, events, messages from ._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction from ._arrange import DockArrangeResult, arrange from ._asyncio import create_task +from ._cache import FIFOCache from ._context import active_app from ._easing import DEFAULT_SCROLL_EASING from ._layout import Layout @@ -260,8 +261,9 @@ class Widget(DOMNode): self._content_width_cache: tuple[object, int] = (None, 0) self._content_height_cache: tuple[object, int] = (None, 0) - self._arrangement_cache_key: tuple[Size, int] = (Size(), -1) - self._cached_arrangement: DockArrangeResult | None = None + self._arrangement_cache: FIFOCache[ + tuple[Size, int], DockArrangeResult + ] = FIFOCache(4) self._styles_cache = StylesCache() self._rich_style_cache: dict[str, tuple[Style, Style]] = {} @@ -474,14 +476,11 @@ class Widget(DOMNode): assert self.is_container cache_key = (size, self._nodes._updates) - if ( - self._arrangement_cache_key == cache_key - and self._cached_arrangement is not None - ): - return self._cached_arrangement + cached_result = self._arrangement_cache.get(cache_key) + if cached_result is not None: + return cached_result - self._arrangement_cache_key = cache_key - arrangement = self._cached_arrangement = arrange( + arrangement = self._arrangement_cache[cache_key] = arrange( self, self._nodes, size, self.screen.size ) @@ -489,7 +488,7 @@ class Widget(DOMNode): def _clear_arrangement_cache(self) -> None: """Clear arrangement cache, forcing a new arrange operation.""" - self._cached_arrangement = None + self._arrangement_cache.clear() def _get_virtual_dom(self) -> Iterable[Widget]: """Get widgets not part of the DOM. From 0ac7eef4b57966b99d3b86c1a12fe9964655752f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 18:11:55 +0000 Subject: [PATCH 074/113] docstrings and types --- src/textual/_spatial_map.py | 22 ++++++++++++++-------- tests/test_spatial_map.py | 2 +- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/textual/_spatial_map.py b/src/textual/_spatial_map.py index 916602fbe..d31a7ee90 100644 --- a/src/textual/_spatial_map.py +++ b/src/textual/_spatial_map.py @@ -4,17 +4,23 @@ from collections import defaultdict from itertools import product from typing import Generic, Iterable, TypeVar +from typing_extensions import TypeAlias + from .geometry import Region ValueType = TypeVar("ValueType") +GridCoordinate: TypeAlias = "tuple[int, int]" class SpatialMap(Generic[ValueType]): """A spatial map allows for data to be associated with rectangular regions in Euclidean space, and efficiently queried. - When the SpatialMap is populated, a reference to each value is placed in a bucket associated - with a regular grid that covers 2D space. + When the SpatialMap is populated, a reference to each value is placed into one or + more buckets associated with a regular grid that covers 2D space. + + The SpatialMap is able to quickly retrieve the values under a given "window" region + by combining the values in the grid squares under the visible area. """ @@ -27,17 +33,17 @@ class SpatialMap(Generic[ValueType]): """ self._grid_size = (grid_width, grid_height) self.total_region = Region() - self._map: defaultdict[tuple[int, int], list[ValueType]] = defaultdict(list) + self._map: defaultdict[GridCoordinate, list[ValueType]] = defaultdict(list) self._fixed: list[ValueType] = [] - def _region_to_grid(self, region: Region) -> Iterable[tuple[int, int]]: + def _region_to_grid_coordinate(self, region: Region) -> Iterable[GridCoordinate]: """Get the grid squares under a region. Args: region: A region. Returns: - Iterable of grid squares (tuple of 2 values). + Iterable of grid coordinates (tuple of 2 values). """ # (x1, y1) is the coordinate of the top left cell # (x2, y2) is the coordinate of the bottom right cell @@ -64,7 +70,7 @@ class SpatialMap(Generic[ValueType]): """ append_fixed = self._fixed.append get_grid_list = self._map.__getitem__ - _region_to_grid = self._region_to_grid + _region_to_grid = self._region_to_grid_coordinate total_region = self.total_region for region, fixed, value in regions_and_values: total_region = total_region.union(region) @@ -89,8 +95,8 @@ class SpatialMap(Generic[ValueType]): results: list[ValueType] = self._fixed.copy() add_results = results.extend get_grid_values = self._map.get - for grid in self._region_to_grid(region): - grid_values = get_grid_values(grid) + for grid_coordinate in self._region_to_grid_coordinate(region): + grid_values = get_grid_values(grid_coordinate) if grid_values is not None: add_results(grid_values) unique_values = list(dict.fromkeys(results)) diff --git a/tests/test_spatial_map.py b/tests/test_spatial_map.py index ddbf890fb..80a0eda1c 100644 --- a/tests/test_spatial_map.py +++ b/tests/test_spatial_map.py @@ -36,7 +36,7 @@ from textual.geometry import Region def test_region_to_grid(region, grid): spatial_map = SpatialMap(10, 10) - assert list(spatial_map._region_to_grid(region)) == grid + assert list(spatial_map._region_to_grid_coordinate(region)) == grid def test_get_values_in_region() -> None: From 31c8fb2818ce9de45c17b615254d9e1a87737c1d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 18:14:13 +0000 Subject: [PATCH 075/113] tests --- tests/test_spatial_map.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/tests/test_spatial_map.py b/tests/test_spatial_map.py index 80a0eda1c..a8b0fb0d6 100644 --- a/tests/test_spatial_map.py +++ b/tests/test_spatial_map.py @@ -46,11 +46,19 @@ def test_get_values_in_region() -> None: [ (Region(10, 5, 5, 5), False, "foo"), (Region(5, 20, 5, 5), False, "bar"), + (Region(0, 0, 40, 1), True, "title"), ] ) - assert spatial_map.get_values_in_region(Region(0, 0, 10, 5)) == ["foo"] - assert spatial_map.get_values_in_region(Region(0, 1, 10, 5)) == ["foo"] - assert spatial_map.get_values_in_region(Region(0, 10, 10, 5)) == [] - assert spatial_map.get_values_in_region(Region(0, 20, 10, 5)) == ["bar"] - assert spatial_map.get_values_in_region(Region(5, 5, 50, 50)) == ["foo", "bar"] + assert spatial_map.get_values_in_region(Region(0, 0, 10, 5)) == [ + "title", + "foo", + ] + assert spatial_map.get_values_in_region(Region(0, 1, 10, 5)) == ["title", "foo"] + assert spatial_map.get_values_in_region(Region(0, 10, 10, 5)) == ["title"] + assert spatial_map.get_values_in_region(Region(0, 20, 10, 5)) == ["title", "bar"] + assert spatial_map.get_values_in_region(Region(5, 5, 50, 50)) == [ + "title", + "foo", + "bar", + ] From 9e94046cc6a4d63220ba8732aa394871ea44e96f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 22:35:00 +0000 Subject: [PATCH 076/113] fix for events not sent on scroll --- src/textual/_compositor.py | 5 +++-- src/textual/screen.py | 33 ++++++++++++++++++----------- src/textual/widget.py | 10 +++++---- src/textual/widgets/_placeholder.py | 11 +++++----- 4 files changed, 36 insertions(+), 23 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index f85f7fbaa..d399fc554 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -320,7 +320,7 @@ class Compositor: old_map = self.map map, widgets = self._arrange_root(parent, size, visible_only=True) - exposed_widgets = widgets - self.widgets + exposed_widgets = map.keys() - old_map.keys() # Replace map and widgets self.map = map self.widgets = widgets @@ -394,6 +394,7 @@ class Compositor: map: CompositorMap = {} widgets: set[Widget] = set() + add_new_widget = widgets.add layer_order: int = 0 def add_widget( @@ -419,7 +420,7 @@ class Compositor: visible = visibility == "visible" if visible: - widgets.add(widget) + add_new_widget(widget) styles_offset = widget.styles.offset layout_offset = ( styles_offset.resolve(region.size, clip.size) diff --git a/src/textual/screen.py b/src/textual/screen.py index 6f19a75aa..48c4face7 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -9,6 +9,7 @@ from rich.style import Style from . import errors, events, messages from ._callback import invoke from ._compositor import Compositor, MapGeometry +from ._profile import timer from ._types import CallbackType from .css.match import match from .css.parse import parse_selectors @@ -434,18 +435,26 @@ class Screen(Widget): exposed_widgets = self._compositor.reflow_visible(self, size) if exposed_widgets: layers = self._compositor.layers - for widget, ( - region, - _order, - _clip, - virtual_size, - container_size, - _, - ) in layers: - widget._size_updated(region.size, virtual_size, container_size) - widget.post_message_no_wait( - ResizeEvent(self, region.size, virtual_size, container_size) - ) + with timer("size events"): + for widget, ( + region, + _order, + _clip, + virtual_size, + container_size, + _, + ) in layers: + if widget in exposed_widgets: + widget._size_updated( + region.size, + virtual_size, + container_size, + ) + widget.post_message_no_wait( + ResizeEvent( + self, region.size, virtual_size, container_size + ) + ) else: hidden, shown, resized = self._compositor.reflow(self, size) Hide = events.Hide diff --git a/src/textual/widget.py b/src/textual/widget.py index ca98dc51e..7668e79b9 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2142,6 +2142,7 @@ class Widget(DOMNode): size: Screen size. virtual_size: Virtual (scrollable) size. container_size: Container size (size of parent). + refresh: Also refresh. """ if ( self._size != size @@ -2149,11 +2150,13 @@ class Widget(DOMNode): or self._container_size != container_size ): self._size = size - self.virtual_size = virtual_size + if self.virtual_size: + self.virtual_size = virtual_size + else: + self._reactive_virtual_size = virtual_size self._container_size = container_size if self.is_scrollable: self._scroll_update(virtual_size) - self.refresh() def _scroll_update(self, virtual_size: Size) -> None: """Update scrollbars visibility and dimensions. @@ -2291,8 +2294,7 @@ class Widget(DOMNode): repaint: Repaint the widget (will call render() again). Defaults to True. layout: Also layout widgets in the view. Defaults to False. """ - - if layout: + if layout and not self._layout_required: self._layout_required = True for ancestor in self.ancestors: if not isinstance(ancestor, Widget): diff --git a/src/textual/widgets/_placeholder.py b/src/textual/widgets/_placeholder.py index d4bf01333..970e4465b 100644 --- a/src/textual/widgets/_placeholder.py +++ b/src/textual/widgets/_placeholder.py @@ -2,6 +2,7 @@ from __future__ import annotations from itertools import cycle +from rich.console import RenderableType from typing_extensions import Literal from .. import events @@ -61,10 +62,10 @@ class Placeholder(Widget): overflow: hidden; color: $text; } - Placeholder.-text { padding: 1; } + """ # Consecutive placeholders get assigned consecutive colors. @@ -73,7 +74,7 @@ class Placeholder(Widget): variant: Reactive[PlaceholderVariant] = reactive("default") - _renderables: dict[PlaceholderVariant, RenderResult] + _renderables: dict[PlaceholderVariant, str] @classmethod def reset_color_cycle(cls) -> None: @@ -119,7 +120,7 @@ class Placeholder(Widget): while next(self._variants_cycle) != self.variant: pass - def render(self) -> RenderResult: + def render(self) -> RenderableType: return self._renderables[self.variant] def cycle_variant(self) -> None: @@ -147,6 +148,6 @@ class Placeholder(Widget): def on_resize(self, event: events.Resize) -> None: """Update the placeholder "size" variant with the new placeholder size.""" - self._renderables["size"] = self._SIZE_RENDER_TEMPLATE.format(*self.size) + self._renderables["size"] = self._SIZE_RENDER_TEMPLATE.format(*event.size) if self.variant == "size": - self.refresh(layout=True) + self.refresh(layout=False) From f5d64ebe8cb33ff76b1a2bd3d4eff43d2c489fbb Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 17 Feb 2023 22:47:08 +0000 Subject: [PATCH 077/113] fix for scrolling and events --- src/textual/screen.py | 1 + src/textual/widget.py | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/textual/screen.py b/src/textual/screen.py index 48c4face7..4c20dfb8f 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -449,6 +449,7 @@ class Screen(Widget): region.size, virtual_size, container_size, + layout=False, ) widget.post_message_no_wait( ResizeEvent( diff --git a/src/textual/widget.py b/src/textual/widget.py index 7668e79b9..194d8e4e4 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2134,7 +2134,7 @@ class Widget(DOMNode): self.app.update_styles(self) def _size_updated( - self, size: Size, virtual_size: Size, container_size: Size + self, size: Size, virtual_size: Size, container_size: Size, layout: bool = True ) -> None: """Called when the widget's size is updated. @@ -2142,7 +2142,7 @@ class Widget(DOMNode): size: Screen size. virtual_size: Virtual (scrollable) size. container_size: Container size (size of parent). - refresh: Also refresh. + layout: Perform layout if required. """ if ( self._size != size @@ -2150,7 +2150,7 @@ class Widget(DOMNode): or self._container_size != container_size ): self._size = size - if self.virtual_size: + if layout: self.virtual_size = virtual_size else: self._reactive_virtual_size = virtual_size From d8e17e98c272ca10fffa6025a64f28224a69f5e1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 09:29:27 +0000 Subject: [PATCH 078/113] size updated bool --- src/textual/screen.py | 37 ++++++++++++++++++++----------------- src/textual/widget.py | 8 +++++++- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/src/textual/screen.py b/src/textual/screen.py index 4c20dfb8f..0377f8034 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -435,25 +435,28 @@ class Screen(Widget): exposed_widgets = self._compositor.reflow_visible(self, size) if exposed_widgets: layers = self._compositor.layers - with timer("size events"): - for widget, ( - region, - _order, - _clip, - virtual_size, - container_size, - _, - ) in layers: - if widget in exposed_widgets: - widget._size_updated( - region.size, - virtual_size, - container_size, - layout=False, - ) + + for widget, ( + region, + _order, + _clip, + virtual_size, + container_size, + _, + ) in layers: + if widget in exposed_widgets: + if widget._size_updated( + region.size, + virtual_size, + container_size, + layout=False, + ): widget.post_message_no_wait( ResizeEvent( - self, region.size, virtual_size, container_size + self, + region.size, + virtual_size, + container_size, ) ) else: diff --git a/src/textual/widget.py b/src/textual/widget.py index 194d8e4e4..12e33ec35 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2135,7 +2135,7 @@ class Widget(DOMNode): def _size_updated( self, size: Size, virtual_size: Size, container_size: Size, layout: bool = True - ) -> None: + ) -> bool: """Called when the widget's size is updated. Args: @@ -2143,6 +2143,9 @@ class Widget(DOMNode): virtual_size: Virtual (scrollable) size. container_size: Container size (size of parent). layout: Perform layout if required. + + Returns: + True if anything changed, or False if nothing changed. """ if ( self._size != size @@ -2157,6 +2160,9 @@ class Widget(DOMNode): self._container_size = container_size if self.is_scrollable: self._scroll_update(virtual_size) + return True + else: + return False def _scroll_update(self, virtual_size: Size) -> None: """Update scrollbars visibility and dimensions. From 7b654f53aed33817cd14385a6a23771f4f39a7b6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 09:51:49 +0000 Subject: [PATCH 079/113] Added batching --- src/textual/app.py | 17 ++++++++++++++++- src/textual/screen.py | 3 ++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 33b887e09..7d2b1cc47 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -411,6 +411,7 @@ class App(Generic[ReturnType], DOMNode): self._screenshot: str | None = None self._dom_lock = asyncio.Lock() self._dom_ready = False + self._batch_count = 0 self.set_class(self.dark, "-dark-mode") @property @@ -426,6 +427,18 @@ class App(Generic[ReturnType], DOMNode): except ScreenError: return () + def _begin_batch(self) -> None: + self._batch_count += 1 + + def _end_batch(self) -> None: + self._batch_count -= 1 + if not self._batch_count: + try: + self.screen.check_idle() + except ScreenStackError: + pass + self.check_idle() + def animate( self, attribute: str, @@ -1504,6 +1517,7 @@ class App(Generic[ReturnType], DOMNode): if inspect.isawaitable(ready_result): await ready_result + self._begin_batch() try: try: await self._dispatch_message(events.Compose(sender=self)) @@ -1524,6 +1538,7 @@ class App(Generic[ReturnType], DOMNode): finally: self._running = True + self._end_batch() await self._ready() await invoke_ready_callback() @@ -1615,7 +1630,7 @@ class App(Generic[ReturnType], DOMNode): def _on_idle(self) -> None: """Perform actions when there are no messages in the queue.""" - if self._require_stylesheet_update: + if self._require_stylesheet_update and not self._batch_count: nodes: set[DOMNode] = { child for node in self._require_stylesheet_update diff --git a/src/textual/screen.py b/src/textual/screen.py index 0377f8034..8e8a47afb 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -359,8 +359,9 @@ class Screen(Widget): # Check for any widgets marked as 'dirty' (needs a repaint) event.prevent_default() - if self.is_current: + if not self.app._batch_count and self.is_current: async with self.app._dom_lock: + print("LAYOUT") if self.is_current: if self._layout_required: self._refresh_layout() From 8e9d99cb22d50629f0abdf4795a6ca28bb0d8400 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 09:56:31 +0000 Subject: [PATCH 080/113] fix for other size_updated --- src/textual/scroll_view.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py index 53c6c238a..1f834e839 100644 --- a/src/textual/scroll_view.py +++ b/src/textual/scroll_view.py @@ -69,14 +69,18 @@ class ScrollView(Widget): return self.virtual_size.height def _size_updated( - self, size: Size, virtual_size: Size, container_size: Size - ) -> None: + self, size: Size, virtual_size: Size, container_size: Size, layout: bool = True + ) -> bool: """Called when size is updated. Args: size: New size. virtual_size: New virtual size. container_size: New container size. + layout: Perform layout if required. + + Returns: + True if anything changed, or False if nothing changed. """ if self._size != size or container_size != container_size: self.refresh() @@ -90,6 +94,9 @@ class ScrollView(Widget): self._container_size = size - self.styles.gutter.totals self._scroll_update(virtual_size) self.scroll_to(self.scroll_x, self.scroll_y, animate=False) + return True + else: + return False def render(self) -> RenderableType: """Render the scrollable region (if `render_lines` is not implemented). From 7523637cb6c588cdef7ae541d411a24da42460a6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 10:07:51 +0000 Subject: [PATCH 081/113] added batch update --- CHANGELOG.md | 4 ++ src/textual/app.py | 64 +++++++++++++++++++----------- src/textual/cli/previews/colors.py | 1 - src/textual/screen.py | 1 - 4 files changed, 45 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f36d87b03..01d4c3cca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [0.12.0] - Unreleased +### Added + +- Added `App.batch_update` + ### Changed - Scrolling by page now adds to current position. diff --git a/src/textual/app.py b/src/textual/app.py index 7d2b1cc47..383a863b2 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -11,7 +11,12 @@ import unicodedata import warnings from asyncio import Task from concurrent.futures import Future -from contextlib import asynccontextmanager, redirect_stderr, redirect_stdout +from contextlib import ( + asynccontextmanager, + contextmanager, + redirect_stderr, + redirect_stdout, +) from datetime import datetime from functools import partial from pathlib import Path, PurePath @@ -22,6 +27,7 @@ from typing import ( Any, Awaitable, Callable, + Generator, Generic, Iterable, List, @@ -427,11 +433,23 @@ class App(Generic[ReturnType], DOMNode): except ScreenError: return () + @contextmanager + def batch_update(self) -> Generator[None, None, None]: + """Suspend all repaints until the end of the batch.""" + self._begin_batch() + try: + yield + finally: + self._end_batch() + def _begin_batch(self) -> None: + """Begin a batch update.""" self._batch_count += 1 def _end_batch(self) -> None: + """End a batch update.""" self._batch_count -= 1 + assert self._batch_count >= 0, "This won't happen if you use `batch_update` =" if not self._batch_count: try: self.screen.check_idle() @@ -1517,30 +1535,29 @@ class App(Generic[ReturnType], DOMNode): if inspect.isawaitable(ready_result): await ready_result - self._begin_batch() - try: + with self.batch_update(): try: - await self._dispatch_message(events.Compose(sender=self)) - await self._dispatch_message(events.Mount(sender=self)) + try: + await self._dispatch_message(events.Compose(sender=self)) + await self._dispatch_message(events.Mount(sender=self)) + finally: + self._mounted_event.set() + + Reactive._initialize_object(self) + + self.stylesheet.update(self) + self.refresh() + + await self.animator.start() + + except Exception: + await self.animator.stop() + raise + finally: - self._mounted_event.set() - - Reactive._initialize_object(self) - - self.stylesheet.update(self) - self.refresh() - - await self.animator.start() - - except Exception: - await self.animator.stop() - raise - - finally: - self._running = True - self._end_batch() - await self._ready() - await invoke_ready_callback() + self._running = True + await self._ready() + await invoke_ready_callback() try: await self._process_messages_loop() @@ -1626,6 +1643,7 @@ class App(Generic[ReturnType], DOMNode): raise TypeError( f"{self!r} compose() returned an invalid response; {error}" ) from error + await self.mount_all(widgets) def _on_idle(self) -> None: diff --git a/src/textual/cli/previews/colors.py b/src/textual/cli/previews/colors.py index 1cbc04f9c..fb11f059a 100644 --- a/src/textual/cli/previews/colors.py +++ b/src/textual/cli/previews/colors.py @@ -72,7 +72,6 @@ class ColorsApp(App): content.mount(ColorsView()) def on_button_pressed(self, event: Button.Pressed) -> None: - self.bell() self.query(ColorGroup).remove_class("-active") group = self.query_one(f"#group-{event.button.id}", ColorGroup) group.add_class("-active") diff --git a/src/textual/screen.py b/src/textual/screen.py index 8e8a47afb..717bac9d0 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -361,7 +361,6 @@ class Screen(Widget): if not self.app._batch_count and self.is_current: async with self.app._dom_lock: - print("LAYOUT") if self.is_current: if self._layout_required: self._refresh_layout() From 00de6b5b9d107a6deb00d71856db3e6d2e06984f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 10:17:30 +0000 Subject: [PATCH 082/113] extra chars --- src/textual/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index 383a863b2..57937dba0 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -449,7 +449,7 @@ class App(Generic[ReturnType], DOMNode): def _end_batch(self) -> None: """End a batch update.""" self._batch_count -= 1 - assert self._batch_count >= 0, "This won't happen if you use `batch_update` =" + assert self._batch_count >= 0, "This won't happen if you use `batch_update`" if not self._batch_count: try: self.screen.check_idle() From b9375c5a141320ec4c25989d26086bc02638c186 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 10:22:29 +0000 Subject: [PATCH 083/113] Added test for batch update --- tests/test_app.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 tests/test_app.py diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 000000000..7d6675379 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,12 @@ +from textual.app import App + + +def test_batch_update(): + app = App() + assert app._batch_count == 0 + with app.batch_update(): + assert app._batch_count == 1 + with app.batch_update(): + assert app._batch_count == 2 + assert app._batch_count == 1 + assert app._batch_count == 0 From ff657489923158ada817c29109987772400f4de2 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 10:24:45 +0000 Subject: [PATCH 084/113] comment on test --- tests/test_app.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/test_app.py b/tests/test_app.py index 7d6675379..221286f11 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -2,11 +2,16 @@ from textual.app import App def test_batch_update(): + """Test `batch_update` context manager""" app = App() - assert app._batch_count == 0 + assert app._batch_count == 0 # Start at zero + with app.batch_update(): - assert app._batch_count == 1 + assert app._batch_count == 1 # Increments in context manager + with app.batch_update(): - assert app._batch_count == 2 - assert app._batch_count == 1 - assert app._batch_count == 0 + assert app._batch_count == 2 # Nested updates + + assert app._batch_count == 1 # Exiting decrements + + assert app._batch_count == 0 # Back to zero From 4d1a3a5dc9428df598c9631494d111b9fa067923 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 10:42:42 +0000 Subject: [PATCH 085/113] speed up shutdown --- src/textual/app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/app.py b/src/textual/app.py index 33b887e09..3185247e9 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1778,6 +1778,7 @@ class App(Generic[ReturnType], DOMNode): await child._close_messages() async def _shutdown(self) -> None: + self._begin_update() # Prevents any layout / repaint while shutting down driver = self._driver self._running = False if driver is not None: From e143af1d3c07f089ad27645da70e5ab647966a44 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 10:46:42 +0000 Subject: [PATCH 086/113] speed up shutdown --- src/textual/app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/app.py b/src/textual/app.py index 57937dba0..c64d59799 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1828,6 +1828,7 @@ class App(Generic[ReturnType], DOMNode): self._writer_thread.stop() async def _on_exit_app(self) -> None: + self._begin_batch() # Prevent repaint / layout while shutting down await self._message_queue.put(None) def refresh(self, *, repaint: bool = True, layout: bool = False) -> None: From 040e8c941a66bf14504464b81d5c527362d73d52 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 14:55:42 +0000 Subject: [PATCH 087/113] better thumb math --- src/textual/scrollbar.py | 59 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index 12e1fd65d..cb1f92870 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -112,8 +112,14 @@ class ScrollBarRender: if window_size and size and virtual_size and size != virtual_size: step_size = virtual_size / size + thumb_size = window_size / step_size * len_bars + + if thumb_size < len_bars: + virtual_size += step_size + step_size = virtual_size / size + start = int(position / step_size * len_bars) - end = start + max(len_bars, int(ceil(window_size / step_size * len_bars))) + end = start + max(len_bars, ceil(thumb_size)) start_index, start_bar = divmod(max(0, start), len_bars) end_index, end_bar = divmod(max(0, end), len_bars) @@ -246,6 +252,7 @@ class ScrollBar(Widget): yield "thickness", self.thickness def render(self) -> RenderableType: + assert self.parent is not None styles = self.parent.styles if self.grabbed: background = styles.scrollbar_background_active @@ -258,11 +265,25 @@ class ScrollBar(Widget): color = styles.scrollbar_color color = background + color scrollbar_style = Style.from_color(color.rich_color, background.rich_color) + return self._render_bar(scrollbar_style) + + def _render_bar(self, scrollbar_style: Style) -> RenderableType: + """Get a renderable for the scrollbar with given style. + + Args: + scrollbar_style: Scrollbar style. + + Returns: + Scrollbar renderable. + """ + window_size = ( + self.window_size if self.window_size < self.window_virtual_size else 0 + ) + virtual_size = self.window_virtual_size + return self.renderer( - virtual_size=self.window_virtual_size, - window_size=( - self.window_size if self.window_size < self.window_virtual_size else 0 - ), + virtual_size=ceil(virtual_size), + window_size=ceil(window_size), position=self.position, thickness=self.thickness, vertical=self.vertical, @@ -311,14 +332,26 @@ class ScrollBar(Widget): x: float | None = None y: float | None = None if self.vertical: + size = self.size.height + virtual_size = self.window_virtual_size + step_size = virtual_size / size + thumb_size = self.window_size / step_size + if thumb_size < 1: + virtual_size = ceil(virtual_size + step_size) y = round( self.grabbed_position + ( (event.screen_y - self.grabbed.y) - * (self.window_virtual_size / self.window_size) + * (virtual_size / self.window_size) ) ) else: + size = self.size.width + virtual_size = self.window_virtual_size + step_size = virtual_size / size + thumb_size = self.window_size / step_size + if thumb_size < 1: + virtual_size = ceil(virtual_size + step_size) x = round( self.grabbed_position + ( @@ -345,3 +378,17 @@ class ScrollBarCorner(Widget): styles = self.parent.styles color = styles.scrollbar_corner_color return Blank(color) + + +if __name__ == "__main__": + from rich import print + from rich.console import Console + + console = Console() + scrollbar = ScrollBar() + scrollbar.window_virtual_size = 100 + scrollbar.window_size = 10 + scrollbar.position = 95 + + style = Style.parse("magenta on #333333") + console.print(scrollbar._render_bar(style), height=10) From f94f6e4ba4ad3257444921ae1ef38c452a9aacc1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 15:02:14 +0000 Subject: [PATCH 088/113] remote test code --- src/textual/scrollbar.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index cb1f92870..39a8e11ef 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -378,17 +378,3 @@ class ScrollBarCorner(Widget): styles = self.parent.styles color = styles.scrollbar_corner_color return Blank(color) - - -if __name__ == "__main__": - from rich import print - from rich.console import Console - - console = Console() - scrollbar = ScrollBar() - scrollbar.window_virtual_size = 100 - scrollbar.window_size = 10 - scrollbar.position = 95 - - style = Style.parse("magenta on #333333") - console.print(scrollbar._render_bar(style), height=10) From 463ff3fa8b7b2d0292962cb10827109693ace434 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 15:20:09 +0000 Subject: [PATCH 089/113] fix horizontal scroll --- src/textual/scrollbar.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index 39a8e11ef..f00dbccb2 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -356,7 +356,7 @@ class ScrollBar(Widget): self.grabbed_position + ( (event.screen_x - self.grabbed.x) - * (self.window_virtual_size / self.window_size) + * (virtual_size / self.window_size) ) ) await self.post_message(ScrollTo(self, x=x, y=y)) From b6272a3b594c3163dfc2f527d97329d2b673ae8f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 17:57:48 +0000 Subject: [PATCH 090/113] fix dictionary example going down --- examples/dictionary.py | 8 ++++++-- src/textual/_compositor.py | 2 ++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/examples/dictionary.py b/examples/dictionary.py index 737bcb283..04183170c 100644 --- a/examples/dictionary.py +++ b/examples/dictionary.py @@ -11,7 +11,7 @@ from rich.markdown import Markdown from textual.app import App, ComposeResult from textual.containers import Content -from textual.widgets import Static, Input +from textual.widgets import Input, Static class DictionaryApp(App): @@ -41,7 +41,11 @@ class DictionaryApp(App): """Looks up a word.""" url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}" async with httpx.AsyncClient() as client: - results = (await client.get(url)).json() + response = await client.get(url) + if response.status_code % 100 != 2: + self.query_one("#results", Static).update(response.text) + return + results = response.json() if word == self.query_one(Input).value: markdown = self.make_word_markdown(results) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index d399fc554..e9965db8b 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -248,6 +248,7 @@ class Compositor: self._layers = None self._layers_visible = None self._visible_widgets = None + self._full_map = None self.root = parent self.size = size @@ -860,6 +861,7 @@ class Compositor: widget: Widget to update. """ + self._full_map = None regions: list[Region] = [] add_region = regions.append get_widget = self.visible_widgets.__getitem__ From 665efa2d052b837fc4986e3931eee0b849bc3f1e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 18:01:25 +0000 Subject: [PATCH 091/113] error handling in dictionary --- examples/dictionary.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/dictionary.py b/examples/dictionary.py index 04183170c..8ef47f3bb 100644 --- a/examples/dictionary.py +++ b/examples/dictionary.py @@ -42,10 +42,11 @@ class DictionaryApp(App): url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}" async with httpx.AsyncClient() as client: response = await client.get(url) - if response.status_code % 100 != 2: + try: + results = response.json() + except Exception: self.query_one("#results", Static).update(response.text) return - results = response.json() if word == self.query_one(Input).value: markdown = self.make_word_markdown(results) From 724e0e3f5850c369d2bc43b59f90c17475c23c8e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 22:43:13 +0000 Subject: [PATCH 092/113] Markdown and dictionary example --- CHANGELOG.md | 1 + examples/dictionary.css | 6 +++--- examples/dictionary.py | 10 +++++----- src/textual/widgets/_markdown.py | 21 +++++++++++++++++++-- 4 files changed, 28 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01d4c3cca..153b24212 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Added `App.batch_update` +- Added horizontal rule to Markdown ### Changed diff --git a/examples/dictionary.css b/examples/dictionary.css index 6bca8b9f5..151fa019d 100644 --- a/examples/dictionary.css +++ b/examples/dictionary.css @@ -8,9 +8,9 @@ Input { } #results { - width: auto; - min-height: 100%; - padding: 0 1; + width: 100%; + height: auto; + } #results-container { diff --git a/examples/dictionary.py b/examples/dictionary.py index 737bcb283..6a6f84d42 100644 --- a/examples/dictionary.py +++ b/examples/dictionary.py @@ -7,11 +7,10 @@ try: except ImportError: raise ImportError("Please install httpx with 'pip install httpx' ") -from rich.markdown import Markdown from textual.app import App, ComposeResult from textual.containers import Content -from textual.widgets import Static, Input +from textual.widgets import Input, Markdown class DictionaryApp(App): @@ -21,7 +20,7 @@ class DictionaryApp(App): def compose(self) -> ComposeResult: yield Input(placeholder="Search for a word") - yield Content(Static(id="results"), id="results-container") + yield Content(Markdown(id="results"), id="results-container") def on_mount(self) -> None: """Called when app starts.""" @@ -35,7 +34,7 @@ class DictionaryApp(App): asyncio.create_task(self.lookup_word(message.value)) else: # Clear the results - self.query_one("#results", Static).update() + await self.query_one("#results", Markdown).update("") async def lookup_word(self, word: str) -> None: """Looks up a word.""" @@ -45,7 +44,8 @@ class DictionaryApp(App): if word == self.query_one(Input).value: markdown = self.make_word_markdown(results) - self.query_one("#results", Static).update(Markdown(markdown)) + self.log(markdown) + await self.query_one("#results", Markdown).update(markdown) def make_word_markdown(self, results: object) -> str: """Convert the results in to markdown.""" diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 50812dd9d..4c8bb5b16 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -198,6 +198,18 @@ class MarkdownH6(MarkdownHeader): """ +class MarkdownHorizontalRule(MarkdownBlock): + """A horizontal rule.""" + + DEFAULT_CSS = """ + MarkdownHorizontalRule { + border-bottom: heavy $primary; + height: 1; + padding-top: 1; + } + """ + + class MarkdownParagraph(MarkdownBlock): """A paragraph Markdown block.""" @@ -501,7 +513,7 @@ class Markdown(Widget): markdown = path.read_text(encoding="utf-8") except Exception: return False - await self.query("MarkdownBlock").remove() + await self.update(markdown) return True @@ -524,6 +536,8 @@ class Markdown(Widget): if token.type == "heading_open": block_id += 1 stack.append(HEADINGS[token.tag](id=f"block{block_id}")) + elif token.type == "hr": + output.append(MarkdownHorizontalRule()) elif token.type == "paragraph_open": stack.append(MarkdownParagraph()) elif token.type == "blockquote_open": @@ -627,7 +641,10 @@ class Markdown(Widget): await self.post_message( Markdown.TableOfContentsUpdated(table_of_contents, sender=self) ) - await self.mount(*output) + with self.app.batch_update(): + await self.query("MarkdownBlock").remove() + await self.mount(*output) + self.refresh(layout=True) class MarkdownTableOfContents(Widget, can_focus_children=True): From c3e5e0490c47691309e5cb0b97796c740da02837 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 22:46:43 +0000 Subject: [PATCH 093/113] remove log --- examples/dictionary.py | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/dictionary.py b/examples/dictionary.py index 6a6f84d42..89fa3170b 100644 --- a/examples/dictionary.py +++ b/examples/dictionary.py @@ -44,7 +44,6 @@ class DictionaryApp(App): if word == self.query_one(Input).value: markdown = self.make_word_markdown(results) - self.log(markdown) await self.query_one("#results", Markdown).update(markdown) def make_word_markdown(self, results: object) -> str: From 104f784a20024bf8cf9216638b22f2a47715de89 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 22:48:49 +0000 Subject: [PATCH 094/113] extra space around hr --- src/textual/widgets/_markdown.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 4c8bb5b16..1328fa5b2 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -206,6 +206,7 @@ class MarkdownHorizontalRule(MarkdownBlock): border-bottom: heavy $primary; height: 1; padding-top: 1; + margin-bottom: 1; } """ From 5a6d7183443551e4981d60a5fddc4a5c2c715820 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 23:15:15 +0000 Subject: [PATCH 095/113] list item improvements --- examples/example.md | 16 ++++++++++++++++ src/textual/widgets/_markdown.py | 21 +++++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/examples/example.md b/examples/example.md index 83495ba2f..92a6e013a 100644 --- a/examples/example.md +++ b/examples/example.md @@ -42,6 +42,22 @@ Two tildes indicates strikethrough, e.g. `~~cross out~~` render ~~cross out~~. Inline code is indicated by backticks. e.g. `import this`. +## Lists + +1. Lists can be ordered +2. Lists can be unordered + - Foo + - Bar + - Jessica + - Reg + - Green + - January + - February + - March + - Blue + - Paul + - Baz + ## Fences Fenced code blocks are introduced with three back-ticks and the optional parser. Here we are rendering the code in a sub-widget with syntax highlighting and indent guides. diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 1328fa5b2..93bb7e306 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -345,7 +345,7 @@ class MarkdownBullet(Widget): } """ - symbol = reactive("●​ ") + symbol = reactive("●​") """The symbol for the bullet.""" def render(self) -> Text: @@ -381,6 +381,14 @@ class MarkdownListItem(MarkdownBlock): self._blocks.clear() +class MarkdownOrderedListItem(MarkdownListItem): + pass + + +class MarkdownUnorderedListItem(MarkdownListItem): + pass + + class MarkdownFence(MarkdownBlock): """A fence Markdown block.""" @@ -452,6 +460,8 @@ class Markdown(Widget): """ COMPONENT_CLASSES = {"em", "strong", "s", "code_inline"} + BULLETS = ["⏺ ", "■ ", "• ", "‣ "] + def __init__( self, markdown: str | None = None, @@ -548,8 +558,15 @@ class Markdown(Widget): elif token.type == "ordered_list_open": stack.append(MarkdownOrderedList()) elif token.type == "list_item_open": + item_count = sum( + 1 for block in stack if isinstance(block, MarkdownUnorderedListItem) + ) stack.append( - MarkdownListItem(f"{token.info}. " if token.info else "● ") + MarkdownOrderedListItem(f" {token.info}. ") + if token.info + else MarkdownUnorderedListItem( + self.BULLETS[item_count % len(self.BULLETS)] + ) ) elif token.type == "table_open": stack.append(MarkdownTable()) From de86b564bb12e66f712e3024f73f119cbf30e7be Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 23:19:43 +0000 Subject: [PATCH 096/113] styling for list items --- src/textual/widgets/_markdown.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 93bb7e306..3d6558c36 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -342,6 +342,8 @@ class MarkdownBullet(Widget): DEFAULT_CSS = """ MarkdownBullet { width: auto; + color: $success; + text-style: bold; } """ From 434e6178d30479ee3aad6c7cfc1342a35f2b25f4 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 18 Feb 2023 23:25:17 +0000 Subject: [PATCH 097/113] list item style --- examples/example.md | 18 +++++++----------- src/textual/widgets/_markdown.py | 2 +- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/examples/example.md b/examples/example.md index 92a6e013a..169a72923 100644 --- a/examples/example.md +++ b/examples/example.md @@ -46,17 +46,13 @@ Inline code is indicated by backticks. e.g. `import this`. 1. Lists can be ordered 2. Lists can be unordered - - Foo - - Bar - - Jessica - - Reg - - Green - - January - - February - - March - - Blue - - Paul - - Baz + - I must not fear. + - Fear is the mind-killer. + - Fear is the little-death that brings total obliteration. + - I will face my fear. + - I will permit it to pass over me and through me. + - And when it has gone past, I will turn the inner eye to see its path. + - Where the fear has gone there will be nothing. Only I will remain. ## Fences diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 3d6558c36..f7ee0f188 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -260,7 +260,7 @@ class MarkdownOrderedList(MarkdownBlock): DEFAULT_CSS = """ MarkdownOrderedList { margin: 0; - padding: 0 0; + padding: 0 0 1 0; } Markdown OrderedList MarkdownOrderedList { From a63d07f619fd6262763e680daf7c3dcc44c8758a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 19 Feb 2023 09:18:26 +0000 Subject: [PATCH 098/113] enhanced ordered list --- examples/example.md | 3 +- src/textual/widgets/_markdown.py | 100 +++++++++++++++++++++---------- 2 files changed, 72 insertions(+), 31 deletions(-) diff --git a/examples/example.md b/examples/example.md index 169a72923..366500ca6 100644 --- a/examples/example.md +++ b/examples/example.md @@ -50,10 +50,11 @@ Inline code is indicated by backticks. e.g. `import this`. - Fear is the mind-killer. - Fear is the little-death that brings total obliteration. - I will face my fear. - - I will permit it to pass over me and through me. + - I will permit it to pass over me and through me. - And when it has gone past, I will turn the inner eye to see its path. - Where the fear has gone there will be nothing. Only I will remain. + ## Fences Fenced code blocks are introduced with three back-ticks and the optional parser. Here we are rendering the code in a sub-widget with syntax highlighting and indent guides. diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index f7ee0f188..b2f47578b 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -10,7 +10,7 @@ from rich.text import Text from typing_extensions import TypeAlias from ..app import ComposeResult -from ..containers import Vertical +from ..containers import Horizontal, Vertical from ..message import Message from ..reactive import reactive, var from ..widget import Widget @@ -238,37 +238,79 @@ class MarkdownBlockQuote(MarkdownBlock): """ -class MarkdownBulletList(MarkdownBlock): +class MarkdownList(MarkdownBlock): + DEFAULT_CSS = """ + MarkdownList MarkdownList { + margin: 0; + padding-top: 0; + } + """ + + +class MarkdownBulletList(MarkdownList): """A Bullet list Markdown block.""" DEFAULT_CSS = """ MarkdownBulletList { - margin: 0; + margin: 0 0 1 0; padding: 0 0; } - MarkdownBulletList MarkdownBulletList { - margin: 0; - padding-top: 0; + MarkdownBulletList Horizontal { + height: auto; } + + MarkdownBulletList Vertical { + height: auto; + } + + """ + def compose(self) -> ComposeResult: + for block in self._blocks: + print(block) + if isinstance(block, MarkdownListItem): + bullet = MarkdownBullet() + bullet.symbol = block.bullet + yield Horizontal(bullet, Vertical(*block._blocks)) + self._blocks.clear() -class MarkdownOrderedList(MarkdownBlock): + +class MarkdownOrderedList(MarkdownList): """An ordered list Markdown block.""" DEFAULT_CSS = """ MarkdownOrderedList { - margin: 0; - padding: 0 0 1 0; + margin: 0 0 1 0; + padding: 0 0; } - Markdown OrderedList MarkdownOrderedList { - margin: 0; - padding-top: 0; + + + MarkdownOrderedList Horizontal { + height: auto; + } + + MarkdownOrderedList Vertical { + height: auto; } """ + def compose(self) -> ComposeResult: + symbol_size = max( + len(block.bullet) + for block in self._blocks + if isinstance(block, MarkdownListItem) + ) + for block in self._blocks: + if isinstance(block, MarkdownListItem): + bullet = MarkdownBullet() + bullet.symbol = block.bullet.rjust(symbol_size + 1) + yield Horizontal(bullet, Vertical(*block._blocks)) + + self._blocks.clear() + class MarkdownTable(MarkdownBlock): """A Table markdown Block.""" @@ -374,14 +416,6 @@ class MarkdownListItem(MarkdownBlock): self.bullet = bullet super().__init__() - def compose(self) -> ComposeResult: - bullet = MarkdownBullet() - bullet.symbol = self.bullet - yield bullet - yield Vertical(*self._blocks) - - self._blocks.clear() - class MarkdownOrderedListItem(MarkdownListItem): pass @@ -462,7 +496,7 @@ class Markdown(Widget): """ COMPONENT_CLASSES = {"em", "strong", "s", "code_inline"} - BULLETS = ["⏺ ", "■ ", "• ", "‣ "] + BULLETS = ["⏺ ", "▪ ", "‣ ", "• ", "⭑ "] def __init__( self, @@ -560,16 +594,20 @@ class Markdown(Widget): elif token.type == "ordered_list_open": stack.append(MarkdownOrderedList()) elif token.type == "list_item_open": - item_count = sum( - 1 for block in stack if isinstance(block, MarkdownUnorderedListItem) - ) - stack.append( - MarkdownOrderedListItem(f" {token.info}. ") - if token.info - else MarkdownUnorderedListItem( - self.BULLETS[item_count % len(self.BULLETS)] + if token.info: + stack.append(MarkdownOrderedListItem(f"{token.info}. ")) + else: + item_count = sum( + 1 + for block in stack + if isinstance(block, MarkdownUnorderedListItem) ) - ) + stack.append( + MarkdownUnorderedListItem( + self.BULLETS[item_count % len(self.BULLETS)] + ) + ) + elif token.type == "table_open": stack.append(MarkdownTable()) elif token.type == "tbody_open": @@ -599,6 +637,8 @@ class Markdown(Widget): for child in token.children: if child.type == "text": content.append(child.content, style_stack[-1]) + if child.type == "softbreak": + content.append(" ") elif child.type == "code_inline": content.append( child.content, From 956fd8b11448224c00d8e624c5cd9c61452087ff Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sun, 19 Feb 2023 09:46:47 +0000 Subject: [PATCH 099/113] All numbers in continued selectors See #1836. --- src/textual/css/tokenize.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/css/tokenize.py b/src/textual/css/tokenize.py index 2a8677f68..b2cd3d30e 100644 --- a/src/textual/css/tokenize.py +++ b/src/textual/css/tokenize.py @@ -75,7 +75,7 @@ expect_selector_continue = Expect( selector_id=r"\#[a-zA-Z_\-][a-zA-Z0-9_\-]*", selector_class=r"\.[a-zA-Z_\-][a-zA-Z0-9_\-]*", selector_universal=r"\*", - selector=r"[a-zA-Z_\-]+", + selector=IDENTIFIER, combinator_child=">", new_selector=r",", declaration_set_start=r"\{", From b340c27d6ac78eca5e2d5b64d4906a2289ac374c Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sun, 19 Feb 2023 09:49:40 +0000 Subject: [PATCH 100/113] Update the CHANGELOG --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98fdac88e..7727141ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## Unreleased + +### Fixed + +- Numbers in a descendant-combined selector no longer cause an error https://github.com/Textualize/textual/issues/1836 + ## [0.11.1] - 2023-02-17 ### Fixed From 433e371dc9c7db61e080af125589852034f533ed Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 19 Feb 2023 10:03:04 +0000 Subject: [PATCH 101/113] changelog and snapshots --- CHANGELOG.md | 1 + examples/example.md | 13 + src/textual/widgets/_markdown.py | 16 +- .../__snapshots__/test_snapshots.ambr | 264 +++++++++--------- 4 files changed, 157 insertions(+), 137 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 153b24212..973a01433 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Changed - Scrolling by page now adds to current position. +- Markdown lists have been polished: a selection of bullets, better alignment of numbers, style tweaks ### Removed diff --git a/examples/example.md b/examples/example.md index 366500ca6..e79234025 100644 --- a/examples/example.md +++ b/examples/example.md @@ -54,6 +54,19 @@ Inline code is indicated by backticks. e.g. `import this`. - And when it has gone past, I will turn the inner eye to see its path. - Where the fear has gone there will be nothing. Only I will remain. +### Longer list + +1. **Duke Leto I Atreides**, head of House Atreides +2. **Lady Jessica**, Bene Gesserit and concubine of Leto, and mother of Paul and Alia +3. **Paul Atreides**, son of Leto and Jessica +4. **Alia Atreides**, daughter of Leto and Jessica +5. **Gurney Halleck**, troubadour warrior of House Atreides +6. **Thufir Hawat**, Mentat and Master of Assassins of House Atreides +7. **Duncan Idaho**, swordmaster of House Atreides +8. **Dr. Wellington Yueh**, Suk doctor of House Atreides +9. **Leto**, first son of Paul and Chani who dies as a toddler +10. **Esmar Tuek**, a smuggler on Arrakis +11. **Staban Tuek**, son of Esmar ## Fences diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index b2f47578b..e2178ca2d 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -240,6 +240,11 @@ class MarkdownBlockQuote(MarkdownBlock): class MarkdownList(MarkdownBlock): DEFAULT_CSS = """ + + MarkdownList { + width: 1fr; + } + MarkdownList MarkdownList { margin: 0; padding-top: 0; @@ -258,18 +263,17 @@ class MarkdownBulletList(MarkdownList): MarkdownBulletList Horizontal { height: auto; + width: 1fr; } - MarkdownBulletList Vertical { + MarkdownBulletList Vertical { height: auto; + width: 1fr; } - - """ def compose(self) -> ComposeResult: for block in self._blocks: - print(block) if isinstance(block, MarkdownListItem): bullet = MarkdownBullet() bullet.symbol = block.bullet @@ -286,14 +290,14 @@ class MarkdownOrderedList(MarkdownList): padding: 0 0; } - - MarkdownOrderedList Horizontal { height: auto; + width: 1fr; } MarkdownOrderedList Vertical { height: auto; + width: 1fr; } """ diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index f149ea09f..f411820fe 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -13032,139 +13032,140 @@ font-weight: 700; } - .terminal-2159695446-matrix { + .terminal-2166823333-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2159695446-title { + .terminal-2166823333-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2159695446-r1 { fill: #e1e1e1 } - .terminal-2159695446-r2 { fill: #121212 } - .terminal-2159695446-r3 { fill: #c5c8c6 } - .terminal-2159695446-r4 { fill: #0053aa } - .terminal-2159695446-r5 { fill: #dde8f3;font-weight: bold } - .terminal-2159695446-r6 { fill: #939393;font-weight: bold } - .terminal-2159695446-r7 { fill: #24292f } - .terminal-2159695446-r8 { fill: #e2e3e3;font-weight: bold } - .terminal-2159695446-r9 { fill: #e1e1e1;font-style: italic; } - .terminal-2159695446-r10 { fill: #e1e1e1;font-weight: bold } + .terminal-2166823333-r1 { fill: #e1e1e1 } + .terminal-2166823333-r2 { fill: #121212 } + .terminal-2166823333-r3 { fill: #c5c8c6 } + .terminal-2166823333-r4 { fill: #0053aa } + .terminal-2166823333-r5 { fill: #dde8f3;font-weight: bold } + .terminal-2166823333-r6 { fill: #939393;font-weight: bold } + .terminal-2166823333-r7 { fill: #24292f } + .terminal-2166823333-r8 { fill: #e2e3e3;font-weight: bold } + .terminal-2166823333-r9 { fill: #4ebf71;font-weight: bold } + .terminal-2166823333-r10 { fill: #e1e1e1;font-style: italic; } + .terminal-2166823333-r11 { fill: #e1e1e1;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MarkdownExampleApp + MarkdownExampleApp - - - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - Markdown Document - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - This is an example of Textual's Markdown widget. - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - Features - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Markdown syntax and extensions are supported. - - ● Typography emphasisstronginline code etc. - ● Headers - ● Lists (bullet and ordered) - ● Syntax highlighted code blocks - ● Tables! - - - - + + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + Markdown Document + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + This is an example of Textual's Markdown widget. + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + Features + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Markdown syntax and extensions are supported. + + ⏺ Typography emphasisstronginline code etc. + ⏺ Headers + ⏺ Lists (bullet and ordered) + ⏺ Syntax highlighted code blocks + ⏺ Tables! + + + + @@ -13195,144 +13196,145 @@ font-weight: 700; } - .terminal-3241959168-matrix { + .terminal-3185906023-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3241959168-title { + .terminal-3185906023-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3241959168-r1 { fill: #c5c8c6 } - .terminal-3241959168-r2 { fill: #24292f } - .terminal-3241959168-r3 { fill: #121212 } - .terminal-3241959168-r4 { fill: #e1e1e1 } - .terminal-3241959168-r5 { fill: #e2e3e3 } - .terminal-3241959168-r6 { fill: #96989b } - .terminal-3241959168-r7 { fill: #0053aa } - .terminal-3241959168-r8 { fill: #008139 } - .terminal-3241959168-r9 { fill: #dde8f3;font-weight: bold } - .terminal-3241959168-r10 { fill: #939393;font-weight: bold } - .terminal-3241959168-r11 { fill: #e2e3e3;font-weight: bold } - .terminal-3241959168-r12 { fill: #14191f } - .terminal-3241959168-r13 { fill: #e1e1e1;font-style: italic; } - .terminal-3241959168-r14 { fill: #e1e1e1;font-weight: bold } + .terminal-3185906023-r1 { fill: #c5c8c6 } + .terminal-3185906023-r2 { fill: #24292f } + .terminal-3185906023-r3 { fill: #121212 } + .terminal-3185906023-r4 { fill: #e1e1e1 } + .terminal-3185906023-r5 { fill: #e2e3e3 } + .terminal-3185906023-r6 { fill: #96989b } + .terminal-3185906023-r7 { fill: #0053aa } + .terminal-3185906023-r8 { fill: #008139 } + .terminal-3185906023-r9 { fill: #dde8f3;font-weight: bold } + .terminal-3185906023-r10 { fill: #939393;font-weight: bold } + .terminal-3185906023-r11 { fill: #e2e3e3;font-weight: bold } + .terminal-3185906023-r12 { fill: #14191f } + .terminal-3185906023-r13 { fill: #4ebf71;font-weight: bold } + .terminal-3185906023-r14 { fill: #e1e1e1;font-style: italic; } + .terminal-3185906023-r15 { fill: #e1e1e1;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - MarkdownExampleApp + MarkdownExampleApp - - - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▼  Markdown Viewer - ├──  FeaturesMarkdown Viewer - ├──  Tables - └──  Code Blocks▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - This is an example of Textual's MarkdownViewer - widget. - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - Features▅▅ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Markdown syntax and extensions are supported. - - ● Typography emphasisstronginline code - etc. - ● Headers - ● Lists (bullet and ordered) - ● Syntax highlighted code blocks - ● Tables! - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - Tables + + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▼  Markdown Viewer + ├──  FeaturesMarkdown Viewer + ├──  Tables + └──  Code Blocks▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + This is an example of Textual's MarkdownViewer + widget. + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + Features▇▇ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Markdown syntax and extensions are supported. + + ⏺ Typography emphasisstronginline code + etc. + ⏺ Headers + ⏺ Lists (bullet and ordered) + ⏺ Syntax highlighted code blocks + ⏺ Tables! + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + From d18c794e69c27c48211d761a0641c80844b55b9b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 19 Feb 2023 22:24:28 +0000 Subject: [PATCH 102/113] call compute on demand --- src/textual/reactive.py | 18 ++++++++++-------- tests/test_reactive.py | 27 +++++++++++++++++++++++++-- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 4c7bd1d98..794d723b0 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -143,23 +143,25 @@ class Reactive(Generic[ReactiveType]): self.name = name # The internal name where the attribute's value is stored self.internal_name = f"_reactive_{name}" + self.compute_name = f"compute_{name}" default = self._default setattr(owner, f"_default_{name}", default) def __get__(self, obj: Reactable, obj_type: type[object]) -> ReactiveType: - _rich_traceback_omit = True - - self._initialize_reactive(obj, self.name) + internal_name = self.internal_name + if not hasattr(obj, internal_name): + self._initialize_reactive(obj, self.name) value: ReactiveType - compute_method = getattr(self, f"compute_{self.name}", None) + compute_method = getattr(obj, self.compute_name, None) if compute_method is not None: - old_value = getattr(obj, self.internal_name) - value = getattr(obj, f"compute_{self.name}")() - setattr(obj, self.internal_name, value) + old_value = getattr(obj, internal_name) + _rich_traceback_omit = True + value = compute_method() + setattr(obj, internal_name, value) self._check_watchers(obj, self.name, old_value) else: - value = getattr(obj, self.internal_name) + value = getattr(obj, internal_name) return value def __set__(self, obj: Reactable, value: ReactiveType) -> None: diff --git a/tests/test_reactive.py b/tests/test_reactive.py index da8be66ae..2a86bdba2 100644 --- a/tests/test_reactive.py +++ b/tests/test_reactive.py @@ -328,6 +328,27 @@ async def test_reactive_inheritance(): assert tertiary.baz == "baz" +async def test_compute(): + """Check compute method is called.""" + + class ComputeApp(App): + count = var(0) + count_double = var(0) + + def compute_count_double(self) -> int: + return self.count * 2 + + app = ComputeApp() + + async with app.run_test(): + assert app.count_double == 0 + app.count = 1 + assert app.count_double == 2 + assert app.count_double == 2 + app.count = 2 + assert app.count_double == 4 + + async def test_watch_compute(): """Check that watching a computed attribute works.""" @@ -347,7 +368,9 @@ async def test_watch_compute(): app = Calculator() - async with app.run_test() as pilot: + # Referencing the value calls compute + # Setting any reactive values calls compute + async with app.run_test(): assert app.show_ac is True app.value = "1" assert app.show_ac is False @@ -356,4 +379,4 @@ async def test_watch_compute(): app.numbers = "123" assert app.show_ac is False - assert watch_called == [True, False, True, False] + assert watch_called == [True, True, False, False, True, True, False, False] From 52e522b2e8cfe86aa0cef9f27bba0a39aa6812e7 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 19 Feb 2023 22:26:07 +0000 Subject: [PATCH 103/113] test --- tests/test_reactive.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_reactive.py b/tests/test_reactive.py index 2a86bdba2..9c824645e 100644 --- a/tests/test_reactive.py +++ b/tests/test_reactive.py @@ -335,8 +335,12 @@ async def test_compute(): count = var(0) count_double = var(0) + def __init__(self) -> None: + self.start = 0 + super().__init__() + def compute_count_double(self) -> int: - return self.count * 2 + return self.start + self.count * 2 app = ComputeApp() @@ -347,6 +351,8 @@ async def test_compute(): assert app.count_double == 2 app.count = 2 assert app.count_double == 4 + app.start = 10 + assert app.count_double == 14 async def test_watch_compute(): From 9b9c878dad9e0a1f381f5f0ee0dd96b9e720bfec Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 19 Feb 2023 22:28:23 +0000 Subject: [PATCH 104/113] micro optimization --- src/textual/reactive.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 794d723b0..ba4289abb 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -160,9 +160,9 @@ class Reactive(Generic[ReactiveType]): value = compute_method() setattr(obj, internal_name, value) self._check_watchers(obj, self.name, old_value) + return value else: - value = getattr(obj, internal_name) - return value + return getattr(obj, internal_name) def __set__(self, obj: Reactable, value: ReactiveType) -> None: _rich_traceback_omit = True From ebfced603ba3d25a48fb6a97eb7da6c400c1b0f1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 20 Feb 2023 08:20:43 +0000 Subject: [PATCH 105/113] micro op --- src/textual/reactive.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index ba4289abb..0553d076c 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -152,12 +152,11 @@ class Reactive(Generic[ReactiveType]): if not hasattr(obj, internal_name): self._initialize_reactive(obj, self.name) - value: ReactiveType - compute_method = getattr(obj, self.compute_name, None) - if compute_method is not None: + if hasattr(obj, self.compute_name): + value: ReactiveType old_value = getattr(obj, internal_name) _rich_traceback_omit = True - value = compute_method() + value = getattr(obj, self.compute_name)() setattr(obj, internal_name, value) self._check_watchers(obj, self.name, old_value) return value From f785a5176993fb39f47d060afd36128a6f7d7689 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 20 Feb 2023 10:08:02 +0000 Subject: [PATCH 106/113] Add combined type CSS parsing tests --- tests/css/test_parse.py | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/tests/css/test_parse.py b/tests/css/test_parse.py index 727d45af4..00cd8d52d 100644 --- a/tests/css/test_parse.py +++ b/tests/css/test_parse.py @@ -8,7 +8,7 @@ from textual.css.parse import substitute_references from textual.css.scalar import Scalar, Unit from textual.css.stylesheet import Stylesheet, StylesheetParseError from textual.css.tokenize import tokenize -from textual.css.tokenizer import ReferencedBy, Token +from textual.css.tokenizer import ReferencedBy, Token, TokenError from textual.css.transition import Transition from textual.geometry import Spacing from textual.layouts.vertical import VerticalLayout @@ -1189,3 +1189,40 @@ class TestParseTextAlign: stylesheet = Stylesheet() stylesheet.add_source(css) assert stylesheet.rules[0].styles.text_align == "start" + + +class TestTypeNames: + def test_type_no_number(self): + stylesheet = Stylesheet() + stylesheet.add_source("TestType {}") + assert len(stylesheet.rules) == 1 + + def test_type_with_number(self): + stylesheet = Stylesheet() + stylesheet.add_source("TestType1 {}") + assert len(stylesheet.rules) == 1 + + def test_type_starts_with_number(self): + stylesheet = Stylesheet() + stylesheet.add_source("1TestType {}") + with pytest.raises(TokenError): + stylesheet.parse() + + def test_combined_type_no_number(self): + for seperator in " >,": + stylesheet = Stylesheet() + stylesheet.add_source(f"StartType {seperator} TestType {{}}") + assert len(stylesheet.rules) == 1 + + def test_combined_type_with_number(self): + for seperator in " >,": + stylesheet = Stylesheet() + stylesheet.add_source(f"StartType {seperator} TestType1 {{}}") + assert len(stylesheet.rules) == 1 + + def test_combined_type_starts_with_number(self): + for seperator in " >,": + stylesheet = Stylesheet() + stylesheet.add_source(f"StartType {seperator} 1TestType {{}}") + with pytest.raises(TokenError): + stylesheet.parse() From 7d9ef17b10fb53843111111d4000113d95732d95 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 20 Feb 2023 20:51:15 +0000 Subject: [PATCH 107/113] changelog --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 973a01433..0756fedf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added -- Added `App.batch_update` -- Added horizontal rule to Markdown +- Added `App.batch_update` https://github.com/Textualize/textual/pull/1832 +- Added horizontal rule to Markdown https://github.com/Textualize/textual/pull/1832 ### Changed - Scrolling by page now adds to current position. -- Markdown lists have been polished: a selection of bullets, better alignment of numbers, style tweaks +- Markdown lists have been polished: a selection of bullets, better alignment of numbers, style tweaks https://github.com/Textualize/textual/pull/1832 ### Removed From 8ec13c3aba85866505abdaf15a4eb55ec46f1e5a Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 20 Feb 2023 21:24:48 +0000 Subject: [PATCH 108/113] Fix variable name typo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- tests/css/test_parse.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/css/test_parse.py b/tests/css/test_parse.py index 00cd8d52d..e7f5f008b 100644 --- a/tests/css/test_parse.py +++ b/tests/css/test_parse.py @@ -1209,9 +1209,9 @@ class TestTypeNames: stylesheet.parse() def test_combined_type_no_number(self): - for seperator in " >,": + for separator in " >,": stylesheet = Stylesheet() - stylesheet.add_source(f"StartType {seperator} TestType {{}}") + stylesheet.add_source(f"StartType {separator} TestType {{}}") assert len(stylesheet.rules) == 1 def test_combined_type_with_number(self): From e8e00b1920e8618ea2dbaf8a998c55a8072e2985 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 20 Feb 2023 21:25:03 +0000 Subject: [PATCH 109/113] Fix variable name typo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- tests/css/test_parse.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/css/test_parse.py b/tests/css/test_parse.py index e7f5f008b..27c2f6c60 100644 --- a/tests/css/test_parse.py +++ b/tests/css/test_parse.py @@ -1215,9 +1215,9 @@ class TestTypeNames: assert len(stylesheet.rules) == 1 def test_combined_type_with_number(self): - for seperator in " >,": + for separator in " >,": stylesheet = Stylesheet() - stylesheet.add_source(f"StartType {seperator} TestType1 {{}}") + stylesheet.add_source(f"StartType {separator} TestType1 {{}}") assert len(stylesheet.rules) == 1 def test_combined_type_starts_with_number(self): From e666ee7286f404236037adc53902160c85bb169c Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 20 Feb 2023 21:25:12 +0000 Subject: [PATCH 110/113] Fix variable name typo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- tests/css/test_parse.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/css/test_parse.py b/tests/css/test_parse.py index 27c2f6c60..49d3368d6 100644 --- a/tests/css/test_parse.py +++ b/tests/css/test_parse.py @@ -1221,8 +1221,8 @@ class TestTypeNames: assert len(stylesheet.rules) == 1 def test_combined_type_starts_with_number(self): - for seperator in " >,": + for separator in " >,": stylesheet = Stylesheet() - stylesheet.add_source(f"StartType {seperator} 1TestType {{}}") + stylesheet.add_source(f"StartType {separator} 1TestType {{}}") with pytest.raises(TokenError): stylesheet.parse() From e4b38f23413b48ce9c06d61c13ef059176adc511 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 21 Feb 2023 09:58:05 +0000 Subject: [PATCH 111/113] Rename --- src/textual/_spatial_map.py | 6 +++--- tests/test_spatial_map.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/textual/_spatial_map.py b/src/textual/_spatial_map.py index d31a7ee90..93e8c4004 100644 --- a/src/textual/_spatial_map.py +++ b/src/textual/_spatial_map.py @@ -36,7 +36,7 @@ class SpatialMap(Generic[ValueType]): self._map: defaultdict[GridCoordinate, list[ValueType]] = defaultdict(list) self._fixed: list[ValueType] = [] - def _region_to_grid_coordinate(self, region: Region) -> Iterable[GridCoordinate]: + def _region_to_grid_coordinates(self, region: Region) -> Iterable[GridCoordinate]: """Get the grid squares under a region. Args: @@ -70,7 +70,7 @@ class SpatialMap(Generic[ValueType]): """ append_fixed = self._fixed.append get_grid_list = self._map.__getitem__ - _region_to_grid = self._region_to_grid_coordinate + _region_to_grid = self._region_to_grid_coordinates total_region = self.total_region for region, fixed, value in regions_and_values: total_region = total_region.union(region) @@ -95,7 +95,7 @@ class SpatialMap(Generic[ValueType]): results: list[ValueType] = self._fixed.copy() add_results = results.extend get_grid_values = self._map.get - for grid_coordinate in self._region_to_grid_coordinate(region): + for grid_coordinate in self._region_to_grid_coordinates(region): grid_values = get_grid_values(grid_coordinate) if grid_values is not None: add_results(grid_values) diff --git a/tests/test_spatial_map.py b/tests/test_spatial_map.py index a8b0fb0d6..413ca4cad 100644 --- a/tests/test_spatial_map.py +++ b/tests/test_spatial_map.py @@ -36,7 +36,7 @@ from textual.geometry import Region def test_region_to_grid(region, grid): spatial_map = SpatialMap(10, 10) - assert list(spatial_map._region_to_grid_coordinate(region)) == grid + assert list(spatial_map._region_to_grid_coordinates(region)) == grid def test_get_values_in_region() -> None: From 6a665d088a8f12046311fa3308b452ec08fa216e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 21 Feb 2023 09:58:44 +0000 Subject: [PATCH 112/113] Remove profile --- src/textual/screen.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/screen.py b/src/textual/screen.py index 0377f8034..58489c65a 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -9,7 +9,6 @@ from rich.style import Style from . import errors, events, messages from ._callback import invoke from ._compositor import Compositor, MapGeometry -from ._profile import timer from ._types import CallbackType from .css.match import match from .css.parse import parse_selectors From 53e2ea77c33ad0140cb208310c04a192f08c3e97 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 21 Feb 2023 10:12:04 +0000 Subject: [PATCH 113/113] docstrings --- src/textual/_compositor.py | 3 +++ src/textual/_layout.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index e9965db8b..245eb3f34 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -308,6 +308,9 @@ class Compositor: parent: The root widget. size: Size of the area to be filled. + Returns: + Set of widgets that were exposed by the scroll. + """ self._cuts = None self._layers = None diff --git a/src/textual/_layout.py b/src/textual/_layout.py index f65541bae..f7ab16312 100644 --- a/src/textual/_layout.py +++ b/src/textual/_layout.py @@ -18,8 +18,11 @@ ArrangeResult: TypeAlias = "tuple[list[WidgetPlacement], set[Widget]]" @dataclass class DockArrangeResult: placements: list[WidgetPlacement] + """A `WidgetPlacement` for every widget to describe it's location on screen.""" widgets: set[Widget] + """A set of widgets in the arrangement.""" spacing: Spacing + """Shared spacing around the widgets.""" _spatial_map: SpatialMap[WidgetPlacement] | None = None