From 3c7391408616c466424249547d812c032cc3574b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 13 Nov 2022 13:22:56 +0000 Subject: [PATCH 01/49] fix deadlock on tests --- src/textual/_animator.py | 9 ++++++--- src/textual/app.py | 21 ++++++++++++++------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 8270456ac..25c7e6db8 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -189,9 +189,12 @@ class Animator: async def stop(self) -> None: """Stop the animator task.""" try: - await self._timer.stop() - except asyncio.CancelledError: - pass + try: + await self._timer.stop() + except asyncio.CancelledError: + pass + finally: + self._idle_event.set() def bind(self, obj: object) -> BoundAnimator: """Bind the animator to a given objects.""" diff --git a/src/textual/app.py b/src/textual/app.py index 9f1640af4..4328854ba 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -803,9 +803,11 @@ class App(Generic[ReturnType], DOMNode): terminal_size=size, ) finally: - if auto_pilot_task is not None: - await auto_pilot_task - await app._shutdown() + try: + if auto_pilot_task is not None: + await auto_pilot_task + finally: + await app._shutdown() return app.return_value @@ -1291,6 +1293,10 @@ class App(Generic[ReturnType], DOMNode): await self.animator.start() + except Exception: + await self.animator.stop() + raise + finally: await self._ready() await invoke_ready_callback() @@ -1303,10 +1309,11 @@ class App(Generic[ReturnType], DOMNode): pass finally: self._running = False - for timer in list(self._timers): - await timer.stop() - - await self.animator.stop() + try: + await self.animator.stop() + finally: + for timer in list(self._timers): + await timer.stop() self._running = True try: From 48e5f5e0267d1019514c3c46263fd97d0cf6c27d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 14 Nov 2022 12:22:24 +0000 Subject: [PATCH 02/49] Add Widget.move_child Adds a method to Widget that allows moving a child of that widget within its list of children. Options are to move before or after a specific location, or a sibling widget. Seeks to implement #1121. --- CHANGELOG.md | 1 + src/textual/widget.py | 73 ++++++++++++++++++++++ tests/test_widget_child_moving.py | 100 ++++++++++++++++++++++++++++++ 3 files changed, 174 insertions(+) create mode 100644 tests/test_widget_child_moving.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 05165a611..a628871d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). the return value of `DOMQuery.remove`, which uses to return `self`. https://github.com/Textualize/textual/issues/1094 - Added Pilot.wait_for_animation +- Added `Widget.move_child` https://github.com/Textualize/textual/issues/1121 ### Changed diff --git a/src/textual/widget.py b/src/textual/widget.py index 9029eedcb..0f190dfff 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -480,6 +480,79 @@ class Widget(DOMNode): self.app._register(parent, *widgets, before=before, after=after) ) + def move_child( + self, + child: int | Widget, + before: int | Widget | None = None, + after: int | Widget | None = None, + ) -> None: + """Move a child widget within its parent's list of children. + + Args: + child (int | Widget): The child widget to move. + before: (int | Widget, optional): Optional location to move before. + after: (int | Widget, optional): Optional location to move after. + + Raises: + WidgetError: If there is a problem with the child or target. + """ + + # One or the other of before or after are required. Can't do + # neither, can't do both. + if before is None and after is None: + raise WidgetError("One of `before` or `after` is required.") + elif before is not None and after is not None: + raise WidgetError("Only one of `before`or `after` can be handled.") + + # Turn the child to move into a reference to the widget, doing some + # checks as we do so. + if isinstance(child, int): + try: + child = self.children[child] + except IndexError: + raise WidgetError( + f"An index of {child} for the child to move is out of bounds" + ) from None + else: + # We got an actual widget, so let's be sure it really is one of + # our children. + try: + _ = self.children.index(child) + except ValueError: + raise WidgetError(f"{child!r} is not a child of {self!r}") from None + + # Next, no matter if we're moving before or after, we just want to + # be sure that the target makes sense at all. So let's concentrate + # on that for a moment. + target = before if after is None else after + if isinstance(target, int): + try: + target = self.children[target] + except IndexError: + raise WidgetError( + f"An index of {target} for the target to move towards is out of bounds" + ) from None + elif isinstance(target, Widget): + # If we got given a widget from the off, let's be sure it's + # actually one of our children. + try: + _ = self.children.index(target) + except ValueError: + raise WidgetError(f"{target!r} is not a child of {self!r}") from None + + # At this point we should know what we're moving, and it should be a + # child; where we're moving it to, which should be within the child + # list; and how we're supposed to move it. All that's left is doing + # the right thing. + self.children._remove(child) + if before is not None: + self.children._insert(self.children.index(target), child) + else: + self.children._insert(self.children.index(target) + 1, child) + + # Request a refresh. + self.refresh(layout=True) + def compose(self) -> ComposeResult: """Called by Textual to create child widgets. diff --git a/tests/test_widget_child_moving.py b/tests/test_widget_child_moving.py new file mode 100644 index 000000000..43aa849eb --- /dev/null +++ b/tests/test_widget_child_moving.py @@ -0,0 +1,100 @@ +import pytest + +from textual.app import App +from textual.widget import Widget, WidgetError + +async def test_widget_move_child() -> None: + """Test moving a widget in a child list.""" + + # Test calling move_child with no direction. + async with App().run_test() as pilot: + child = Widget(Widget()) + await pilot.app.mount(child) + with pytest.raises(WidgetError): + pilot.app.screen.move_child(child) + + # Test calling move_child with more than one direction. + async with App().run_test() as pilot: + child = Widget(Widget()) + await pilot.app.mount(child) + with pytest.raises(WidgetError): + pilot.app.screen.move_child(child, before=1, after=2) + + # Test attempting to move a child that isn't ours. + async with App().run_test() as pilot: + child = Widget(Widget()) + await pilot.app.mount(child) + with pytest.raises(WidgetError): + pilot.app.screen.move_child(Widget(), before=child) + + # Test attempting to move relative to a widget that isn't a child. + async with App().run_test() as pilot: + child = Widget(Widget()) + await pilot.app.mount(child) + with pytest.raises(WidgetError): + pilot.app.screen.move_child(child, before=Widget()) + + # Make a background set of widgets. + widgets = [Widget(id=f"widget-{n}") for n in range( 10 )] + + # Test attempting to move past the end of the child list. + async with App().run_test() as pilot: + container = Widget(*widgets) + await pilot.app.mount(container) + with pytest.raises(WidgetError): + container.move_child(widgets[0], before=len(widgets)+10) + + # Test attempting to move before the end of the child list. + async with App().run_test() as pilot: + container = Widget(*widgets) + await pilot.app.mount(container) + with pytest.raises(WidgetError): + container.move_child(widgets[0], before=-(len(widgets)+10)) + + # Test the different permutations of moving one widget before another. + perms = ( + ( 1, 0 ), + ( widgets[1], 0 ), + ( 1, widgets[ 0 ] ), + ( widgets[ 1 ], widgets[ 0 ]) + ) + for child, target in perms: + async with App().run_test() as pilot: + container = Widget(*widgets) + await pilot.app.mount(container) + container.move_child(child, before=target) + assert container.children[0].id == "widget-1" + assert container.children[1].id == "widget-0" + assert container.children[2].id == "widget-2" + + # Test the different permutations of moving one widget after another. + perms = ( + ( 0, 1 ), + ( widgets[0], 1 ), + ( 0, widgets[ 1 ] ), + ( widgets[ 0 ], widgets[ 1 ]) + ) + for child, target in perms: + async with App().run_test() as pilot: + container = Widget(*widgets) + await pilot.app.mount(container) + container.move_child(child, after=target) + assert container.children[0].id == "widget-1" + assert container.children[1].id == "widget-0" + assert container.children[2].id == "widget-2" + + # Test moving after a child after the last child. + async with App().run_test() as pilot: + container = Widget(*widgets) + await pilot.app.mount(container) + container.move_child(widgets[0], after=widgets[-1]) + assert container.children[0].id == "widget-1" + assert container.children[-1].id == "widget-0" + + # Test moving after a child after the last child's numeric position. + async with App().run_test() as pilot: + container = Widget(*widgets) + await pilot.app.mount(container) + container.move_child(widgets[0], after=widgets[9]) + assert container.children[0].id == "widget-1" + assert container.children[-1].id == "widget-0" From 58ee841dbd1f3b811342b09c936dca1eee9f0fc6 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 14 Nov 2022 16:41:18 +0000 Subject: [PATCH 03/49] DRY the conversion of a child index to a child Widget --- src/textual/widget.py | 54 ++++++++++++++++--------------------------- 1 file changed, 20 insertions(+), 34 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index 0f190dfff..f9e6dbad6 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -504,41 +504,27 @@ class Widget(DOMNode): elif before is not None and after is not None: raise WidgetError("Only one of `before`or `after` can be handled.") - # Turn the child to move into a reference to the widget, doing some - # checks as we do so. - if isinstance(child, int): - try: - child = self.children[child] - except IndexError: - raise WidgetError( - f"An index of {child} for the child to move is out of bounds" - ) from None - else: - # We got an actual widget, so let's be sure it really is one of - # our children. - try: - _ = self.children.index(child) - except ValueError: - raise WidgetError(f"{child!r} is not a child of {self!r}") from None + def _to_widget(child: int | Widget, called: str) -> Widget: + """Ensure a given child reference is a Widget.""" + if isinstance(child, int): + try: + child = self.children[child] + except IndexError: + raise WidgetError( + f"An index of {child} for the child to {called} is out of bounds" + ) from None + else: + # We got an actual widget, so let's be sure it really is one of + # our children. + try: + _ = self.children.index(child) + except ValueError: + raise WidgetError(f"{child!r} is not a child of {self!r}") from None + return child - # Next, no matter if we're moving before or after, we just want to - # be sure that the target makes sense at all. So let's concentrate - # on that for a moment. - target = before if after is None else after - if isinstance(target, int): - try: - target = self.children[target] - except IndexError: - raise WidgetError( - f"An index of {target} for the target to move towards is out of bounds" - ) from None - elif isinstance(target, Widget): - # If we got given a widget from the off, let's be sure it's - # actually one of our children. - try: - _ = self.children.index(target) - except ValueError: - raise WidgetError(f"{target!r} is not a child of {self!r}") from None + # Ensure the child and target are widgets. + child = _to_widget(child, "move") + target = _to_widget(before if after is None else after, "move towards") # At this point we should know what we're moving, and it should be a # child; where we're moving it to, which should be within the child From 54bf7a9fd3e6933722bdb9135f884a4b2744804f Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 14 Nov 2022 16:56:37 +0000 Subject: [PATCH 04/49] Add a docstring note about before/after being mutually exclusive --- src/textual/widget.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/textual/widget.py b/src/textual/widget.py index f9e6dbad6..2de261227 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -495,6 +495,10 @@ class Widget(DOMNode): Raises: WidgetError: If there is a problem with the child or target. + + Note: + Only one of ``before`` or ``after`` can be provided. If neither + or both are provided a ``WidgetError`` will be raised. """ # One or the other of before or after are required. Can't do From 912980d61581523761eb380bd0ca5101d9fc6eb5 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 15 Nov 2022 09:56:48 +0000 Subject: [PATCH 05/49] Correct a typo in the StylesCache docstring --- src/textual/_styles_cache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/_styles_cache.py b/src/textual/_styles_cache.py index 3cc5e7512..8fa39e047 100644 --- a/src/textual/_styles_cache.py +++ b/src/textual/_styles_cache.py @@ -53,7 +53,7 @@ def style_links( class StylesCache: - """Responsible for rendering CSS Styles and keeping a cached of rendered lines. + """Responsible for rendering CSS Styles and keeping a cache of rendered lines. The render method applies border, outline, and padding set in the Styles object to widget content. From af1168b4ee89ed461e2bd510de209f55e3f61ed4 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 15 Nov 2022 11:59:01 +0000 Subject: [PATCH 06/49] Don't take transparent to imply not visible This seeks to address #1175, where a containing widget with a transparent background, but with a border, won't show the border. It seems that at the heart of the Compositor it's taking `is_transparent` (of the widget) as one of the indicators that mean that it won't be visible. This wouldn't normally be the case, in that a widget could be transparent (in the background) but could have visible content. Note that some snapshot tests failed with this change, but no material difference was found in those failing snapshot tests. --- src/textual/_compositor.py | 6 +- .../__snapshots__/test_snapshots.ambr | 514 +++++++++--------- 2 files changed, 258 insertions(+), 262 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 8fead2165..ab9d69eca 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -633,11 +633,7 @@ class Compositor: def is_visible(widget: Widget) -> bool: """Return True if the widget is (literally) visible by examining various properties which affect whether it can be seen or not.""" - return ( - widget.visible - and not widget.is_transparent - and widget.styles.opacity > 0 - ) + return widget.visible and widget.styles.opacity > 0 _Region = Region diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 51ad5531c..b10be936d 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -21,162 +21,162 @@ font-weight: 700; } - .terminal-1900837103-matrix { + .terminal-481343241-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1900837103-title { + .terminal-481343241-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1900837103-r1 { fill: #e1e1e1 } - .terminal-1900837103-r2 { fill: #c5c8c6 } - .terminal-1900837103-r3 { fill: #e1e1e1;font-weight: bold } - .terminal-1900837103-r4 { fill: #454a50 } - .terminal-1900837103-r5 { fill: #292b2e } - .terminal-1900837103-r6 { fill: #24292f;font-weight: bold } - .terminal-1900837103-r7 { fill: #555657;font-weight: bold } - .terminal-1900837103-r8 { fill: #000000 } - .terminal-1900837103-r9 { fill: #161617 } - .terminal-1900837103-r10 { fill: #507bb3 } - .terminal-1900837103-r11 { fill: #283c52 } - .terminal-1900837103-r12 { fill: #dde6ed;font-weight: bold } - .terminal-1900837103-r13 { fill: #4f5a62;font-weight: bold } - .terminal-1900837103-r14 { fill: #001541 } - .terminal-1900837103-r15 { fill: #122032 } - .terminal-1900837103-r16 { fill: #7ae998 } - .terminal-1900837103-r17 { fill: #3d6a4a } - .terminal-1900837103-r18 { fill: #0a180e;font-weight: bold } - .terminal-1900837103-r19 { fill: #1e2f23;font-weight: bold } - .terminal-1900837103-r20 { fill: #008139 } - .terminal-1900837103-r21 { fill: #1b4c2f } - .terminal-1900837103-r22 { fill: #ffcf56 } - .terminal-1900837103-r23 { fill: #775f2f } - .terminal-1900837103-r24 { fill: #211505;font-weight: bold } - .terminal-1900837103-r25 { fill: #392b18;font-weight: bold } - .terminal-1900837103-r26 { fill: #b86b00 } - .terminal-1900837103-r27 { fill: #644316 } - .terminal-1900837103-r28 { fill: #e76580 } - .terminal-1900837103-r29 { fill: #683540 } - .terminal-1900837103-r30 { fill: #f5e5e9;font-weight: bold } - .terminal-1900837103-r31 { fill: #6c595e;font-weight: bold } - .terminal-1900837103-r32 { fill: #780028 } - .terminal-1900837103-r33 { fill: #491928 } + .terminal-481343241-r1 { fill: #e1e1e1 } + .terminal-481343241-r2 { fill: #c5c8c6 } + .terminal-481343241-r3 { fill: #e1e1e1;font-weight: bold } + .terminal-481343241-r4 { fill: #454a50 } + .terminal-481343241-r5 { fill: #292b2e } + .terminal-481343241-r6 { fill: #24292f;font-weight: bold } + .terminal-481343241-r7 { fill: #555657;font-weight: bold } + .terminal-481343241-r8 { fill: #000000 } + .terminal-481343241-r9 { fill: #161617 } + .terminal-481343241-r10 { fill: #507bb3 } + .terminal-481343241-r11 { fill: #283c52 } + .terminal-481343241-r12 { fill: #dde6ed;font-weight: bold } + .terminal-481343241-r13 { fill: #4f5a62;font-weight: bold } + .terminal-481343241-r14 { fill: #001541 } + .terminal-481343241-r15 { fill: #122032 } + .terminal-481343241-r16 { fill: #7ae998 } + .terminal-481343241-r17 { fill: #3d6a4a } + .terminal-481343241-r18 { fill: #0a180e;font-weight: bold } + .terminal-481343241-r19 { fill: #1e2f23;font-weight: bold } + .terminal-481343241-r20 { fill: #008139 } + .terminal-481343241-r21 { fill: #1b4c2f } + .terminal-481343241-r22 { fill: #ffcf56 } + .terminal-481343241-r23 { fill: #775f2f } + .terminal-481343241-r24 { fill: #211505;font-weight: bold } + .terminal-481343241-r25 { fill: #392b18;font-weight: bold } + .terminal-481343241-r26 { fill: #b86b00 } + .terminal-481343241-r27 { fill: #644316 } + .terminal-481343241-r28 { fill: #e76580 } + .terminal-481343241-r29 { fill: #683540 } + .terminal-481343241-r30 { fill: #f5e5e9;font-weight: bold } + .terminal-481343241-r31 { fill: #6c595e;font-weight: bold } + .terminal-481343241-r32 { fill: #780028 } + .terminal-481343241-r33 { fill: #491928 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ButtonsApp + ButtonsApp - - - - - Standard ButtonsDisabled Buttons - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Default  Default  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Primary!  Primary!  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Success!  Success!  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Warning!  Warning!  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Error!  Error!  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - + + + + + Standard ButtonsDisabled Buttons + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +  Default  Default  + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +  Primary!  Primary!  + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +  Success!  Success!  + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +  Warning!  Warning!  + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +  Error!  Error!  + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + @@ -2727,136 +2727,136 @@ font-weight: 700; } - .terminal-4117108672-matrix { + .terminal-1725099926-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4117108672-title { + .terminal-1725099926-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-4117108672-r1 { fill: #000000 } - .terminal-4117108672-r2 { fill: #c5c8c6 } - .terminal-4117108672-r3 { fill: #008000 } - .terminal-4117108672-r4 { fill: #e5f0e5 } - .terminal-4117108672-r5 { fill: #036a03 } - .terminal-4117108672-r6 { fill: #14191f } + .terminal-1725099926-r1 { fill: #c5c8c6 } + .terminal-1725099926-r2 { fill: #000000 } + .terminal-1725099926-r3 { fill: #008000 } + .terminal-1725099926-r4 { fill: #e5f0e5 } + .terminal-1725099926-r5 { fill: #036a03 } + .terminal-1725099926-r6 { fill: #14191f } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - OverflowApp + OverflowApp - - - - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - I must not fear.I must not fear. - Fear is the mind-killer.Fear is the mind-killer. - Fear is the little-death that Fear is the little-death that  - brings total obliteration.brings total obliteration. - I will face my fear.I will face my fear. - I will permit it to pass over meI will permit it to pass over me  - and through me.and through me. - And when it has gone past, I And when it has gone past, I will  - will turn the inner eye to see turn the inner eye to see its  - its path.▁▁path. - Where the fear has gone there Where the fear has gone there will - will be nothing. Only I will be nothing. Only I will remain. - remain.▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁I must not fear. - I must not fear.Fear is the mind-killer. - Fear is the mind-killer.Fear is the little-death that  - Fear is the little-death that brings total obliteration. - brings total obliteration.I will face my fear. - I will face my fear.I will permit it to pass over me  - I will permit it to pass over meand through me. + + + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + I must not fear.I must not fear. + Fear is the mind-killer.Fear is the mind-killer. + Fear is the little-death that Fear is the little-death that  + brings total obliteration.brings total obliteration. + I will face my fear.I will face my fear. + I will permit it to pass over meI will permit it to pass over me  + and through me.and through me. + And when it has gone past, I And when it has gone past, I will  + will turn the inner eye to see turn the inner eye to see its  + its path.▁▁path. + Where the fear has gone there Where the fear has gone there will + will be nothing. Only I will be nothing. Only I will remain. + remain.▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁I must not fear. + I must not fear.Fear is the mind-killer. + Fear is the mind-killer.Fear is the little-death that  + Fear is the little-death that brings total obliteration. + brings total obliteration.I will face my fear. + I will face my fear.I will permit it to pass over me  + I will permit it to pass over meand through me. @@ -3197,132 +3197,132 @@ font-weight: 700; } - .terminal-1467518286-matrix { + .terminal-849091649-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1467518286-title { + .terminal-849091649-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1467518286-r1 { fill: #3333ff } - .terminal-1467518286-r2 { fill: #c5c8c6 } - .terminal-1467518286-r3 { fill: #14191f } + .terminal-849091649-r1 { fill: #c5c8c6 } + .terminal-849091649-r2 { fill: #3333ff } + .terminal-849091649-r3 { fill: #14191f } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ScrollbarApp + ScrollbarApp - + - - - - 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. - 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. - 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. + + + + 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. + 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. + 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. @@ -3353,133 +3353,133 @@ font-weight: 700; } - .terminal-2712574715-matrix { + .terminal-2331479198-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2712574715-title { + .terminal-2331479198-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2712574715-r1 { fill: #d2d2d2 } - .terminal-2712574715-r2 { fill: #c5c8c6 } - .terminal-2712574715-r3 { fill: #bbbbbb } - .terminal-2712574715-r4 { fill: #800080 } + .terminal-2331479198-r1 { fill: #c5c8c6 } + .terminal-2331479198-r2 { fill: #d2d2d2 } + .terminal-2331479198-r3 { fill: #bbbbbb } + .terminal-2331479198-r4 { fill: #800080 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ScrollbarApp + ScrollbarApp - + - - - - I must not fear.I must not fear. - Fear is the mind-killer.Fear is the mind-killer. - Fear is the little-death that Fear is the little-death that  - brings total obliteration.brings total obliteration. - I will face my fear.I will face my fear. - I will permit it to pass over I will permit it to pass over  - me and through me.▇▇me and through me.▇▇ - And when it has gone past, I And when it has gone past, I  - will turn the inner eye to seewill turn the inner eye to see - its path.its path. - Where the fear has gone there Where the fear has gone there  - will be nothing. Only I will will be nothing. Only I will  - remain.remain. - I must not fear.I must not fear. - Fear is the mind-killer.Fear is the mind-killer. - Fear is the little-death that Fear is the little-death that  - brings total obliteration.brings total obliteration. - I will face my fear.I will face my fear. - I will permit it to pass over I will permit it to pass over  - me and through me.me and through me. - And when it has gone past, I And when it has gone past, I  + + + + I must not fear.I must not fear. + Fear is the mind-killer.Fear is the mind-killer. + Fear is the little-death that Fear is the little-death that  + brings total obliteration.brings total obliteration. + I will face my fear.I will face my fear. + I will permit it to pass over I will permit it to pass over  + me and through me.▇▇me and through me.▇▇ + And when it has gone past, I And when it has gone past, I  + will turn the inner eye to seewill turn the inner eye to see + its path.its path. + Where the fear has gone there Where the fear has gone there  + will be nothing. Only I will will be nothing. Only I will  + remain.remain. + I must not fear.I must not fear. + Fear is the mind-killer.Fear is the mind-killer. + Fear is the little-death that Fear is the little-death that  + brings total obliteration.brings total obliteration. + I will face my fear.I will face my fear. + I will permit it to pass over I will permit it to pass over  + me and through me.me and through me. + And when it has gone past, I And when it has gone past, I  From 8e89561e2d6eacabfb6a7eb63adf47ae0fbe2a31 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 15 Nov 2022 13:03:06 +0000 Subject: [PATCH 07/49] Cleanup of _border.py All indications are that Border isn't used within Textual any more. --- src/textual/_border.py | 208 +---------------------------------------- 1 file changed, 1 insertion(+), 207 deletions(-) diff --git a/src/textual/_border.py b/src/textual/_border.py index dfcdf01ae..4307281bc 100644 --- a/src/textual/_border.py +++ b/src/textual/_border.py @@ -3,9 +3,7 @@ from __future__ import annotations from functools import lru_cache from typing import cast, Tuple, Union -from rich.console import Console, ConsoleOptions, RenderResult, RenderableType -import rich.repr -from rich.segment import Segment, SegmentLines +from rich.segment import Segment from rich.style import Style from .color import Color @@ -158,164 +156,6 @@ def render_row( return [Segment(box2.text * width, box2.style)] -@rich.repr.auto -class Border: - """Renders Textual CSS borders. - - This is analogous to Rich's `Box` but more flexible. Different borders may be - applied to each of the four edges, and more advanced borders can be achieved through - various combinations of Widget and parent background colors. - - """ - - def __init__( - self, - renderable: RenderableType, - borders: Borders, - inner_color: Color, - outer_color: Color, - outline: bool = False, - ): - self.renderable = renderable - self.edge_styles = borders - self.outline = outline - - ( - (top, top_color), - (right, right_color), - (bottom, bottom_color), - (left, left_color), - ) = borders - self._sides: tuple[EdgeType, EdgeType, EdgeType, EdgeType] - self._sides = (top, right, bottom, left) - from_color = Style.from_color - - self._styles = ( - from_color(top_color.rich_color), - from_color(right_color.rich_color), - from_color(bottom_color.rich_color), - from_color(left_color.rich_color), - ) - self.inner_style = from_color(bgcolor=inner_color.rich_color) - self.outer_style = from_color(bgcolor=outer_color.rich_color) - - def __rich_repr__(self) -> rich.repr.Result: - yield self.renderable - yield self.edge_styles - - def _crop_renderable(self, lines: list[list[Segment]], width: int) -> None: - """Crops a renderable in place. - - Args: - lines (list[list[Segment]]): Segment lines. - width (int): Desired width. - """ - top, right, bottom, left = self._sides - # the 4 following lines rely on the fact that we normalise "none" and "hidden" to en empty string - has_left = bool(left) - has_right = bool(right) - has_top = bool(top) - has_bottom = bool(bottom) - - if has_top: - lines.pop(0) - if has_bottom and lines: - lines.pop(-1) - - # TODO: Divide is probably quite inefficient here, - # It could be much faster for the specific case of one off the start end end - divide = Segment.divide - if has_left and has_right: - for line in lines: - _, line[:] = divide(line, [1, width - 1]) - elif has_left: - for line in lines: - _, line[:] = divide(line, [1, width]) - elif has_right: - for line in lines: - line[:], _ = divide(line, [width - 1, width]) - - def __rich_console__( - self, console: "Console", options: "ConsoleOptions" - ) -> "RenderResult": - top, right, bottom, left = self._sides - style = console.get_style(self.inner_style) - outer_style = console.get_style(self.outer_style) - top_style, right_style, bottom_style, left_style = self._styles - - # ditto than in `_crop_renderable` ☝ - has_left = bool(left) - has_right = bool(right) - has_top = bool(top) - has_bottom = bool(bottom) - - width = options.max_width - has_left - has_right - - if width <= 2: - lines = console.render_lines(self.renderable, options, new_lines=True) - yield SegmentLines(lines) - return - - if self.outline: - render_options = options - else: - if options.height is None: - render_options = options.update_width(width) - else: - new_height = options.height - has_top - has_bottom - if new_height >= 1: - render_options = options.update_dimensions(width, new_height) - else: - render_options = options.update_width(width) - - lines = console.render_lines(self.renderable, render_options) - if self.outline: - self._crop_renderable(lines, options.max_width) - - _Segment = Segment - new_line = _Segment.line() - if has_top: - box1, box2, box3 = get_box(top, style, outer_style, top_style)[0] - if has_left: - yield box1 if top == left else _Segment(" ", box2.style) - yield _Segment(box2.text * width, box2.style) - if has_right: - yield box3 if top == left else _Segment(" ", box3.style) - yield new_line - - left_segment = get_box(left, style, outer_style, left_style)[1][0] - _right_segment = get_box(right, style, outer_style, right_style)[1][2] - right_segment = _Segment(_right_segment.text + "\n", _right_segment.style) - - if has_left and has_right: - for line in lines: - yield left_segment - yield from line - yield right_segment - elif has_left: - for line in lines: - yield left_segment - yield from line - yield new_line - elif has_right: - for line in lines: - yield from line - yield right_segment - else: - for line in lines: - yield from line - yield new_line - - if has_bottom: - box1, box2, box3 = get_box(bottom, style, outer_style, bottom_style)[2] - if has_left: - yield box1 if bottom == left else _Segment(" ", box1.style) - yield _Segment(box2.text * width, box2.style) - if has_right: - yield box3 if bottom == right else _Segment(" ", box3.style) - yield new_line - - _edge_type_normalization_table: dict[EdgeType, EdgeType] = { # i.e. we normalize "border: none;" to "border: ;". # As a result our layout-related calculations that include borders are simpler (and have better performance) @@ -326,49 +166,3 @@ _edge_type_normalization_table: dict[EdgeType, EdgeType] = { def normalize_border_value(value: BorderValue) -> BorderValue: return _edge_type_normalization_table.get(value[0], value[0]), value[1] - - -if __name__ == "__main__": - from rich import print - from rich.text import Text - from rich.padding import Padding - - from .color import Color - - inner = Color.parse("#303F9F") - outer = Color.parse("#212121") - - lorem = """[#C5CAE9]Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus.""" - text = Text.from_markup(lorem) - border = Border( - Padding(text, 1, style="on #303F9F"), - ( - ("none", Color.parse("#C5CAE9")), - ("none", Color.parse("#C5CAE9")), - ("wide", Color.parse("#C5CAE9")), - ("none", Color.parse("#C5CAE9")), - ), - inner_color=inner, - outer_color=outer, - ) - - print( - Padding(border, (1, 2), style="on #212121"), - ) - print() - - border = Border( - Padding(text, 1, style="on #303F9F"), - ( - ("hkey", Color.parse("#8BC34A")), - ("hkey", Color.parse("#8BC34A")), - ("hkey", Color.parse("#8BC34A")), - ("hkey", Color.parse("#8BC34A")), - ), - inner_color=inner, - outer_color=outer, - ) - - print( - Padding(border, (1, 2), style="on #212121"), - ) From 7213834bd45a874187f766f95c3123539ecc8f76 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 15 Nov 2022 13:07:50 +0000 Subject: [PATCH 08/49] Update the CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05165a611..f6293162f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed DataTable row not updating after add https://github.com/Textualize/textual/issues/1026 - Fixed issues with animation. Now objects of different types may be animated. +- Fixed containers with transparent background not showing borders https://github.com/Textualize/textual/issues/1175 ## [0.4.0] - 2022-11-08 From 650b6549662502876dd87d910a45a065200375ed Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Nov 2022 13:28:18 +0000 Subject: [PATCH 09/49] remove nesting of try --- src/textual/_animator.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 25c7e6db8..f4d373abc 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -189,10 +189,9 @@ class Animator: async def stop(self) -> None: """Stop the animator task.""" try: - try: - await self._timer.stop() - except asyncio.CancelledError: - pass + await self._timer.stop() + except asyncio.CancelledError: + pass finally: self._idle_event.set() From 9fd1e7605a8f60635062cd038fca7654bfbe4481 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Nov 2022 14:58:38 +0000 Subject: [PATCH 10/49] Create PULL_REQUEST_TEMPLATE.md --- .github/PULL_REQUEST_TEMPLATE.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..1164581d2 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,6 @@ + +**Please review the following checklist.** + +- [ ] Docstrings on all new or modified functions / classes +- [ ] Updated documentation +- [ ] Updated CHANGELOG.md (where appropriate) From 38e6d1dbcb05d1564584ae3eead8dbf13b463eba Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 15 Nov 2022 16:40:13 +0000 Subject: [PATCH 11/49] Update the docstring of get_box_model It looks like the get_auto_* parameters got renamed at some point so this seeks to update the docstring for that. I'm not 100% sure if the description of the parameters fully holds so that'll need checking. --- src/textual/box_model.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/box_model.py b/src/textual/box_model.py index 265f19cdc..8dadfc684 100644 --- a/src/textual/box_model.py +++ b/src/textual/box_model.py @@ -33,8 +33,8 @@ def get_box_model( viewport (Size): The viewport size. width_fraction (Fraction): A fraction used for 1 `fr` unit on the width dimension. height_fraction (Fraction):A fraction used for 1 `fr` unit on the height dimension. - get_auto_width (Callable): A callable which accepts container size and parent size and returns a width. - get_auto_height (Callable): A callable which accepts container size and parent size and returns a height. + get_content_width (Callable[[Size, Size], int]): A callable which accepts container size and parent size and returns a width. + get_content_height (Callable[[Size, Size, int], int]): A callable which accepts container size and parent size and returns a height. Returns: BoxModel: A tuple with the size of the content area and margin. From 94e02b8d24fed9eed6fdc78b7179dd2bb4d23123 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 15 Nov 2022 16:43:55 +0000 Subject: [PATCH 12/49] Add types to the docstrings for a Binding --- src/textual/binding.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/textual/binding.py b/src/textual/binding.py index 1398d5596..ede64d0d4 100644 --- a/src/textual/binding.py +++ b/src/textual/binding.py @@ -21,17 +21,17 @@ class NoBinding(Exception): @dataclass(frozen=True) class Binding: key: str - """Key to bind. This can also be a comma-separated list of keys to map multiple keys to a single action.""" + """str: Key to bind. This can also be a comma-separated list of keys to map multiple keys to a single action.""" action: str - """Action to bind to.""" + """str: Action to bind to.""" description: str - """Description of action.""" + """str: Description of action.""" show: bool = True - """Show the action in Footer, or False to hide.""" + """bool: Show the action in Footer, or False to hide.""" key_display: str | None = None - """How the key should be shown in footer.""" + """str | None: How the key should be shown in footer.""" universal: bool = False - """Allow forwarding from app to focused widget.""" + """bool: Allow forwarding from app to focused widget.""" @rich.repr.auto From 8c6c68a4793fd797161cddb42d7c7c8e3bddbe8b Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 15 Nov 2022 16:49:13 +0000 Subject: [PATCH 13/49] Add a docstring for the bind method --- src/textual/binding.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/textual/binding.py b/src/textual/binding.py index ede64d0d4..62925ec53 100644 --- a/src/textual/binding.py +++ b/src/textual/binding.py @@ -107,6 +107,16 @@ class Bindings: key_display: str | None = None, universal: bool = False, ) -> None: + """Bind keys to an action. + + Args: + keys (str): The keys to bind. Can be a comma-separated list of keys. + action (str): The action to bind the keys to. + description (str, optional): An optional description for the binding. + show (bool, optional): A flag to say if the binding should appear in the footer. + key_display (str | None, optional): Optional string to display in the footer for the key. + universal (bool, optional): Allow forwarding from the app to the focused widget. + """ all_keys = [key.strip() for key in keys.split(",")] for key in all_keys: self.keys[key] = Binding( From a465f5c236df9904debb0dd01e0094e7e61a7023 Mon Sep 17 00:00:00 2001 From: darrenburns Date: Wed, 16 Nov 2022 14:49:52 +0000 Subject: [PATCH 14/49] Horizontal width auto (#1155) * Improvements to width:auto HorizontalLayout * Fix HorizontalLayout.get_content_width * Horizontal width auto improvement * Removing some printxz * Update snapshot for horizontal layout width auto dock --- src/textual/layouts/horizontal.py | 22 +-- tests/layouts/test_horizontal.py | 63 +++---- .../__snapshots__/test_snapshots.ambr | 158 ++++++++++++++++++ .../snapshot_apps/horizontal_auto_width.css | 24 +++ .../snapshot_apps/horizontal_auto_width.py | 22 +++ tests/snapshot_tests/test_snapshots.py | 6 + 6 files changed, 240 insertions(+), 55 deletions(-) create mode 100644 tests/snapshot_tests/snapshot_apps/horizontal_auto_width.css create mode 100644 tests/snapshot_tests/snapshot_apps/horizontal_auto_width.py diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py index 210ee95d3..87ab7c28c 100644 --- a/src/textual/layouts/horizontal.py +++ b/src/textual/layouts/horizontal.py @@ -72,20 +72,12 @@ class HorizontalLayout(Layout): Returns: int: Width of the content. """ - width: int | None = None - gutter_width = widget.gutter.width - for child in widget.displayed_children: - if not child.is_container: - child_width = ( - child.get_content_width(container, viewport) - + gutter_width - + child.gutter.width - ) - if width is None: - width = child_width - else: - width += child_width - if width is None: + if not widget.displayed_children: width = container.width - + else: + placements, *_ = widget._arrange(container) + width = max( + placement.region.right + placement.margin.right + for placement in placements + ) return width diff --git a/tests/layouts/test_horizontal.py b/tests/layouts/test_horizontal.py index 5a7aad388..05ce4c890 100644 --- a/tests/layouts/test_horizontal.py +++ b/tests/layouts/test_horizontal.py @@ -1,46 +1,29 @@ -from textual.geometry import Size -from textual.layouts.horizontal import HorizontalLayout +import pytest + +from textual.app import App, ComposeResult +from textual.containers import Horizontal from textual.widget import Widget -class SizedWidget(Widget): - """Simple Widget wrapped allowing you to modify the return values for - get_content_width and get_content_height via the constructor.""" +@pytest.fixture +async def app(): + class HorizontalAutoWidth(App): + def compose(self) -> ComposeResult: + child1 = Widget(id="child1") + child1.styles.width = 4 + child2 = Widget(id="child2") + child2.styles.width = 6 + child3 = Widget(id="child3") + child3.styles.width = 5 + self.horizontal = Horizontal(child1, child2, child3) + yield self.horizontal - def __init__( - self, - *children: Widget, - content_width: int = 10, - content_height: int = 5, - ): - super().__init__(*children) - self.content_width = content_width - self.content_height = content_height - - def get_content_width(self, container: Size, viewport: Size) -> int: - return self.content_width - - def get_content_height(self, container: Size, viewport: Size, width: int) -> int: - return self.content_height + app = HorizontalAutoWidth() + async with app.run_test(): + yield app -CHILDREN = [ - SizedWidget(content_width=10, content_height=5), - SizedWidget(content_width=4, content_height=2), - SizedWidget(content_width=12, content_height=3), -] - - -def test_horizontal_get_content_width(): - parent = Widget(*CHILDREN) - layout = HorizontalLayout() - width = layout.get_content_width(widget=parent, container=Size(), viewport=Size()) - assert width == sum(child.content_width for child in CHILDREN) - - -def test_horizontal_get_content_width_no_children(): - parent = Widget() - layout = HorizontalLayout() - container_size = Size(24, 24) - width = layout.get_content_width(widget=parent, container=container_size, viewport=Size()) - assert width == container_size.width +async def test_horizontal_get_content_width(app): + size = app.screen.size + width = app.horizontal.get_content_width(size, size) + assert width == 15 diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index b10be936d..cb6252b33 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -5849,6 +5849,164 @@ ''' # --- +# name: test_horizontal_layout_width_auto_dock + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + HorizontalAutoWidth + + + + + + + + + + Docke + Widget 1Widget 2 + left  + 1Docked left 2 + + + + + + + + + + + + + + + + + + + + + + + + + ''' +# --- # name: test_input_and_focus ''' diff --git a/tests/snapshot_tests/snapshot_apps/horizontal_auto_width.css b/tests/snapshot_tests/snapshot_apps/horizontal_auto_width.css new file mode 100644 index 000000000..50ea9edff --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/horizontal_auto_width.css @@ -0,0 +1,24 @@ +.widget { + background: olivedrab; + width: 10; + margin: 1; +} + +#dock-1 { + dock: left; + background: dodgerblue; + width: 5; +} + +#dock-2 { + dock: left; + background: mediumvioletred; + margin: 3; + width: 20; +} + +#horizontal { + width: auto; + height: auto; + background: darkslateblue; +} \ No newline at end of file diff --git a/tests/snapshot_tests/snapshot_apps/horizontal_auto_width.py b/tests/snapshot_tests/snapshot_apps/horizontal_auto_width.py new file mode 100644 index 000000000..ef0528fa9 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/horizontal_auto_width.py @@ -0,0 +1,22 @@ +from textual.app import App, ComposeResult +from textual.containers import Horizontal +from textual.widgets import Static + + +class HorizontalAutoWidth(App): + """ + Checks that the auto width of the parent Horizontal is correct. + """ + def compose(self) -> ComposeResult: + yield Horizontal( + Static("Docked left 1", id="dock-1"), + Static("Docked left 2", id="dock-2"), + Static("Widget 1", classes="widget"), + Static("Widget 2", classes="widget"), + id="horizontal", + ) + + +app = HorizontalAutoWidth(css_path="horizontal_auto_width.css") +if __name__ == '__main__': + app.run() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 41846ae11..95e83c278 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -2,9 +2,11 @@ from pathlib import Path import pytest +# These paths should be relative to THIS directory. WIDGET_EXAMPLES_DIR = Path("../../docs/examples/widgets") LAYOUT_EXAMPLES_DIR = Path("../../docs/examples/guide/layout") STYLES_EXAMPLES_DIR = Path("../../docs/examples/styles") +SNAPSHOT_APPS_DIR = Path("./snapshot_apps") # --- Layout related stuff --- @@ -29,6 +31,10 @@ def test_horizontal_layout(snap_compare): assert snap_compare(LAYOUT_EXAMPLES_DIR / "horizontal_layout.py") +def test_horizontal_layout_width_auto_dock(snap_compare): + assert snap_compare(SNAPSHOT_APPS_DIR / "horizontal_auto_width.py") + + def test_vertical_layout(snap_compare): assert snap_compare(LAYOUT_EXAMPLES_DIR / "vertical_layout.py") From c881a9657fe3c967139c07ce45673ece6a2e7221 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 16 Nov 2022 15:03:24 +0000 Subject: [PATCH 15/49] Add a Label widget For the moment this does nothing more than inherit from a Static; but what it does do is make it easier for someone to add text to their application and to style it by styling all the Labels. Before now it would be common to use a Static but if you try and style (or query) all Statics, you'd also get things like Buttons, which inherit from Static. See #1190 --- CHANGELOG.md | 1 + examples/five_by_five.py | 16 ++++++++-------- src/textual/widgets/__init__.py | 2 ++ src/textual/widgets/__init__.pyi | 1 + src/textual/widgets/_label.py | 7 +++++++ 5 files changed, 19 insertions(+), 8 deletions(-) create mode 100644 src/textual/widgets/_label.py diff --git a/CHANGELOG.md b/CHANGELOG.md index dd2fdb331..39bd22dc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). https://github.com/Textualize/textual/issues/1094 - Added Pilot.wait_for_animation - Added `Widget.move_child` https://github.com/Textualize/textual/issues/1121 +- Added a `Label` widget https://github.com/Textualize/textual/issues/1190 ### Changed diff --git a/examples/five_by_five.py b/examples/five_by_five.py index 20bbea80f..f4574a8a4 100644 --- a/examples/five_by_five.py +++ b/examples/five_by_five.py @@ -13,7 +13,7 @@ from textual.containers import Horizontal from textual.app import App, ComposeResult from textual.screen import Screen from textual.widget import Widget -from textual.widgets import Footer, Button, Static +from textual.widgets import Footer, Button, Label from textual.css.query import DOMQuery from textual.reactive import reactive from textual.binding import Binding @@ -33,10 +33,10 @@ class Help(Screen): Returns: ComposeResult: The result of composing the help screen. """ - yield Static(Markdown(Path(__file__).with_suffix(".md").read_text())) + yield Label(Markdown(Path(__file__).with_suffix(".md").read_text())) -class WinnerMessage(Static): +class WinnerMessage(Label): """Widget to tell the user they have won.""" MIN_MOVES: Final = 14 @@ -91,9 +91,9 @@ class GameHeader(Widget): ComposeResult: The result of composing the game header. """ yield Horizontal( - Static(self.app.title, id="app-title"), - Static(id="moves"), - Static(id="progress"), + Label(self.app.title, id="app-title"), + Label(id="moves"), + Label(id="progress"), ) def watch_moves(self, moves: int): @@ -102,7 +102,7 @@ class GameHeader(Widget): Args: moves (int): The number of moves made. """ - self.query_one("#moves", Static).update(f"Moves: {moves}") + self.query_one("#moves", Label).update(f"Moves: {moves}") def watch_filled(self, filled: int): """Watch the on-count reactive and update when it changes. @@ -110,7 +110,7 @@ class GameHeader(Widget): Args: filled (int): The number of cells that are currently on. """ - self.query_one("#progress", Static).update(f"Filled: {filled}") + self.query_one("#progress", Label).update(f"Filled: {filled}") class GameCell(Button): diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index c42b011a9..8cbcb9f3b 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -15,6 +15,7 @@ if typing.TYPE_CHECKING: from ._directory_tree import DirectoryTree from ._footer import Footer from ._header import Header + from ._label import Label from ._placeholder import Placeholder from ._pretty import Pretty from ._static import Static @@ -30,6 +31,7 @@ __all__ = [ "DirectoryTree", "Footer", "Header", + "Label", "Placeholder", "Pretty", "Static", diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi index 5ceb01835..57f87df82 100644 --- a/src/textual/widgets/__init__.pyi +++ b/src/textual/widgets/__init__.pyi @@ -5,6 +5,7 @@ from ._checkbox import Checkbox as Checkbox from ._directory_tree import DirectoryTree as DirectoryTree from ._footer import Footer as Footer from ._header import Header as Header +from ._label import Label as Label from ._placeholder import Placeholder as Placeholder from ._pretty import Pretty as Pretty from ._static import Static as Static diff --git a/src/textual/widgets/_label.py b/src/textual/widgets/_label.py new file mode 100644 index 000000000..a3adba462 --- /dev/null +++ b/src/textual/widgets/_label.py @@ -0,0 +1,7 @@ +"""Provides a simple Label widget.""" + +from ._static import Static + + +class Label(Static): + """A simple label widget for displaying text-oriented rendenrables.""" From 22863148ef628d9c3d9dbe1d8527f98b2e156e2f Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 16 Nov 2022 15:09:27 +0000 Subject: [PATCH 16/49] Update src/textual/widgets/_label.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/widgets/_label.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_label.py b/src/textual/widgets/_label.py index a3adba462..df519ae4f 100644 --- a/src/textual/widgets/_label.py +++ b/src/textual/widgets/_label.py @@ -4,4 +4,4 @@ from ._static import Static class Label(Static): - """A simple label widget for displaying text-oriented rendenrables.""" + """A simple label widget for displaying text-oriented renderables.""" From df37a9b90a52de91643ea4dc01b21f32dbeca718 Mon Sep 17 00:00:00 2001 From: darrenburns Date: Wed, 16 Nov 2022 15:29:59 +0000 Subject: [PATCH 17/49] Add get_child_by_id and get_widget_by_id (#1146) * Add get_child_by_id and get_widget_by_id * Remove redundant code * Add unit tests for app-level get_child_by_id and get_widget_by_id * Remove redundant test fixture injection * Update CHANGELOG * Enforce uniqueness of ID amongst widget children * Enforce unique widget IDs amongst widgets mounted together * Update CHANGELOG.md * Ensuring unique IDs in a more logical place * Add docstring to NodeList._get_by_id * Dont use duplicate IDs in tests, dont mount 2000 widgets * Mounting less widgets in a unit test * Reword error message * Use lower-level depth first search in get_widget_by_id to break out early --- CHANGELOG.md | 2 +- src/textual/_node_list.py | 34 ++++++++++++ src/textual/app.py | 25 +++++++-- src/textual/dom.py | 19 ------- src/textual/walk.py | 6 ++- src/textual/widget.py | 54 +++++++++++++++++++ tests/test_dom.py | 32 ------------ tests/test_unmount.py | 2 + tests/test_widget.py | 97 ++++++++++++++++++++++++++++++++++- tests/test_widget_removing.py | 12 ++--- 10 files changed, 219 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd2fdb331..c300711d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [0.5.0] - Unreleased - ### Added +- Add get_child_by_id and get_widget_by_id, remove get_child https://github.com/Textualize/textual/pull/1146 - Add easing parameter to Widget.scroll_* methods https://github.com/Textualize/textual/pull/1144 - Added Widget.call_later which invokes a callback on idle. - `DOMNode.ancestors` no longer includes `self`. diff --git a/src/textual/_node_list.py b/src/textual/_node_list.py index 5a9a31486..d48597090 100644 --- a/src/textual/_node_list.py +++ b/src/textual/_node_list.py @@ -8,6 +8,10 @@ if TYPE_CHECKING: from .widget import Widget +class DuplicateIds(Exception): + pass + + @rich.repr.auto(angular=True) class NodeList(Sequence): """ @@ -21,6 +25,12 @@ class NodeList(Sequence): # The nodes in the list self._nodes: list[Widget] = [] self._nodes_set: set[Widget] = set() + + # We cache widgets by their IDs too for a quick lookup + # Note that only widgets with IDs are cached like this, so + # this cache will likely hold fewer values than self._nodes. + self._nodes_by_id: dict[str, Widget] = {} + # Increments when list is updated (used for caching) self._updates = 0 @@ -53,6 +63,10 @@ class NodeList(Sequence): """ return self._nodes.index(widget) + def _get_by_id(self, widget_id: str) -> Widget | None: + """Get the widget for the given widget_id, or None if there's no matches in this list""" + return self._nodes_by_id.get(widget_id) + def _append(self, widget: Widget) -> None: """Append a Widget. @@ -62,6 +76,10 @@ class NodeList(Sequence): if widget not in self._nodes_set: self._nodes.append(widget) self._nodes_set.add(widget) + widget_id = widget.id + if widget_id is not None: + self._ensure_unique_id(widget_id) + self._nodes_by_id[widget_id] = widget self._updates += 1 def _insert(self, index: int, widget: Widget) -> None: @@ -73,8 +91,20 @@ class NodeList(Sequence): if widget not in self._nodes_set: self._nodes.insert(index, widget) self._nodes_set.add(widget) + widget_id = widget.id + if widget_id is not None: + self._ensure_unique_id(widget_id) + self._nodes_by_id[widget_id] = widget self._updates += 1 + def _ensure_unique_id(self, widget_id: str) -> None: + if widget_id in self._nodes_by_id: + raise DuplicateIds( + f"Tried to insert a widget with ID {widget_id!r}, but a widget {self._nodes_by_id[widget_id]!r} " + f"already exists with that ID in this list of children. " + f"The children of a widget must have unique IDs." + ) + def _remove(self, widget: Widget) -> None: """Remove a widget from the list. @@ -86,6 +116,9 @@ class NodeList(Sequence): if widget in self._nodes_set: del self._nodes[self._nodes.index(widget)] self._nodes_set.remove(widget) + widget_id = widget.id + if widget_id in self._nodes_by_id: + del self._nodes_by_id[widget_id] self._updates += 1 def _clear(self) -> None: @@ -93,6 +126,7 @@ class NodeList(Sequence): if self._nodes: self._nodes.clear() self._nodes_set.clear() + self._nodes_by_id.clear() self._updates += 1 def __iter__(self) -> Iterator[Widget]: diff --git a/src/textual/app.py b/src/textual/app.py index 4328854ba..b5526f73c 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -60,7 +60,7 @@ from .messages import CallbackType from .reactive import Reactive from .renderables.blank import Blank from .screen import Screen -from .widget import AwaitMount, Widget +from .widget import AwaitMount, Widget, MountError if TYPE_CHECKING: from .devtools.client import DevtoolsClient @@ -873,7 +873,7 @@ class App(Generic[ReturnType], DOMNode): def render(self) -> RenderableType: return Blank(self.styles.background) - def get_child(self, id: str) -> DOMNode: + def get_child_by_id(self, id: str) -> Widget: """Shorthand for self.screen.get_child(id: str) Returns the first child (immediate descendent) of this DOMNode with the given ID. @@ -887,7 +887,26 @@ class App(Generic[ReturnType], DOMNode): Raises: NoMatches: if no children could be found for this ID """ - return self.screen.get_child(id) + return self.screen.get_child_by_id(id) + + def get_widget_by_id(self, id: str) -> Widget: + """Shorthand for self.screen.get_widget_by_id(id) + Return the first descendant widget with the given ID. + + Performs a breadth-first search rooted at the current screen. + It will not return the Screen if that matches the ID. + To get the screen, use `self.screen`. + + Args: + id (str): The ID to search for in the subtree + + Returns: + DOMNode: The first descendant encountered with this ID. + + Raises: + NoMatches: if no children could be found for this ID + """ + return self.screen.get_widget_by_id(id) def update_styles(self, node: DOMNode | None = None) -> None: """Request update of styles. diff --git a/src/textual/dom.py b/src/textual/dom.py index 0373f9e04..4d5509804 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -28,7 +28,6 @@ from .css._error_tools import friendly_list from .css.constants import VALID_DISPLAY, VALID_VISIBILITY from .css.errors import DeclarationError, StyleValueError from .css.parse import parse_declarations -from .css.query import NoMatches from .css.styles import RenderStyles, Styles from .css.tokenize import IDENTIFIER from .message_pump import MessagePump @@ -645,7 +644,6 @@ class DOMNode(MessagePump): list[DOMNode] | list[WalkType]: A list of nodes. """ - check_type = filter_type or DOMNode node_generator = ( @@ -661,23 +659,6 @@ class DOMNode(MessagePump): nodes.reverse() return cast("list[DOMNode]", nodes) - def get_child(self, id: str) -> DOMNode: - """Return the first child (immediate descendent) of this node with the given ID. - - Args: - id (str): The ID of the child. - - Returns: - DOMNode: The first child of this node with the ID. - - Raises: - NoMatches: if no children could be found for this ID - """ - for child in self.children: - if child.id == id: - return child - raise NoMatches(f"No child found with id={id!r}") - ExpectType = TypeVar("ExpectType", bound="Widget") @overload diff --git a/src/textual/walk.py b/src/textual/walk.py index 1126d9d30..aa2e3467e 100644 --- a/src/textual/walk.py +++ b/src/textual/walk.py @@ -4,7 +4,7 @@ from collections import deque from typing import Iterable, Iterator, TypeVar, overload, TYPE_CHECKING if TYPE_CHECKING: - from .dom import DOMNode + from textual.dom import DOMNode WalkType = TypeVar("WalkType", bound=DOMNode) @@ -51,6 +51,8 @@ def walk_depth_first( Iterable[DOMNode] | Iterable[WalkType]: An iterable of DOMNodes, or the type specified in ``filter_type``. """ + from textual.dom import DOMNode + stack: list[Iterator[DOMNode]] = [iter(root.children)] pop = stack.pop push = stack.append @@ -111,6 +113,8 @@ def walk_breadth_first( Iterable[DOMNode] | Iterable[WalkType]: An iterable of DOMNodes, or the type specified in ``filter_type``. """ + from textual.dom import DOMNode + queue: deque[DOMNode] = deque() popleft = queue.popleft extend = queue.extend diff --git a/src/textual/widget.py b/src/textual/widget.py index 2de261227..8d993453c 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections import Counter from asyncio import Lock, wait, create_task, Event as AsyncEvent from fractions import Fraction from itertools import islice @@ -41,6 +42,7 @@ from ._styles_cache import StylesCache from ._types import Lines from .binding import NoBinding from .box_model import BoxModel, get_box_model +from .css.query import NoMatches from .css.scalar import ScalarOffset from .dom import DOMNode, NoScreen from .geometry import Offset, Region, Size, Spacing, clamp @@ -50,6 +52,7 @@ from .messages import CallbackType from .reactive import Reactive from .render import measure from .await_remove import AwaitRemove +from .walk import walk_depth_first if TYPE_CHECKING: from .app import App, ComposeResult @@ -334,6 +337,43 @@ class Widget(DOMNode): def offset(self, offset: Offset) -> None: self.styles.offset = ScalarOffset.from_offset(offset) + def get_child_by_id(self, id: str) -> Widget: + """Return the first child (immediate descendent) of this node with the given ID. + + Args: + id (str): The ID of the child. + + Returns: + DOMNode: The first child of this node with the ID. + + Raises: + NoMatches: if no children could be found for this ID + """ + child = self.children._get_by_id(id) + if child is not None: + return child + raise NoMatches(f"No child found with id={id!r}") + + def get_widget_by_id(self, id: str) -> Widget: + """Return the first descendant widget with the given ID. + Performs a depth-first search rooted at this widget. + + Args: + id (str): The ID to search for in the subtree + + Returns: + DOMNode: The first descendant encountered with this ID. + + Raises: + NoMatches: if no children could be found for this ID + """ + for child in walk_depth_first(self): + try: + return child.get_child_by_id(id) + except NoMatches: + pass + raise NoMatches(f"No descendant found with id={id!r}") + def get_component_rich_style(self, name: str) -> Style: """Get a *Rich* style for a component. @@ -461,6 +501,20 @@ class Widget(DOMNode): provided a ``MountError`` will be raised. """ + # Check for duplicate IDs in the incoming widgets + ids_to_mount = [widget.id for widget in widgets if widget.id is not None] + unique_ids = set(ids_to_mount) + num_unique_ids = len(unique_ids) + num_widgets_with_ids = len(ids_to_mount) + if num_unique_ids != num_widgets_with_ids: + counter = Counter(widget.id for widget in widgets) + for widget_id, count in counter.items(): + if count > 1: + raise MountError( + f"Tried to insert {count!r} widgets with the same ID {widget_id!r}. " + f"Widget IDs must be unique." + ) + # Saying you want to mount before *and* after something is an error. if before is not None and after is not None: raise MountError( diff --git a/tests/test_dom.py b/tests/test_dom.py index 5a713193a..e925c5c14 100644 --- a/tests/test_dom.py +++ b/tests/test_dom.py @@ -1,7 +1,6 @@ import pytest from textual.css.errors import StyleValueError -from textual.css.query import NoMatches from textual.dom import DOMNode, BadIdentifier @@ -26,37 +25,6 @@ def test_display_set_invalid_value(): node.display = "blah" -@pytest.fixture -def parent(): - parent = DOMNode(id="parent") - child1 = DOMNode(id="child1") - child2 = DOMNode(id="child2") - grandchild1 = DOMNode(id="grandchild1") - child1._add_child(grandchild1) - - parent._add_child(child1) - parent._add_child(child2) - - yield parent - - -def test_get_child_gets_first_child(parent): - child = parent.get_child(id="child1") - assert child.id == "child1" - assert child.get_child(id="grandchild1").id == "grandchild1" - assert parent.get_child(id="child2").id == "child2" - - -def test_get_child_no_matching_child(parent): - with pytest.raises(NoMatches): - parent.get_child(id="doesnt-exist") - - -def test_get_child_only_immediate_descendents(parent): - with pytest.raises(NoMatches): - parent.get_child(id="grandchild1") - - def test_validate(): with pytest.raises(BadIdentifier): DOMNode(id="23") diff --git a/tests/test_unmount.py b/tests/test_unmount.py index 3e6a5ed0f..4611ff7d9 100644 --- a/tests/test_unmount.py +++ b/tests/test_unmount.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from textual.app import App, ComposeResult from textual import events from textual.containers import Container diff --git a/tests/test_widget.py b/tests/test_widget.py index 9c81c3fe4..e3c0a618c 100644 --- a/tests/test_widget.py +++ b/tests/test_widget.py @@ -1,9 +1,13 @@ import pytest +import rich -from textual.app import App +from textual._node_list import DuplicateIds +from textual.app import App, ComposeResult from textual.css.errors import StyleValueError +from textual.css.query import NoMatches +from textual.dom import DOMNode from textual.geometry import Size -from textual.widget import Widget +from textual.widget import Widget, MountError @pytest.mark.parametrize( @@ -64,3 +68,92 @@ def test_widget_content_width(): height = widget3.get_content_height(Size(20, 20), Size(80, 24), width) assert width == 3 assert height == 3 + + +class GetByIdApp(App): + def compose(self) -> ComposeResult: + grandchild1 = Widget(id="grandchild1") + child1 = Widget(grandchild1, id="child1") + child2 = Widget(id="child2") + + yield Widget( + child1, + child2, + id="parent", + ) + + +@pytest.fixture +async def hierarchy_app(): + app = GetByIdApp() + async with app.run_test(): + yield app + + +@pytest.fixture +async def parent(hierarchy_app): + yield hierarchy_app.get_widget_by_id("parent") + + +def test_get_child_by_id_gets_first_child(parent): + child = parent.get_child_by_id(id="child1") + assert child.id == "child1" + assert child.get_child_by_id(id="grandchild1").id == "grandchild1" + assert parent.get_child_by_id(id="child2").id == "child2" + + +def test_get_child_by_id_no_matching_child(parent): + with pytest.raises(NoMatches): + parent.get_child_by_id(id="doesnt-exist") + + +def test_get_child_by_id_only_immediate_descendents(parent): + with pytest.raises(NoMatches): + parent.get_child_by_id(id="grandchild1") + + +def test_get_widget_by_id_no_matching_child(parent): + with pytest.raises(NoMatches): + parent.get_widget_by_id(id="i-dont-exist") + + +def test_get_widget_by_id_non_immediate_descendants(parent): + result = parent.get_widget_by_id("grandchild1") + assert result.id == "grandchild1" + + +def test_get_widget_by_id_immediate_descendants(parent): + result = parent.get_widget_by_id("child1") + assert result.id == "child1" + + +def test_get_widget_by_id_doesnt_return_self(parent): + with pytest.raises(NoMatches): + parent.get_widget_by_id("parent") + + +def test_get_widgets_app_delegated(hierarchy_app, parent): + # Check that get_child_by_id finds the parent, which is a child of the default Screen + queried_parent = hierarchy_app.get_child_by_id("parent") + assert queried_parent is parent + + # Check that the grandchild (descendant of the default screen) is found + grandchild = hierarchy_app.get_widget_by_id("grandchild1") + assert grandchild.id == "grandchild1" + + +def test_widget_mount_ids_must_be_unique_mounting_all_in_one_go(parent): + widget1 = Widget(id="hello") + widget2 = Widget(id="hello") + + with pytest.raises(MountError): + parent.mount(widget1, widget2) + + +def test_widget_mount_ids_must_be_unique_mounting_multiple_calls(parent): + widget1 = Widget(id="hello") + widget2 = Widget(id="hello") + + parent.mount(widget1) + with pytest.raises(DuplicateIds): + parent.mount(widget2) diff --git a/tests/test_widget_removing.py b/tests/test_widget_removing.py index 341866a9f..6371cf9d3 100644 --- a/tests/test_widget_removing.py +++ b/tests/test_widget_removing.py @@ -15,18 +15,18 @@ async def test_remove_single_widget(): async def test_many_remove_all_widgets(): """It should be possible to remove all widgets on a multi-widget screen.""" async with App().run_test() as pilot: - await pilot.app.mount(*[Static() for _ in range(1000)]) - assert len(pilot.app.screen.children) == 1000 + await pilot.app.mount(*[Static() for _ in range(10)]) + assert len(pilot.app.screen.children) == 10 await pilot.app.query(Static).remove() assert len(pilot.app.screen.children) == 0 async def test_many_remove_some_widgets(): """It should be possible to remove some widgets on a multi-widget screen.""" async with App().run_test() as pilot: - await pilot.app.mount(*[Static(id=f"is-{n%2}") for n in range(1000)]) - assert len(pilot.app.screen.children) == 1000 - await pilot.app.query("#is-0").remove() - assert len(pilot.app.screen.children) == 500 + await pilot.app.mount(*[Static(classes=f"is-{n%2}") for n in range(10)]) + assert len(pilot.app.screen.children) == 10 + await pilot.app.query(".is-0").remove() + assert len(pilot.app.screen.children) == 5 async def test_remove_branch(): """It should be possible to remove a whole branch in the DOM.""" From e3899c0c106147e8e67845b8be1942bab579c141 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 16 Nov 2022 15:41:48 +0000 Subject: [PATCH 18/49] Check button variant for validity during button construction See #1189 --- CHANGELOG.md | 1 + src/textual/widgets/_button.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd2fdb331..c217b6999 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Watchers are now called immediately when setting the attribute if they are synchronous. https://github.com/Textualize/textual/pull/1145 - Widget.call_later has been renamed to Widget.call_after_refresh. +- Button variant values are now checked at runtime. https://github.com/Textualize/textual/issues/1189 ### Fixed diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index b394762d8..aa0f476fc 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -186,7 +186,7 @@ class Button(Static, can_focus=True): if disabled: self.add_class("-disabled") - self.variant = variant + self.variant = self.validate_variant(variant) label: Reactive[RenderableType] = Reactive("") variant = Reactive.init("default") From e32e094b9265d1690a6e4df3e1964161ed69d70f Mon Sep 17 00:00:00 2001 From: darrenburns Date: Wed, 16 Nov 2022 15:47:48 +0000 Subject: [PATCH 19/49] Support callables in App.SCREENS (#1185) * Support Type[Screen] in App.SCREENS (lazy screens) * Update CHANGELOG * Remove redundant isinstance --- CHANGELOG.md | 1 + src/textual/app.py | 12 ++++++++---- tests/test_screens.py | 31 ++++++++++++++++++++++++++++++- 3 files changed, 39 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c300711d7..7148331ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). https://github.com/Textualize/textual/issues/1094 - Added Pilot.wait_for_animation - Added `Widget.move_child` https://github.com/Textualize/textual/issues/1121 +- Support lazy-instantiated Screens (callables in App.SCREENS) https://github.com/Textualize/textual/pull/1185 ### Changed diff --git a/src/textual/app.py b/src/textual/app.py index b5526f73c..e74a90f8a 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -25,6 +25,7 @@ from typing import ( TypeVar, Union, cast, + Callable, ) from weakref import WeakSet, WeakValueDictionary @@ -228,7 +229,7 @@ class App(Generic[ReturnType], DOMNode): } """ - SCREENS: dict[str, Screen] = {} + SCREENS: dict[str, Screen | Callable[[], Screen]] = {} _BASE_PATH: str | None = None CSS_PATH: CSSPathType = None TITLE: str | None = None @@ -330,7 +331,7 @@ class App(Generic[ReturnType], DOMNode): self._registry: WeakSet[DOMNode] = WeakSet() self._installed_screens: WeakValueDictionary[ - str, Screen + str, Screen | Callable[[], Screen] ] = WeakValueDictionary() self._installed_screens.update(**self.SCREENS) @@ -998,12 +999,15 @@ class App(Generic[ReturnType], DOMNode): next_screen = self._installed_screens[screen] except KeyError: raise KeyError(f"No screen called {screen!r} installed") from None + if callable(next_screen): + next_screen = next_screen() + self._installed_screens[screen] = next_screen else: next_screen = screen return next_screen def _get_screen(self, screen: Screen | str) -> tuple[Screen, AwaitMount]: - """Get an installed screen and a await mount object. + """Get an installed screen and an AwaitMount object. If the screen isn't running, it will be registered before it is run. @@ -1558,7 +1562,7 @@ class App(Generic[ReturnType], DOMNode): # Close pre-defined screens for screen in self.SCREENS.values(): - if screen._running: + if isinstance(screen, Screen) and screen._running: await self._prune_node(screen) # Close any remaining nodes diff --git a/tests/test_screens.py b/tests/test_screens.py index 0841faf51..707bad5df 100644 --- a/tests/test_screens.py +++ b/tests/test_screens.py @@ -11,8 +11,37 @@ skip_py310 = pytest.mark.skipif( ) +async def test_installed_screens(): + class ScreensApp(App): + SCREENS = { + "home": Screen, # Screen type + "one": Screen(), # Screen instance + "two": lambda: Screen() # Callable[[], Screen] + } + + app = ScreensApp() + async with app.run_test() as pilot: + pilot.app.push_screen("home") # Instantiates and pushes the "home" screen + pilot.app.push_screen("one") # Pushes the pre-instantiated "one" screen + pilot.app.push_screen("home") # Pushes the single instance of "home" screen + pilot.app.push_screen("two") # Calls the callable, pushes returned Screen instance + + assert len(app.screen_stack) == 5 + assert app.screen_stack[1] is app.screen_stack[3] + assert app.screen is app.screen_stack[4] + assert isinstance(app.screen, Screen) + assert app.is_screen_installed(app.screen) + + assert pilot.app.pop_screen() + assert pilot.app.pop_screen() + assert pilot.app.pop_screen() + assert pilot.app.pop_screen() + with pytest.raises(ScreenStackError): + pilot.app.pop_screen() + + + @skip_py310 -@pytest.mark.asyncio async def test_screens(): app = App() From fd61ca69a4ed1c98b09482e5d162eee9a3251a5a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 16 Nov 2022 17:02:50 +0000 Subject: [PATCH 20/49] Update comment.yml --- .github/workflows/comment.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/comment.yml b/.github/workflows/comment.yml index 7191f1836..46cf1677f 100644 --- a/.github/workflows/comment.yml +++ b/.github/workflows/comment.yml @@ -13,6 +13,6 @@ jobs: with: issue-number: ${{ github.event.issue.number }} body: | - Did we solve your problem? - - Glad we could help! + Don't forget to [star](https://github.com/Textualize/textual) the repository! + + Follow [@textualizeio](https://twitter.com/textualizeio) for Textual updates. From e0ddea839de738c9cffc9ecc712a9cfd8e5c2543 Mon Sep 17 00:00:00 2001 From: darrenburns Date: Wed, 16 Nov 2022 21:48:30 +0000 Subject: [PATCH 21/49] Update changelog regarding horizontal width auto fix (#1192) Co-authored-by: Dave Pearson --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d7394ea00..fb6eac1be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed DataTable row not updating after add https://github.com/Textualize/textual/issues/1026 - Fixed issues with animation. Now objects of different types may be animated. - Fixed containers with transparent background not showing borders https://github.com/Textualize/textual/issues/1175 +- Fixed auto-width in horizontal containers https://github.com/Textualize/textual/pull/1155 ## [0.4.0] - 2022-11-08 From 67386478effd65a52ffea7f1207eab86d477a0d1 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 10:14:55 +0000 Subject: [PATCH 22/49] Trailing whitespace squishing --- mkdocs.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mkdocs.yml b/mkdocs.yml index e7b4e5f9e..8bf9c7f34 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -185,13 +185,13 @@ plugins: - blog: - rss: - match_path: blog/posts/.* + match_path: blog/posts/.* date_from_meta: as_creation: date categories: - categories - release - - tags + - tags - search: - autorefs: - mkdocstrings: @@ -215,10 +215,10 @@ extra_css: extra: social: - - icon: fontawesome/brands/twitter + - icon: fontawesome/brands/twitter link: https://twitter.com/textualizeio name: textualizeio on Twitter - - icon: fontawesome/brands/github + - icon: fontawesome/brands/github link: https://github.com/textualize/textual/ name: Textual on Github - icon: fontawesome/brands/discord From 8a6d21da5eefe751ef8c6e057e323a28c2a1579a Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 10:15:35 +0000 Subject: [PATCH 23/49] Add the basics of Label docs for the manual --- docs/api/label.md | 1 + docs/examples/widgets/label.py | 12 ++++++++++++ docs/widgets/label.md | 33 +++++++++++++++++++++++++++++++++ docs/widgets/static.md | 5 +++-- mkdocs.yml | 2 ++ 5 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 docs/api/label.md create mode 100644 docs/examples/widgets/label.py create mode 100644 docs/widgets/label.md diff --git a/docs/api/label.md b/docs/api/label.md new file mode 100644 index 000000000..eee506a2c --- /dev/null +++ b/docs/api/label.md @@ -0,0 +1 @@ +::: textual.widgets.Label diff --git a/docs/examples/widgets/label.py b/docs/examples/widgets/label.py new file mode 100644 index 000000000..43a50bb10 --- /dev/null +++ b/docs/examples/widgets/label.py @@ -0,0 +1,12 @@ +from textual.app import App, ComposeResult +from textual.widgets import Label + + +class LabelApp(App): + def compose(self) -> ComposeResult: + yield Label("Hello, world!") + + +if __name__ == "__main__": + app = LabelApp() + app.run() diff --git a/docs/widgets/label.md b/docs/widgets/label.md new file mode 100644 index 000000000..d4c22feac --- /dev/null +++ b/docs/widgets/label.md @@ -0,0 +1,33 @@ +# Label + +A widget which displays static text, but which can also contain more complex Rich renderables. + +- [ ] Focusable +- [x] Container + +## Example + +The example below shows how you can use a `Label` widget to display some text. + +=== "Output" + + ```{.textual path="docs/examples/widgets/label.py"} + ``` + +=== "label.py" + + ```python + --8<-- "docs/examples/widgets/label.py" + ``` + +## Reactive Attributes + +This widget has no reactive attributes. + +## Messages + +This widget sends no messages. + +## See Also + +* [Label](../api/label.md) code reference diff --git a/docs/widgets/static.md b/docs/widgets/static.md index 342e2daf7..4d9fc994c 100644 --- a/docs/widgets/static.md +++ b/docs/widgets/static.md @@ -1,14 +1,14 @@ # Static A widget which displays static content. -Can be used for simple text labels, but can also contain more complex Rich renderables. +Can be used for Rich renderables and can also for the base for other types of widgets. - [ ] Focusable - [x] Container ## Example -The example below shows how you can use a `Static` widget as a simple text label. +The example below shows how you can use a `Static` widget as a simple text label (but see [Label](./label.md) as a way of displaying text). === "Output" @@ -32,3 +32,4 @@ This widget sends no messages. ## See Also * [Static](../api/static.md) code reference +* [Label](./label.md) diff --git a/mkdocs.yml b/mkdocs.yml index 8bf9c7f34..332298e5d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -96,6 +96,7 @@ nav: - "widgets/footer.md" - "widgets/header.md" - "widgets/input.md" + - "widgets/label.md" - "widgets/static.md" - "widgets/tree_control.md" - API: @@ -109,6 +110,7 @@ nav: - "api/footer.md" - "api/geometry.md" - "api/header.md" + - "api/label.md" - "api/message_pump.md" - "api/message.md" - "api/pilot.md" From 29a891724be7975cdb06e0ff7fb7e55817edf13a Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 10:37:30 +0000 Subject: [PATCH 24/49] Tidy up dead code from linux_driver.py --- src/textual/drivers/linux_driver.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index f0e75e71e..8f61c4fb2 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -236,17 +236,3 @@ class LinuxDriver(Driver): finally: with timer("selector.close"): selector.close() - - -if __name__ == "__main__": - from rich.console import Console - - console = Console() - - from ..app import App - - class MyApp(App): - async def on_mount(self, event: events.Mount) -> None: - self.set_timer(5, callback=self._close_messages) - - MyApp.run() From 5f4a44c6c64fa8521ca6224dec559561fdb997d3 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 10:39:43 +0000 Subject: [PATCH 25/49] Fix the devtools example of how to run an app --- docs/guide/devtools.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guide/devtools.md b/docs/guide/devtools.md index fa0d1e7e9..8643073ff 100644 --- a/docs/guide/devtools.md +++ b/docs/guide/devtools.md @@ -25,7 +25,7 @@ textual run my_app.py The `run` sub-command will first look for a `App` instance called `app` in the global scope of your Python file. If there is no `app`, it will create an instance of the first `App` class it finds and run that. -Alternatively, you can add the name of an `App` instance or class after a colon to run a specific app in the Python file. Here's an example: +Alternatively, you can add the name of an `App` instance or class after a colon to run a specific app in the Python file. Here's an example: ```bash textual run my_app.py:alternative_app @@ -119,6 +119,6 @@ class LogApp(App): self.log(self.tree) if __name__ == "__main__": - LogApp.run() + LogApp().run() ``` From 965cc7d19f8fd95d44b3c90f5607a8d0d6248773 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 10:44:08 +0000 Subject: [PATCH 26/49] Remove old test code from css/parse.py This is covered in unit tests these days. --- src/textual/css/parse.py | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/src/textual/css/parse.py b/src/textual/css/parse.py index fdfca677a..1560a009f 100644 --- a/src/textual/css/parse.py +++ b/src/textual/css/parse.py @@ -366,26 +366,3 @@ def parse( is_default_rules=is_default_rules, tie_breaker=tie_breaker, ) - - -if __name__ == "__main__": - print(parse_selectors("Foo > Bar.baz { foo: bar")) - - css = """#something { - text: on red; - transition: offset 5.51s in_out_cubic; - offset-x: 100%; -} -""" - - from textual.css.stylesheet import Stylesheet, StylesheetParseError - from rich.console import Console - - console = Console() - stylesheet = Stylesheet() - try: - stylesheet.add_source(css) - except StylesheetParseError as e: - console.print(e.errors) - print(stylesheet) - print(stylesheet.css) From 93f952b74b165bb28bb4f6df188d75c374d04443 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 10:45:07 +0000 Subject: [PATCH 27/49] Remove old test code from css/scalar.py This is covered in unit tests these days. --- src/textual/css/scalar.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/textual/css/scalar.py b/src/textual/css/scalar.py index 8c5fa39b9..0fb4f874b 100644 --- a/src/textual/css/scalar.py +++ b/src/textual/css/scalar.py @@ -383,10 +383,3 @@ def percentage_string_to_float(string: str) -> float: else: float_percentage = float(string) return float_percentage - - -if __name__ == "__main__": - print(Scalar.parse("3.14fr")) - s = Scalar.parse("23") - print(repr(s)) - print(repr(s.cells)) From c8ae2455cf580de7456bccf69c671dec51bb5daf Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 10:47:22 +0000 Subject: [PATCH 28/49] Remove old test code from css/styles.py This isn't fullt covered in unit tests yet, but the dead code can be removed and adding unit tests should likely be encouraged. --- src/textual/css/styles.py | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 17e5963a8..6b4dc1d7e 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -1037,25 +1037,3 @@ class RenderStyles(StylesBase): styles.merge(self._inline_styles) combined_css = styles.css return combined_css - - -if __name__ == "__main__": - styles = Styles() - - styles.display = "none" - styles.visibility = "hidden" - styles.border = ("solid", "rgb(10,20,30)") - styles.outline_right = ("solid", "red") - styles.text_style = "italic" - styles.dock = "bar" - styles.layers = "foo bar" - - from rich import print - - print(styles.text_style) - print(styles.text) - - print(styles) - print(styles.css) - - print(styles.extract_rules((0, 1, 0))) From f40c0bf3d0eaf1d8d920d4a65b86ad658c0ea8e5 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 10:53:46 +0000 Subject: [PATCH 29/49] Remove old test code from css/tokenize.py This is covered in unit tests these days. --- src/textual/css/tokenize.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/textual/css/tokenize.py b/src/textual/css/tokenize.py index dbec369df..e13820fd6 100644 --- a/src/textual/css/tokenize.py +++ b/src/textual/css/tokenize.py @@ -197,18 +197,3 @@ def tokenize_values(values: dict[str, str]) -> dict[str, list[Token]]: name: list(tokenize_value(value, "__name__")) for name, value in values.items() } return value_tokens - - -if __name__ == "__main__": - from rich import print - - css = """#something { - - color: rgb(10,12,23) - } - """ - # transition: offset 500 in_out_cubic; - tokens = tokenize(css, __name__) - print(list(tokens)) - - print(tokenize_values({"primary": "rgb(10,20,30)", "secondary": "#ff00ff"})) From 33eefd56cfdaec9beea254d45f4e0afe268a3a15 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 10:56:27 +0000 Subject: [PATCH 30/49] Remove old test code from renderables/blank.py This is covered in unit tests these days. --- src/textual/renderables/blank.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/textual/renderables/blank.py b/src/textual/renderables/blank.py index b83dbbc34..05552ac2f 100644 --- a/src/textual/renderables/blank.py +++ b/src/textual/renderables/blank.py @@ -25,9 +25,3 @@ class Blank: for _ in range(height): yield segment yield line - - -if __name__ == "__main__": - from rich import print - - print(Blank("red")) From 37670578ff1a3e6ec7cb3d4362a77040ecebe1d1 Mon Sep 17 00:00:00 2001 From: darrenburns Date: Thu, 17 Nov 2022 10:57:23 +0000 Subject: [PATCH 31/49] Ensure cursor visible when no placeholder in Input (#1202) * Ensure cursor visible when no placeholder in Input * Update CHANGELOG.md --- CHANGELOG.md | 1 + src/textual/widgets/_input.py | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb6eac1be..502d1fc1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed issues with animation. Now objects of different types may be animated. - Fixed containers with transparent background not showing borders https://github.com/Textualize/textual/issues/1175 - Fixed auto-width in horizontal containers https://github.com/Textualize/textual/pull/1155 +- Fixed Input cursor invisible when placeholder empty https://github.com/Textualize/textual/pull/1202 ## [0.4.0] - 2022-11-08 diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index c82e1074c..f01587120 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -176,6 +176,10 @@ class Input(Widget, can_focus=True): if self.has_focus: cursor_style = self.get_component_rich_style("input--cursor") if self._cursor_visible: + # If the placeholder is empty, there's no characters to stylise + # to make the cursor flash, so use a single space character + if len(placeholder) == 0: + placeholder = Text(" ") placeholder.stylize(cursor_style, 0, 1) return placeholder return _InputRenderable(self, self._cursor_visible) From 53b760eea2bb66f6525944aa041a954a86492e4c Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 10:58:01 +0000 Subject: [PATCH 32/49] Remove old test code from renderables/gradient.py This isn't currently covered in unit tests but should be at some point? Either way, having a test in dead code in the library doesn't help much any more. --- src/textual/renderables/gradient.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/textual/renderables/gradient.py b/src/textual/renderables/gradient.py index 7d5fb3243..a0c5379fb 100644 --- a/src/textual/renderables/gradient.py +++ b/src/textual/renderables/gradient.py @@ -36,9 +36,3 @@ class VerticalGradient: ), ) yield Segment(f"{width * ' '}\n", line_color) - - -if __name__ == "__main__": - from rich import print - - print(VerticalGradient("red", "blue")) From 265f77097645d0b11aff102f256e625eca015b16 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 11:01:31 +0000 Subject: [PATCH 33/49] Remove old test code from case.py This is covered in unit tests these days. --- src/textual/case.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/textual/case.py b/src/textual/case.py index ba34e58a8..4ae4883f3 100644 --- a/src/textual/case.py +++ b/src/textual/case.py @@ -22,7 +22,3 @@ def camel_to_snake( return f"{lower}_{upper.lower()}" return _re_snake.sub(repl, name).lower() - - -if __name__ == "__main__": - print(camel_to_snake("HelloWorldEvent")) From 3b8b0ebeb2fa22b2899c01ff9e814be3abf1ec1d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 11:02:29 +0000 Subject: [PATCH 34/49] Remove old test code from design.py This is covered in unit tests these days. --- src/textual/design.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/textual/design.py b/src/textual/design.py index 2294de748..490aa9da2 100644 --- a/src/textual/design.py +++ b/src/textual/design.py @@ -222,11 +222,3 @@ def show_design(light: ColorSystem, dark: ColorSystem) -> Table: table.add_column("Dark", justify="center") table.add_row(make_shades(light), make_shades(dark)) return table - - -if __name__ == "__main__": - from .app import DEFAULT_COLORS - - from rich import print - - print(show_design(DEFAULT_COLORS["light"], DEFAULT_COLORS["dark"])) From be0a268395ba533c4d571068f4bb17387a85f2f3 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 11:04:01 +0000 Subject: [PATCH 35/49] Remove old test code from scrollbar.py This is covered in unit tests these days. --- src/textual/scrollbar.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index 707cd67d2..f6d115f8a 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -320,18 +320,3 @@ class ScrollBarCorner(Widget): styles = self.parent.styles color = styles.scrollbar_corner_color return Blank(color) - - -if __name__ == "__main__": - from rich.console import Console - - console = Console() - - thickness = 2 - console.print(f"Bars thickness: {thickness}") - - console.print("Vertical bar:") - console.print(ScrollBarRender.render_bar(thickness=thickness)) - - console.print("Horizontal bar:") - console.print(ScrollBarRender.render_bar(vertical=False, thickness=thickness)) From e656284c57332e5a439f9a4d902d8866238fcd86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+rodrigogiraoserrao@users.noreply.github.com> Date: Thu, 17 Nov 2022 11:21:39 +0000 Subject: [PATCH 36/49] Fix docstring for some widget optional arguments. (#1204) * Update _button.py * Update tabs.py --- src/textual/widgets/_button.py | 18 +++++++++--------- src/textual/widgets/tabs.py | 16 ++++++++-------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index aa0f476fc..719cbcb0d 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -168,9 +168,9 @@ class Button(Static, can_focus=True): """Create a Button widget. Args: - label (str): The text that appears within the button. - disabled (bool): Whether the button is disabled or not. - variant (ButtonVariant): The variant of the button. + label (str, optional): The text that appears within the button. + disabled (bool, optional): Whether the button is disabled or not. + variant (ButtonVariant, optional): The variant of the button. name (str | None, optional): The name of the button. id (str | None, optional): The ID of the button in the DOM. classes (str | None, optional): The CSS classes of the button. @@ -267,8 +267,8 @@ class Button(Static, can_focus=True): """Utility constructor for creating a success Button variant. Args: - label (str): The text that appears within the button. - disabled (bool): Whether the button is disabled or not. + label (str, optional): The text that appears within the button. + disabled (bool, optional): Whether the button is disabled or not. name (str | None, optional): The name of the button. id (str | None, optional): The ID of the button in the DOM. classes(str | None, optional): The CSS classes of the button. @@ -298,8 +298,8 @@ class Button(Static, can_focus=True): """Utility constructor for creating a warning Button variant. Args: - label (str): The text that appears within the button. - disabled (bool): Whether the button is disabled or not. + label (str, optional): The text that appears within the button. + disabled (bool, optional): Whether the button is disabled or not. name (str | None, optional): The name of the button. id (str | None, optional): The ID of the button in the DOM. classes (str | None, optional): The CSS classes of the button. @@ -329,8 +329,8 @@ class Button(Static, can_focus=True): """Utility constructor for creating an error Button variant. Args: - label (str): The text that appears within the button. - disabled (bool): Whether the button is disabled or not. + label (str, optional): The text that appears within the button. + disabled (bool, optional): Whether the button is disabled or not. name (str | None, optional): The name of the button. id (str | None, optional): The ID of the button in the DOM. classes (str | None, optional): The CSS classes of the button. diff --git a/src/textual/widgets/tabs.py b/src/textual/widgets/tabs.py index 2b814d6a8..3a2af067d 100644 --- a/src/textual/widgets/tabs.py +++ b/src/textual/widgets/tabs.py @@ -177,16 +177,16 @@ class Tabs(Widget): Args: tabs (list[Tab]): A list of Tab objects defining the tabs which should be rendered. active_tab (str, optional): The name of the tab that should be active on first render. - active_tab_style (StyleType): Style to apply to the label of the active tab. - active_bar_style (StyleType): Style to apply to the underline of the active tab. - inactive_tab_style (StyleType): Style to apply to the label of inactive tabs. - inactive_bar_style (StyleType): Style to apply to the underline of inactive tabs. - inactive_text_opacity (float): Opacity of the text labels of inactive tabs. - animation_duration (float): The duration of the tab change animation, in seconds. - animation_function (str): The easing function to use for the tab change animation. + active_tab_style (StyleType, optional): Style to apply to the label of the active tab. + active_bar_style (StyleType, optional): Style to apply to the underline of the active tab. + inactive_tab_style (StyleType, optional): Style to apply to the label of inactive tabs. + inactive_bar_style (StyleType, optional): Style to apply to the underline of inactive tabs. + inactive_text_opacity (float, optional): Opacity of the text labels of inactive tabs. + animation_duration (float, optional): The duration of the tab change animation, in seconds. + animation_function (str, optional): The easing function to use for the tab change animation. tab_padding (int, optional): The padding at the side of each tab. If None, tabs will automatically be padded such that they fit the available horizontal space. - search_by_first_character (bool): If True, entering a character on your keyboard + search_by_first_character (bool, optional): If True, entering a character on your keyboard will activate the next tab (in left-to-right order) with a label starting with that character. """ From f1be4e21aa147d6e975aa0f1017f7b214a07b297 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 11:34:04 +0000 Subject: [PATCH 37/49] Move the labels in the easing preview away from Static and into Label --- src/textual/cli/previews/easing.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/textual/cli/previews/easing.py b/src/textual/cli/previews/easing.py index 6edcdb82a..b81290302 100644 --- a/src/textual/cli/previews/easing.py +++ b/src/textual/cli/previews/easing.py @@ -8,7 +8,7 @@ from textual.containers import Container, Horizontal, Vertical from textual.reactive import Reactive from textual.scrollbar import ScrollBarRender from textual.widget import Widget -from textual.widgets import Button, Footer, Static, Input +from textual.widgets import Button, Footer, Label, Input VIRTUAL_SIZE = 100 WINDOW_SIZE = 10 @@ -27,7 +27,7 @@ class Bar(Widget): animation_running = Reactive(False) DEFAULT_CSS = """ - + Bar { background: $surface; color: $error; @@ -37,7 +37,7 @@ class Bar(Widget): background: $surface; color: $success; } - + """ def watch_animation_running(self, running: bool) -> None: @@ -67,14 +67,14 @@ class EasingApp(App): self.animated_bar.position = START_POSITION duration_input = Input("1.0", placeholder="Duration", id="duration-input") - self.opacity_widget = Static( + self.opacity_widget = Label( f"[b]Welcome to Textual![/]\n\n{TEXT}", id="opacity-widget" ) yield EasingButtons() yield Vertical( Horizontal( - Static("Animation Duration:", id="label"), duration_input, id="inputs" + Label("Animation Duration:", id="label"), duration_input, id="inputs" ), Horizontal( self.animated_bar, From 5a1b436e918809f7c94c1fa03f854d6c8245bd3c Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 11:36:07 +0000 Subject: [PATCH 38/49] Move the label in the border preview away from Static and into Label --- src/textual/cli/previews/borders.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/cli/previews/borders.py b/src/textual/cli/previews/borders.py index 613343443..e5d453d86 100644 --- a/src/textual/cli/previews/borders.py +++ b/src/textual/cli/previews/borders.py @@ -1,6 +1,6 @@ from textual.app import App, ComposeResult from textual.constants import BORDERS -from textual.widgets import Button, Static +from textual.widgets import Button, Label from textual.containers import Vertical @@ -48,7 +48,7 @@ class BorderApp(App): def compose(self): yield BorderButtons() - self.text = Static(TEXT, id="text") + self.text = Label(TEXT, id="text") yield self.text def on_button_pressed(self, event: Button.Pressed) -> None: From 5dcbd07f0878db2b7ebf02f833194ee15904b8df Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 11:42:04 +0000 Subject: [PATCH 39/49] Move the labels in the colour preview away from Static and into Label --- src/textual/cli/previews/colors.css | 2 +- src/textual/cli/previews/colors.py | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/textual/cli/previews/colors.css b/src/textual/cli/previews/colors.css index 3af8eabd7..657a8aabb 100644 --- a/src/textual/cli/previews/colors.css +++ b/src/textual/cli/previews/colors.css @@ -68,7 +68,7 @@ ColorGroup.-active { } -ColorLabel { +Label { padding: 0 0 1 0; content-align: center middle; color: $text; diff --git a/src/textual/cli/previews/colors.py b/src/textual/cli/previews/colors.py index 56ac645c6..e25c70d7f 100644 --- a/src/textual/cli/previews/colors.py +++ b/src/textual/cli/previews/colors.py @@ -2,7 +2,7 @@ from textual.app import App, ComposeResult from textual.containers import Horizontal, Vertical from textual.design import ColorSystem from textual.widget import Widget -from textual.widgets import Button, Footer, Static +from textual.widgets import Button, Footer, Static, Label class ColorButtons(Vertical): @@ -28,10 +28,6 @@ class Content(Vertical): pass -class ColorLabel(Static): - pass - - class ColorsView(Vertical): def compose(self) -> ComposeResult: @@ -47,7 +43,7 @@ class ColorsView(Vertical): for color_name in ColorSystem.COLOR_NAMES: - items: list[Widget] = [ColorLabel(f'"{color_name}"')] + items: list[Widget] = [Label(f'"{color_name}"')] for level in LEVELS: color = f"{color_name}-{level}" if level else color_name item = ColorItem( From d30d3624bf5ebb765f098a49f92d916eba6ab895 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 15:51:38 +0000 Subject: [PATCH 40/49] Restore the content of docs.md --- docs.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs.md b/docs.md index ddc2bd439..63c47ed6a 100644 --- a/docs.md +++ b/docs.md @@ -1,14 +1,14 @@ # Documentation Workflow -* [Install Hatch](https://hatch.pypa.io/latest/install/) -* Run the live-reload server using `hatch run docs:serve` from the project root +* Ensure you're inside a *Python 3.10+* virtual environment +* Run the live-reload server using `mkdocs serve` from the project root * Create new pages by adding new directories and Markdown files inside `docs/*` ## Commands -- `hatch run docs:serve` - Start the live-reloading docs server. -- `hatch run docs:build` - Build the documentation site. -- `hatch run docs:help` - Print help message and exit. +- `mkdocs serve` - Start the live-reloading docs server. +- `mkdocs build` - Build the documentation site. +- `mkdocs -h` - Print help message and exit. ## Project layout From 5d7c98938f86e567ab1a72f2ea5e5ee4184994d5 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 16:13:31 +0000 Subject: [PATCH 41/49] Correct a typo in a exception string --- 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 8d993453c..8b8cc0fd6 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -560,7 +560,7 @@ class Widget(DOMNode): if before is None and after is None: raise WidgetError("One of `before` or `after` is required.") elif before is not None and after is not None: - raise WidgetError("Only one of `before`or `after` can be handled.") + raise WidgetError("Only one of `before` or `after` can be handled.") def _to_widget(child: int | Widget, called: str) -> Widget: """Ensure a given child reference is a Widget.""" From 28bc889f7b0fbadfc9d194b40e84a537b7b57bfd Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 20:24:39 +0000 Subject: [PATCH 42/49] Correct the container status of Static in the docs See https://github.com/Textualize/textual/pull/1193#discussion_r1025466567 --- docs/widgets/static.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/widgets/static.md b/docs/widgets/static.md index 4d9fc994c..c8e41606f 100644 --- a/docs/widgets/static.md +++ b/docs/widgets/static.md @@ -4,7 +4,7 @@ A widget which displays static content. Can be used for Rich renderables and can also for the base for other types of widgets. - [ ] Focusable -- [x] Container +- [ ] Container ## Example From c9484c64cacd294e297b98e185a99c0dad7c8193 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 20:25:02 +0000 Subject: [PATCH 43/49] Correct the container status of Label in the docs See https://github.com/Textualize/textual/pull/1193#discussion_r1025466567 --- docs/widgets/label.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/widgets/label.md b/docs/widgets/label.md index d4c22feac..96a31bcc6 100644 --- a/docs/widgets/label.md +++ b/docs/widgets/label.md @@ -3,7 +3,7 @@ A widget which displays static text, but which can also contain more complex Rich renderables. - [ ] Focusable -- [x] Container +- [ ] Container ## Example From 6dce7f1402311fbec5e15e5b697a20a5dd4337c6 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 17 Nov 2022 20:42:16 +0000 Subject: [PATCH 44/49] Fix a typo in the CHANGELOG --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9092208f..4f95e4d10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - It is now possible to `await` a `Widget.remove`. https://github.com/Textualize/textual/issues/1094 - It is now possible to `await` a `DOMQuery.remove`. Note that this changes - the return value of `DOMQuery.remove`, which uses to return `self`. + the return value of `DOMQuery.remove`, which used to return `self`. https://github.com/Textualize/textual/issues/1094 - Added Pilot.wait_for_animation - Added `Widget.move_child` https://github.com/Textualize/textual/issues/1121 From 0943d5a263bdb072b7937aa75e09eb2c11f7bccc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rodrigo=20Gir=C3=A3o=20Serr=C3=A3o?= <5621605+RodrigoGiraoSerrao@users.noreply.github.com> Date: Fri, 18 Nov 2022 13:14:40 +0000 Subject: [PATCH 45/49] Fix docstring signatures. --- src/textual/reactive.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 7cd88d630..c71070ba3 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -43,7 +43,7 @@ class Reactive(Generic[ReactiveType]): layout (bool, optional): Perform a layout on change. Defaults to False. repaint (bool, optional): Perform a repaint on change. Defaults to True. init (bool, optional): Call watchers on initialize (post mount). Defaults to False. - always_update(bool, optional): Call watchers even when the new value equals the old value. Defaults to False. + always_update (bool, optional): Call watchers even when the new value equals the old value. Defaults to False. """ def __init__( @@ -76,7 +76,7 @@ class Reactive(Generic[ReactiveType]): default (ReactiveType | Callable[[], ReactiveType]): A default value or callable that returns a default. layout (bool, optional): Perform a layout on change. Defaults to False. repaint (bool, optional): Perform a repaint on change. Defaults to True. - always_update(bool, optional): Call watchers even when the new value equals the old value. Defaults to False. + always_update (bool, optional): Call watchers even when the new value equals the old value. Defaults to False. Returns: Reactive: A Reactive instance which calls watchers or initialize. @@ -292,7 +292,7 @@ class reactive(Reactive[ReactiveType]): layout (bool, optional): Perform a layout on change. Defaults to False. repaint (bool, optional): Perform a repaint on change. Defaults to True. init (bool, optional): Call watchers on initialize (post mount). Defaults to True. - always_update(bool, optional): Call watchers even when the new value equals the old value. Defaults to False. + always_update (bool, optional): Call watchers even when the new value equals the old value. Defaults to False. """ def __init__( From 50860b98b623b97d20688658cfbbcd41513b2f13 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Fri, 18 Nov 2022 13:43:27 +0000 Subject: [PATCH 46/49] Update the 5x5 example to make use of callable screens Now that #1054 has made it into main, make use of it. --- examples/five_by_five.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/five_by_five.py b/examples/five_by_five.py index f4574a8a4..3e668f04d 100644 --- a/examples/five_by_five.py +++ b/examples/five_by_five.py @@ -311,7 +311,7 @@ class FiveByFive(App[None]): CSS_PATH = "five_by_five.css" """The name of the stylesheet for the app.""" - SCREENS = {"help": Help()} + SCREENS = {"help": Help} """The pre-loaded screens for the application.""" BINDINGS = [("ctrl+d", "toggle_dark", "Toggle Dark Mode")] From fa5ac0dd6887fcda601d1f86e05f6eeedd612c86 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 18 Nov 2022 13:59:58 +0000 Subject: [PATCH 47/49] simplified cache_key --- CHANGELOG.md | 1 + src/textual/css/styles.py | 39 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f95e4d10..f6cb6dc7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Watchers are now called immediately when setting the attribute if they are synchronous. https://github.com/Textualize/textual/pull/1145 - Widget.call_later has been renamed to Widget.call_after_refresh. - Button variant values are now checked at runtime. https://github.com/Textualize/textual/issues/1189 +- Added caching of some properties in Styles object ### Fixed diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 6b4dc1d7e..006f12910 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -557,6 +557,7 @@ class StylesBase(ABC): class Styles(StylesBase): node: DOMNode | None = None _rules: RulesMap = field(default_factory=dict) + _updates: int = 0 important: set[str] = field(default_factory=set) @@ -577,6 +578,7 @@ class Styles(StylesBase): Returns: bool: ``True`` if a rule was cleared, or ``False`` if it was already not set. """ + self._updates += 1 return self._rules.pop(rule, None) is not None def get_rules(self) -> RulesMap: @@ -592,6 +594,7 @@ class Styles(StylesBase): Returns: bool: ``True`` if the rule changed, otherwise ``False``. """ + self._updates += 1 if value is None: return self._rules.pop(rule, None) is not None current = self._rules.get(rule) @@ -610,6 +613,7 @@ class Styles(StylesBase): def reset(self) -> None: """Reset the rules to initial state.""" + self._updates += 1 self._rules.clear() def merge(self, other: Styles) -> None: @@ -618,10 +622,11 @@ class Styles(StylesBase): Args: other (Styles): A Styles object. """ - + self._updates += 1 self._rules.update(other._rules) def merge_rules(self, rules: RulesMap) -> None: + self._updates += 1 self._rules.update(rules) def extract_rules( @@ -929,6 +934,18 @@ class RenderStyles(StylesBase): self._base_styles = base self._inline_styles = inline_styles self._animate: BoundAnimator | None = None + self._updates: int = 0 + self._rich_style: tuple[int, Style] | None = None + self._gutter: tuple[int, Spacing] | None = None + + @property + def _cache_key(self) -> int: + """A key key, that changes when any style is changed. + + Returns: + int: An opaque integer. + """ + return self._updates + self._base_styles._updates + self._inline_styles._updates @property def base(self) -> Styles: @@ -946,6 +963,21 @@ class RenderStyles(StylesBase): assert self.node is not None return self.node.rich_style + @property + def gutter(self) -> Spacing: + """Get space around widget. + + Returns: + Spacing: Space around widget content. + """ + if self._gutter is not None: + cache_key, gutter = self._gutter + if cache_key == self._updates: + return gutter + gutter = self.padding + self.border.spacing + self._gutter = (self._cache_key, gutter) + return gutter + def animate( self, attribute: str, @@ -972,6 +1004,7 @@ class RenderStyles(StylesBase): """ if self._animate is None: + assert self.node is not None self._animate = self.node.app.animator.bind(self) assert self._animate is not None self._animate( @@ -1003,16 +1036,19 @@ class RenderStyles(StylesBase): def merge_rules(self, rules: RulesMap) -> None: self._inline_styles.merge_rules(rules) + self._updates += 1 def reset(self) -> None: """Reset the rules to initial state.""" self._inline_styles.reset() + self._updates += 1 def has_rule(self, rule: str) -> bool: """Check if a rule has been set.""" return self._inline_styles.has_rule(rule) or self._base_styles.has_rule(rule) def set_rule(self, rule: str, value: object | None) -> bool: + self._updates += 1 return self._inline_styles.set_rule(rule, value) def get_rule(self, rule: str, default: object = None) -> object: @@ -1022,6 +1058,7 @@ class RenderStyles(StylesBase): def clear_rule(self, rule_name: str) -> bool: """Clear a rule (from inline).""" + self._updates += 1 return self._inline_styles.clear_rule(rule_name) def get_rules(self) -> RulesMap: From 36664ef7ae88303f02c282c5f3cc9b080098969b Mon Sep 17 00:00:00 2001 From: darrenburns Date: Fri, 18 Nov 2022 14:05:45 +0000 Subject: [PATCH 48/49] Sensible default key displays + allow users to override key displays at the `App` level (#1213) * Get rid of string split key display * Include screen level bindings when no widget is focused * Add default key display mappings * Allow user to customise key display at app level * Better docstring * Update CHANGELOG.md --- CHANGELOG.md | 2 + src/textual/app.py | 23 ++- src/textual/keys.py | 28 ++++ src/textual/widgets/_footer.py | 12 +- .../__snapshots__/test_snapshots.ambr | 157 ++++++++++++++++++ .../snapshot_apps/key_display.py | 36 ++++ tests/snapshot_tests/test_snapshots.py | 6 + 7 files changed, 255 insertions(+), 9 deletions(-) create mode 100644 tests/snapshot_tests/snapshot_apps/key_display.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f6cb6dc7e..1323d53bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `Widget.move_child` https://github.com/Textualize/textual/issues/1121 - Added a `Label` widget https://github.com/Textualize/textual/issues/1190 - Support lazy-instantiated Screens (callables in App.SCREENS) https://github.com/Textualize/textual/pull/1185 +- Display of keys in footer has more sensible defaults https://github.com/Textualize/textual/pull/1213 +- Add App.get_key_display, allowing custom key_display App-wide https://github.com/Textualize/textual/pull/1213 ### Changed diff --git a/src/textual/app.py b/src/textual/app.py index e74a90f8a..ba95fb6ef 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -56,12 +56,12 @@ from .drivers.headless_driver import HeadlessDriver from .features import FeatureFlag, parse_features from .file_monitor import FileMonitor from .geometry import Offset, Region, Size -from .keys import REPLACED_KEYS +from .keys import REPLACED_KEYS, _get_key_display from .messages import CallbackType from .reactive import Reactive from .renderables.blank import Blank from .screen import Screen -from .widget import AwaitMount, Widget, MountError +from .widget import AwaitMount, Widget if TYPE_CHECKING: from .devtools.client import DevtoolsClient @@ -102,7 +102,6 @@ DEFAULT_COLORS = { ComposeResult = Iterable[Widget] RenderResult = RenderableType - AutopilotCallbackType: TypeAlias = "Callable[[Pilot], Coroutine[Any, Any, None]]" @@ -668,6 +667,22 @@ class App(Generic[ReturnType], DOMNode): keys, action, description, show=show, key_display=key_display ) + def get_key_display(self, key: str) -> str: + """For a given key, return how it should be displayed in an app + (e.g. in the Footer widget). + By key, we refer to the string used in the "key" argument for + a Binding instance. By overriding this method, you can ensure that + keys are displayed consistently throughout your app, without + needing to add a key_display to every binding. + + Args: + key (str): The binding key string. + + Returns: + str: The display string for the input key. + """ + return _get_key_display(key) + async def _press_keys(self, keys: Iterable[str]) -> None: """A task to send key events.""" app = self @@ -705,7 +720,7 @@ class App(Generic[ReturnType], DOMNode): # This conditional sleep can be removed after that issue is closed. if key == "tab": await asyncio.sleep(0.05) - await asyncio.sleep(0.02) + await asyncio.sleep(0.025) await app._animator.wait_for_idle() @asynccontextmanager diff --git a/src/textual/keys.py b/src/textual/keys.py index e6d386c68..aac14c138 100644 --- a/src/textual/keys.py +++ b/src/textual/keys.py @@ -1,5 +1,6 @@ from __future__ import annotations +import unicodedata from enum import Enum @@ -219,7 +220,34 @@ KEY_ALIASES = { "ctrl+j": ["newline"], } +KEY_DISPLAY_ALIASES = { + "up": "↑", + "down": "↓", + "left": "←", + "right": "→", + "backspace": "⌫", + "escape": "ESC", + "enter": "⏎", +} + def _get_key_aliases(key: str) -> list[str]: """Return all aliases for the given key, including the key itself""" return [key] + KEY_ALIASES.get(key, []) + + +def _get_key_display(key: str) -> str: + """Given a key (i.e. the `key` string argument to Binding __init__), + return the value that should be displayed in the app when referring + to this key (e.g. in the Footer widget).""" + display_alias = KEY_DISPLAY_ALIASES.get(key) + if display_alias: + return display_alias + + original_key = REPLACED_KEYS.get(key, key) + try: + unicode_character = unicodedata.lookup(original_key.upper().replace("_", " ")) + except KeyError: + return original_key.upper() + + return unicode_character diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index fe39d8b5a..fd8353ab6 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -7,6 +7,7 @@ from rich.console import RenderableType from rich.text import Text from .. import events +from ..keys import _get_key_display from ..reactive import Reactive, watch from ..widget import Widget @@ -99,11 +100,12 @@ class Footer(Widget): for action, bindings in action_to_bindings.items(): binding = bindings[0] - key_display = ( - binding.key.upper() - if binding.key_display is None - else binding.key_display - ) + if binding.key_display is None: + key_display = self.app.get_key_display(binding.key) + if key_display is None: + key_display = binding.key.upper() + else: + key_display = binding.key_display hovered = self.highlight_key == binding.key key_text = Text.assemble( (f" {key_display} ", highlight_key_style if hovered else key_style), diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index cb6252b33..354a78d0f 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -6166,6 +6166,163 @@ ''' # --- +# name: test_key_display + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + KeyDisplayApp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +  ?  Question  ^q  Quit app  Escape!  Escape  A  Letter A  + + + + + ''' +# --- # name: test_layers ''' diff --git a/tests/snapshot_tests/snapshot_apps/key_display.py b/tests/snapshot_tests/snapshot_apps/key_display.py new file mode 100644 index 000000000..9762bcff6 --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/key_display.py @@ -0,0 +1,36 @@ +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.widgets import Footer + + +class KeyDisplayApp(App): + """Tests how keys are displayed in the Footer, and ensures + that overriding the key_displays works as expected. + Exercises both the built-in Textual key display replacements, + and user supplied replacements. + Will break when we update the Footer - but we should add a similar + test (or updated snapshot) for the updated Footer.""" + BINDINGS = [ + Binding("question_mark", "question", "Question"), + Binding("ctrl+q", "quit", "Quit app"), + Binding("escape", "escape", "Escape"), + Binding("a", "a", "Letter A"), + ] + + def compose(self) -> ComposeResult: + yield Footer() + + def get_key_display(self, key: str) -> str: + key_display_replacements = { + "escape": "Escape!", + "ctrl+q": "^q", + } + display = key_display_replacements.get(key) + if display: + return display + return super().get_key_display(key) + + +app = KeyDisplayApp() +if __name__ == '__main__': + app.run() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 95e83c278..f5116cc78 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -118,3 +118,9 @@ def test_css_property(file_name, snap_compare): def test_multiple_css(snap_compare): # Interaction between multiple CSS files and app-level/classvar CSS assert snap_compare("snapshot_apps/multiple_css/multiple_css.py") + + +# --- Other --- + +def test_key_display(snap_compare): + assert snap_compare(SNAPSHOT_APPS_DIR / "key_display.py") From 5e2a5668a09a44135b38334ecc08046791f0d47a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 18 Nov 2022 14:17:18 +0000 Subject: [PATCH 49/49] ws --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1323d53bf..56981a786 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Change Log + All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/)