From 67eb5e753adb50fafdb375fb2c0d104a0215bbeb Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 23 Jan 2023 17:25:06 +0000 Subject: [PATCH 01/83] Add support for deeply expanding/collapsing/toggling nodes This commit moves the bulk of the work of each action into an internal method that does everything *apart* from invalidating the tree. The idea being that all of the expanded states get updated, all of the update counts get updated, and then finally one single tree invalidation takes place (the latter taking place in the public method, which calls the related internal method). See #1430. --- src/textual/widgets/_tree.py | 59 +++++++++++++++++++++++++++++++----- 1 file changed, 52 insertions(+), 7 deletions(-) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index fc930f945..05162b3e2 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import ClassVar, Generic, NewType, TypeVar +from typing import ClassVar, Generic, Iterator, NewType, TypeVar import rich.repr from rich.style import NULL_STYLE, Style @@ -159,22 +159,67 @@ class TreeNode(Generic[TreeDataType]): self._allow_expand = allow_expand self._updates += 1 - def expand(self) -> None: - """Expand a node (show its children).""" + def _expand(self, expand_all) -> None: + """Mark a node as expanded (its children are shown). + + Args: + expand_all: If `True` expand the all offspring at all depths. + """ self._expanded = True self._updates += 1 + if expand_all: + for child in self.children: + child._expand(expand_all=True) + + def expand(self, expand_all: bool = False) -> None: + """Expand a node (show its children). + + Args: + expand_all: If `True` expand the all offspring at all depths. + """ + self._expand(expand_all) self._tree._invalidate() - def collapse(self) -> None: - """Collapse the node (hide children).""" + def _collapse(self, collapse_all: bool) -> None: + """Mark a node as collapsed (its children are hidden). + + Args: + collapse_all: If `True` collapse the all offspring at all depths. + """ self._expanded = False + if collapse_all: + for child in self.children: + child._collapse(collapse_all=True) self._updates += 1 + + def collapse(self, collapse_all: bool = True) -> None: + """Collapse the node (hide children). + + Args: + collapse_all: If `True` collapse the all offspring at all depths. + """ + self._collapse(collapse_all) self._tree._invalidate() - def toggle(self) -> None: - """Toggle the expanded state.""" + def _toggle(self, toggle_all: bool) -> None: + """Toggle the expanded state of the node. + + Args: + toggle_all: If `True` toggle the all offspring at all depths. + """ self._expanded = not self._expanded + if toggle_all: + for child in self.children: + child._toggle(toggle_all=True) self._updates += 1 + + def toggle(self, toggle_all: bool = True) -> None: + """Toggle the expanded state. + + Args: + toggle_all: If `True` toggle the all offspring at all depths. + """ + self._toggle(toggle_all) self._tree._invalidate() @property From 2ac3a03471fbefbee05e817850b6a5c797e1042e Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 23 Jan 2023 20:56:09 +0000 Subject: [PATCH 02/83] Add a missing type hint to TreeNode._expand --- src/textual/widgets/_tree.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 05162b3e2..c40dddf24 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -159,7 +159,7 @@ class TreeNode(Generic[TreeDataType]): self._allow_expand = allow_expand self._updates += 1 - def _expand(self, expand_all) -> None: + def _expand(self, expand_all: bool) -> None: """Mark a node as expanded (its children are shown). Args: From c57ca884ca32874057ad2e3037d035720a4b3f70 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 23 Jan 2023 21:09:21 +0000 Subject: [PATCH 03/83] Correct some terrible English --- src/textual/widgets/_tree.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index c40dddf24..0a076f0b7 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -163,7 +163,7 @@ class TreeNode(Generic[TreeDataType]): """Mark a node as expanded (its children are shown). Args: - expand_all: If `True` expand the all offspring at all depths. + expand_all: If `True` expand all offspring at all depths. """ self._expanded = True self._updates += 1 @@ -175,7 +175,7 @@ class TreeNode(Generic[TreeDataType]): """Expand a node (show its children). Args: - expand_all: If `True` expand the all offspring at all depths. + expand_all: If `True` expand all offspring at all depths. """ self._expand(expand_all) self._tree._invalidate() @@ -184,7 +184,7 @@ class TreeNode(Generic[TreeDataType]): """Mark a node as collapsed (its children are hidden). Args: - collapse_all: If `True` collapse the all offspring at all depths. + collapse_all: If `True` collapse all offspring at all depths. """ self._expanded = False if collapse_all: @@ -196,7 +196,7 @@ class TreeNode(Generic[TreeDataType]): """Collapse the node (hide children). Args: - collapse_all: If `True` collapse the all offspring at all depths. + collapse_all: If `True` collapse all offspring at all depths. """ self._collapse(collapse_all) self._tree._invalidate() @@ -205,7 +205,7 @@ class TreeNode(Generic[TreeDataType]): """Toggle the expanded state of the node. Args: - toggle_all: If `True` toggle the all offspring at all depths. + toggle_all: If `True` toggle all offspring at all depths. """ self._expanded = not self._expanded if toggle_all: @@ -217,7 +217,7 @@ class TreeNode(Generic[TreeDataType]): """Toggle the expanded state. Args: - toggle_all: If `True` toggle the all offspring at all depths. + toggle_all: If `True` toggle all offspring at all depths. """ self._toggle(toggle_all) self._tree._invalidate() From 91d6f2b9731f545027a9cfee5fa17f95291e296c Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 23 Jan 2023 21:12:16 +0000 Subject: [PATCH 04/83] Force keyword argument use for expand/collapse/toggle_all It might seem excessive for just a single argument, but I feel it's worthwhile doing it here. It's a single boolean parameter on each of the methods that, left bare, will always end up reading badly. Consider: tree.toggle( True ) vs: tree.toggle( toggle_all=True ) the former looks awkward at best and ugly at worst; toggle True? What does that even mean? The latter, while a touch more verbose, makes it really clear what's going on. Trying this on for size. --- src/textual/widgets/_tree.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 0a076f0b7..e13ffef0a 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -171,7 +171,7 @@ class TreeNode(Generic[TreeDataType]): for child in self.children: child._expand(expand_all=True) - def expand(self, expand_all: bool = False) -> None: + def expand(self, *, expand_all: bool = False) -> None: """Expand a node (show its children). Args: @@ -192,7 +192,7 @@ class TreeNode(Generic[TreeDataType]): child._collapse(collapse_all=True) self._updates += 1 - def collapse(self, collapse_all: bool = True) -> None: + def collapse(self, *, collapse_all: bool = True) -> None: """Collapse the node (hide children). Args: @@ -213,7 +213,7 @@ class TreeNode(Generic[TreeDataType]): child._toggle(toggle_all=True) self._updates += 1 - def toggle(self, toggle_all: bool = True) -> None: + def toggle(self, *, toggle_all: bool = True) -> None: """Toggle the expanded state. Args: From 20636a55344afd9b8e8a287ca1f1839b234b36fc Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 23 Jan 2023 21:21:56 +0000 Subject: [PATCH 05/83] Remove some duplication of effort No need to set the keyword to True when I can just pass the parameter's value in anyway. This reads a bit nicer. --- src/textual/widgets/_tree.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index e13ffef0a..6e7ec422a 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -169,7 +169,7 @@ class TreeNode(Generic[TreeDataType]): self._updates += 1 if expand_all: for child in self.children: - child._expand(expand_all=True) + child._expand(expand_all) def expand(self, *, expand_all: bool = False) -> None: """Expand a node (show its children). @@ -189,7 +189,7 @@ class TreeNode(Generic[TreeDataType]): self._expanded = False if collapse_all: for child in self.children: - child._collapse(collapse_all=True) + child._collapse(collapse_all) self._updates += 1 def collapse(self, *, collapse_all: bool = True) -> None: @@ -210,7 +210,7 @@ class TreeNode(Generic[TreeDataType]): self._expanded = not self._expanded if toggle_all: for child in self.children: - child._toggle(toggle_all=True) + child._toggle(toggle_all) self._updates += 1 def toggle(self, *, toggle_all: bool = True) -> None: From bf8a2745bcd65d02568fe559002f0723e18d0bf0 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 23 Jan 2023 21:25:48 +0000 Subject: [PATCH 06/83] Update the CHANGELOG --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b014c4fbb..dc513c2a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [0.11.0] - Unreleased +### Added + +- Added an optional `expand_all` parameter to `TreeNode.expand` https://github.com/Textualize/textual/issues/1430 +- Added an optional `collapse_all` parameter to `TreeNode.collapse` https://github.com/Textualize/textual/issues/1430 +- Added an optional `toggle_all` parameter to `TreeNode.toggle` https://github.com/Textualize/textual/issues/1430 + ### Fixed - Fixed stuck screen https://github.com/Textualize/textual/issues/1632 From 8815d82ba4865238eca875575246d1e9c1a33d02 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 24 Jan 2023 10:36:42 +0000 Subject: [PATCH 07/83] Remove unnecessary import This got added while I was experimenting with something earlier, and I forgot to remove it and didn't notice it slip in with a commit. --- src/textual/widgets/_tree.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 6e7ec422a..f39076c22 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import ClassVar, Generic, Iterator, NewType, TypeVar +from typing import ClassVar, Generic, NewType, TypeVar import rich.repr from rich.style import NULL_STYLE, Style From 6743ec6a3070756cb1428b0f901c6bf3cf000963 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 24 Jan 2023 10:47:44 +0000 Subject: [PATCH 08/83] Fix the defaults for collapse_all and toggle_all Somehow I'd managed to typo them as True when they obviously should be False to maintain backward-compatibility (and generally this is the only sensible default). --- src/textual/widgets/_tree.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index f39076c22..aa03fcf3e 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -192,7 +192,7 @@ class TreeNode(Generic[TreeDataType]): child._collapse(collapse_all) self._updates += 1 - def collapse(self, *, collapse_all: bool = True) -> None: + def collapse(self, *, collapse_all: bool = False) -> None: """Collapse the node (hide children). Args: @@ -213,7 +213,7 @@ class TreeNode(Generic[TreeDataType]): child._toggle(toggle_all) self._updates += 1 - def toggle(self, *, toggle_all: bool = True) -> None: + def toggle(self, *, toggle_all: bool = False) -> None: """Toggle the expanded state. Args: From a36583612c78d21fe8bbd2cce2bb6da2caf70fe8 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 24 Jan 2023 11:07:40 +0000 Subject: [PATCH 09/83] Change TreeNode.toggle to act only from the source node See https://github.com/Textualize/textual/pull/1644#issuecomment-1401720808 where Darren raises the excellent point that while the "technically correct" approach that I had was... well, technically correct I guess (it toggled all the nodes from the target node down), it didn't have what was likely the desired effect. So this commit does away with the previous logic for doing the toggle and instead simply calls on expand or collapse depending on the state of the source node. --- src/textual/widgets/_tree.py | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index aa03fcf3e..bb94c8c01 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -201,26 +201,16 @@ class TreeNode(Generic[TreeDataType]): self._collapse(collapse_all) self._tree._invalidate() - def _toggle(self, toggle_all: bool) -> None: - """Toggle the expanded state of the node. - - Args: - toggle_all: If `True` toggle all offspring at all depths. - """ - self._expanded = not self._expanded - if toggle_all: - for child in self.children: - child._toggle(toggle_all) - self._updates += 1 - def toggle(self, *, toggle_all: bool = False) -> None: """Toggle the expanded state. Args: toggle_all: If `True` toggle all offspring at all depths. """ - self._toggle(toggle_all) - self._tree._invalidate() + if self._expanded: + self.collapse(collapse_all=toggle_all) + else: + self.expand(expand_all=toggle_all) @property def label(self) -> TextType: From 2841936e15423557ad9a7a236c82d69cd5a0a3af Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 24 Jan 2023 11:36:13 +0000 Subject: [PATCH 10/83] Clarify how TreeNode.toggle works at depth --- src/textual/widgets/_tree.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index bb94c8c01..93425625a 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -205,7 +205,8 @@ class TreeNode(Generic[TreeDataType]): """Toggle the expanded state. Args: - toggle_all: If `True` toggle all offspring at all depths. + toggle_all: If `True` set the expanded state of all offspring + nodes at all depths to match this node's toggled state. """ if self._expanded: self.collapse(collapse_all=toggle_all) From a01563b0684a48252f6b26ee86af431d742a730a 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: Tue, 24 Jan 2023 16:29:13 +0000 Subject: [PATCH 11/83] Add mechanism to keep track of scheduled animations. --- src/textual/_animator.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index fb2b4015b..2f7f5a89f 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -22,6 +22,8 @@ if TYPE_CHECKING: from textual.app import App EasingFunction = Callable[[float], float] +AnimationKey = tuple[int, str] +"""Animation keys are the id of the object and the attribute being animated.""" class AnimationError(Exception): @@ -166,10 +168,19 @@ class BoundAnimator: class Animator: - """An object to manage updates to a given attribute over a period of time.""" + """An object to manage updates to a given attribute over a period of time. + + Attrs: + _animations: Dictionary that maps animation keys to the corresponding animation + instances. + _scheduled: Keys corresponding to animations that have been scheduled but not yet + started. + app: The app that owns the animator object. + """ def __init__(self, app: App, frames_per_second: int = 60) -> None: - self._animations: dict[tuple[object, str], Animation] = {} + self._animations: dict[AnimationKey, Animation] = {} + self._scheduled: set[AnimationKey] = set() self.app = app self._timer = Timer( app, @@ -196,7 +207,7 @@ class Animator: self._idle_event.set() def bind(self, obj: object) -> BoundAnimator: - """Bind the animator to a given objects.""" + """Bind the animator to a given object.""" return BoundAnimator(self, obj) def animate( @@ -237,6 +248,7 @@ class Animator: on_complete=on_complete, ) if delay: + self._scheduled.add((id(obj), attribute)) self.app.set_timer(delay, animate_callback) else: animate_callback() @@ -273,13 +285,14 @@ class Animator: duration is None and speed is not None ), "An Animation should have a duration OR a speed" + animation_key = (id(obj), attribute) + self._scheduled.discard(animation_key) + if final_value is ...: final_value = value start_time = self._get_time() - animation_key = (id(obj), attribute) - easing_function = EASING[easing] if isinstance(easing, str) else easing animation: Animation | None = None From 1d18ac35c513375aca75185fa2b3f22f8605b404 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: Tue, 24 Jan 2023 16:29:27 +0000 Subject: [PATCH 12/83] Add method to check if something is being animated. --- src/textual/_animator.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 2f7f5a89f..3874a07e1 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -210,6 +210,11 @@ class Animator: """Bind the animator to a given object.""" return BoundAnimator(self, obj) + def is_being_animated(self, obj: object, attribute: str) -> bool: + """Does the object/attribute pair have an ongoing or scheduled animation?""" + key = (id(obj), attribute) + return key in self._animations or key in self._scheduled + def animate( self, obj: object, From 34a08b7fe58ebab2eec7c43d185da459bbdecc5f 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: Tue, 24 Jan 2023 16:29:49 +0000 Subject: [PATCH 13/83] Check if a rule is being animated when updating it. This is the fix for #1372 because the styles that were sticking had nothing to do with `:hover` per se. The issue was in the fact that we were trying to start a second animation (back to the default background color) while the first animation hadn't started yet, so we would skip the creation of the new animation but the old one would still run to completion. This issue also affects animations that start with a delay. If we set an animation A -> B with a delay of 10s and 1s later we set an animation B -> A, then the animation A -> B will run after 9s but the animation B -> A will not run because it was not created in the first place. --- src/textual/css/stylesheet.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 9b00add69..0b676776d 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -449,6 +449,8 @@ class Stylesheet: get_new_render_rule = new_render_rules.get if animate: + animator = node.app.animator + base = node.styles.base for key in modified_rule_keys: # Get old and new render rules old_render_value = get_current_render_rule(key) @@ -456,13 +458,18 @@ class Stylesheet: # Get new rule value (may be None) new_value = rules.get(key) - # Check if this can / should be animated - if is_animatable(key) and new_render_value != old_render_value: + # Check if this can / should be animated. It doesn't suffice to check + # if the current and target values are different because a previous + # animation may have been scheduled but may have not started yet. + if is_animatable(key) and ( + new_render_value != old_render_value + or animator.is_being_animated(base, key) + ): transition = new_styles._get_transition(key) if transition is not None: duration, easing, delay = transition - node.app.animator.animate( - node.styles.base, + animator.animate( + base, key, new_render_value, final_value=new_value, From 15c95db9608e7be28c530cc5a2ba8aa0785567b6 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: Tue, 24 Jan 2023 18:12:25 +0000 Subject: [PATCH 14/83] Add mechanism to wait for current and scheduled animations. --- src/textual/_animator.py | 13 +++++++++++++ src/textual/pilot.py | 6 +++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 3874a07e1..85ad72e87 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -190,11 +190,15 @@ class Animator: callback=self, pause=True, ) + # Flag if no animations are currently taking place. self._idle_event = asyncio.Event() + # Flag if no animations are currently taking place and none are scheduled. + self._fully_idle_event = asyncio.Event() async def start(self) -> None: """Start the animator task.""" self._idle_event.set() + self._fully_idle_event.set() self._timer.start() async def stop(self) -> None: @@ -205,6 +209,7 @@ class Animator: pass finally: self._idle_event.set() + self._fully_idle_event.set() def bind(self, obj: object) -> BoundAnimator: """Bind the animator to a given object.""" @@ -254,6 +259,7 @@ class Animator: ) if delay: self._scheduled.add((id(obj), attribute)) + self._fully_idle_event.clear() self.app.set_timer(delay, animate_callback) else: animate_callback() @@ -360,11 +366,14 @@ class Animator: self._animations[animation_key] = animation self._timer.resume() self._idle_event.clear() + self._fully_idle_event.clear() async def __call__(self) -> None: if not self._animations: self._timer.pause() self._idle_event.set() + if not self._scheduled: + self._fully_idle_event.set() else: animation_time = self._get_time() animation_keys = list(self._animations.keys()) @@ -386,3 +395,7 @@ class Animator: async def wait_for_idle(self) -> None: """Wait for any animations to complete.""" await self._idle_event.wait() + + async def wait_for_fully_idle(self) -> None: + """Wait for any current and scheduled animations to complete.""" + await self._fully_idle_event.wait() diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 13ae9fe03..d6daf1a78 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -43,9 +43,13 @@ class Pilot(Generic[ReturnType]): await asyncio.sleep(delay) async def wait_for_animation(self) -> None: - """Wait for any animation to complete.""" + """Wait for any current animation to complete.""" await self._app.animator.wait_for_idle() + async def wait_for_scheduled_animations(self) -> None: + """Wait for any current and scheduled animations to complete.""" + await self._app.animator.wait_for_fully_idle() + async def exit(self, result: ReturnType) -> None: """Exit the app with the given result. From c8ff5bd14bb74277b6c15472e5f719c549c98381 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: Tue, 24 Jan 2023 18:30:24 +0000 Subject: [PATCH 15/83] Add tests. --- tests/test_animation.py | 122 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 121 insertions(+), 1 deletion(-) diff --git a/tests/test_animation.py b/tests/test_animation.py index 0498e7693..14d57f6fc 100644 --- a/tests/test_animation.py +++ b/tests/test_animation.py @@ -9,7 +9,7 @@ class AnimApp(App): #foo { height: 1; } - + """ def compose(self) -> ComposeResult: @@ -37,3 +37,123 @@ async def test_animate_height() -> None: assert elapsed >= 0.5 # Check the height reached the maximum assert static.styles.height.value == 100 + + +async def test_scheduling_animation() -> None: + """Test that scheduling an animation works.""" + + app = AnimApp() + delay = 0.1 + + async with app.run_test() as pilot: + styles = app.query_one(Static).styles + styles.background = "black" + + styles.animate("background", "white", delay=delay, duration=0) + + await pilot.pause(0.9 * delay) + assert styles.background.rgb == (0, 0, 0) # Still black + + await pilot.wait_for_scheduled_animations() + assert styles.background.rgb == (255, 255, 255) + + +async def test_wait_for_current_animations() -> None: + """Test that we can wait only for the current animations taking place.""" + + app = AnimApp() + + delay = 10 + + async with app.run_test() as pilot: + styles = app.query_one(Static).styles + styles.animate("height", 100, duration=0.1) + start = perf_counter() + styles.animate("height", 200, duration=0.1, delay=delay) + + # Wait for the first animation to finish + await pilot.wait_for_animation() + elapsed = perf_counter() - start + assert elapsed < (delay / 2) + + +async def test_wait_for_current_and_scheduled_animations() -> None: + """Test that we can wait for current and scheduled animations.""" + + app = AnimApp() + + async with app.run_test() as pilot: + styles = app.query_one(Static).styles + + start = perf_counter() + styles.animate("height", 50, duration=0.01) + styles.animate("background", "black", duration=0.01, delay=0.05) + + await pilot.wait_for_scheduled_animations() + elapsed = perf_counter() - start + assert elapsed >= 0.06 + assert styles.background.rgb == (0, 0, 0) + + +async def test_reverse_animations() -> None: + """Test that you can create reverse animations. + + Regression test for #1372 https://github.com/Textualize/textual/issues/1372 + """ + + app = AnimApp() + + async with app.run_test() as pilot: + static = app.query_one(Static) + styles = static.styles + + # Starting point. + styles.background = "black" + assert styles.background.rgb == (0, 0, 0) + + # First, make sure we can go from black to white and back, step by step. + styles.animate("background", "white", duration=0.01) + await pilot.wait_for_animation() + assert styles.background.rgb == (255, 255, 255) + + styles.animate("background", "black", duration=0.01) + await pilot.wait_for_animation() + assert styles.background.rgb == (0, 0, 0) + + # Now, the actual test is to make sure we go back to black if scheduling both at once. + styles.animate("background", "white", duration=0.01) + styles.animate("background", "black", duration=0.01) + await pilot.wait_for_animation() + assert styles.background.rgb == (0, 0, 0) + + +async def test_schedule_reverse_animations() -> None: + """Test that you can schedule reverse animations. + + Regression test for #1372 https://github.com/Textualize/textual/issues/1372 + """ + + app = AnimApp() + + async with app.run_test() as pilot: + static = app.query_one(Static) + styles = static.styles + + # Starting point. + styles.background = "black" + assert styles.background.rgb == (0, 0, 0) + + # First, make sure we can go from black to white and back, step by step. + styles.animate("background", "white", delay=0.01, duration=0.01) + await pilot.wait_for_scheduled_animations() + assert styles.background.rgb == (255, 255, 255) + + styles.animate("background", "black", delay=0.01, duration=0.01) + await pilot.wait_for_scheduled_animations() + assert styles.background.rgb == (0, 0, 0) + + # Now, the actual test is to make sure we go back to black if scheduling both at once. + styles.animate("background", "white", delay=0.01, duration=0.01) + styles.animate("background", "black", delay=0.01, duration=0.01) + await pilot.wait_for_scheduled_animations() + assert styles.background.rgb == (0, 0, 0) From cd0cc6030b6b24bc961cee0c77fcd00f9006792d 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: Tue, 24 Jan 2023 18:36:55 +0000 Subject: [PATCH 16/83] Changelog. --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b014c4fbb..db72150e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [0.11.0] - Unreleased +### Added + +- Added the coroutines `Animator.wait_for_fully_idle` and `pilot.wait_for_scheduled_animations` that allow waiting for all current and scheduled animations https://github.com/Textualize/textual/issues/1658 + ### Fixed - Fixed stuck screen https://github.com/Textualize/textual/issues/1632 - Fixed relative units in `grid-rows` and `grid-columns` being computed with respect to the wrong dimension https://github.com/Textualize/textual/issues/1406 +- Fixed bug with animations that were triggered back to back, where the second one wouldn't start https://github.com/Textualize/textual/issues/1372 +- Fixed bug with animations that were scheduled where all but the first would be skipped https://github.com/Textualize/textual/issues/1372 ## [0.10.1] - 2023-01-20 From ad24349bb24a7dac35cc213b89dd1d0576c343b8 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: Tue, 24 Jan 2023 18:38:30 +0000 Subject: [PATCH 17/83] Move auxiliary type to TYPE_CHECKING. --- src/textual/_animator.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 85ad72e87..98e0ee413 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -21,9 +21,10 @@ else: # pragma: no cover if TYPE_CHECKING: from textual.app import App + AnimationKey = tuple[int, str] + """Animation keys are the id of the object and the attribute being animated.""" + EasingFunction = Callable[[float], float] -AnimationKey = tuple[int, str] -"""Animation keys are the id of the object and the attribute being animated.""" class AnimationError(Exception): From fa6bd4486692bfafef322eded276e34f9e4d4087 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: Tue, 24 Jan 2023 18:46:37 +0000 Subject: [PATCH 18/83] Housekeeping. --- CHANGELOG.md | 1 + tests/test_animation.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index db72150e0..d23f4705f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Added the coroutines `Animator.wait_for_fully_idle` and `pilot.wait_for_scheduled_animations` that allow waiting for all current and scheduled animations https://github.com/Textualize/textual/issues/1658 +- Added the method `Animator.is_being_animated` that checks if an attribute of an object is being animated or is scheduled for animation ### Fixed diff --git a/tests/test_animation.py b/tests/test_animation.py index 14d57f6fc..eea935a33 100644 --- a/tests/test_animation.py +++ b/tests/test_animation.py @@ -120,7 +120,7 @@ async def test_reverse_animations() -> None: await pilot.wait_for_animation() assert styles.background.rgb == (0, 0, 0) - # Now, the actual test is to make sure we go back to black if scheduling both at once. + # Now, the actual test is to make sure we go back to black if creating both at once. styles.animate("background", "white", duration=0.01) styles.animate("background", "black", duration=0.01) await pilot.wait_for_animation() From 0f2e991bf5c02fe982b21aaf7ad56610ab53b4cc 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: Wed, 25 Jan 2023 13:04:14 +0000 Subject: [PATCH 19/83] Rename event. --- CHANGELOG.md | 2 +- src/textual/_animator.py | 16 ++++++++-------- src/textual/pilot.py | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5594b6679..e488bb6a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added -- Added the coroutines `Animator.wait_for_fully_idle` and `pilot.wait_for_scheduled_animations` that allow waiting for all current and scheduled animations https://github.com/Textualize/textual/issues/1658 +- Added the coroutines `Animator.wait_until_complete` and `pilot.wait_for_scheduled_animations` that allow waiting for all current and scheduled animations https://github.com/Textualize/textual/issues/1658 - Added the method `Animator.is_being_animated` that checks if an attribute of an object is being animated or is scheduled for animation ### Changed diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 98e0ee413..330ea2dfd 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -194,12 +194,12 @@ class Animator: # Flag if no animations are currently taking place. self._idle_event = asyncio.Event() # Flag if no animations are currently taking place and none are scheduled. - self._fully_idle_event = asyncio.Event() + self._complete_event = asyncio.Event() async def start(self) -> None: """Start the animator task.""" self._idle_event.set() - self._fully_idle_event.set() + self._complete_event.set() self._timer.start() async def stop(self) -> None: @@ -210,7 +210,7 @@ class Animator: pass finally: self._idle_event.set() - self._fully_idle_event.set() + self._complete_event.set() def bind(self, obj: object) -> BoundAnimator: """Bind the animator to a given object.""" @@ -260,7 +260,7 @@ class Animator: ) if delay: self._scheduled.add((id(obj), attribute)) - self._fully_idle_event.clear() + self._complete_event.clear() self.app.set_timer(delay, animate_callback) else: animate_callback() @@ -367,14 +367,14 @@ class Animator: self._animations[animation_key] = animation self._timer.resume() self._idle_event.clear() - self._fully_idle_event.clear() + self._complete_event.clear() async def __call__(self) -> None: if not self._animations: self._timer.pause() self._idle_event.set() if not self._scheduled: - self._fully_idle_event.set() + self._complete_event.set() else: animation_time = self._get_time() animation_keys = list(self._animations.keys()) @@ -397,6 +397,6 @@ class Animator: """Wait for any animations to complete.""" await self._idle_event.wait() - async def wait_for_fully_idle(self) -> None: + async def wait_until_complete(self) -> None: """Wait for any current and scheduled animations to complete.""" - await self._fully_idle_event.wait() + await self._complete_event.wait() diff --git a/src/textual/pilot.py b/src/textual/pilot.py index d6daf1a78..5f3fbe817 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -48,7 +48,7 @@ class Pilot(Generic[ReturnType]): async def wait_for_scheduled_animations(self) -> None: """Wait for any current and scheduled animations to complete.""" - await self._app.animator.wait_for_fully_idle() + await self._app.animator.wait_until_complete() async def exit(self, result: ReturnType) -> None: """Exit the app with the given result. From 11f470b59bd61564e8b8c549cf4a1a6baa17c5ba 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: Wed, 25 Jan 2023 13:40:57 +0000 Subject: [PATCH 20/83] Ensure animation scheduling order. --- tests/test_animation.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_animation.py b/tests/test_animation.py index eea935a33..8aa1615a2 100644 --- a/tests/test_animation.py +++ b/tests/test_animation.py @@ -154,6 +154,7 @@ async def test_schedule_reverse_animations() -> None: # Now, the actual test is to make sure we go back to black if scheduling both at once. styles.animate("background", "white", delay=0.01, duration=0.01) + await pilot.pause(0.005) styles.animate("background", "black", delay=0.01, duration=0.01) await pilot.wait_for_scheduled_animations() assert styles.background.rgb == (0, 0, 0) From c678e3e1e373753b6c8316516b53b6d9e8c0f34c Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 25 Jan 2023 16:48:04 +0000 Subject: [PATCH 21/83] Reduce the expand/collapse/toggle kwarg to just 'all' See https://github.com/Textualize/textual/pull/1644#discussion_r1086493525 -- not exactly my preference but it's been decided it makes for a nicer interface. --- src/textual/widgets/_tree.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index 93425625a..ef9e1f345 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -171,13 +171,13 @@ class TreeNode(Generic[TreeDataType]): for child in self.children: child._expand(expand_all) - def expand(self, *, expand_all: bool = False) -> None: + def expand(self, *, all: bool = False) -> None: """Expand a node (show its children). Args: - expand_all: If `True` expand all offspring at all depths. + all: If `True` expand all offspring at all depths. """ - self._expand(expand_all) + self._expand(all) self._tree._invalidate() def _collapse(self, collapse_all: bool) -> None: @@ -192,26 +192,26 @@ class TreeNode(Generic[TreeDataType]): child._collapse(collapse_all) self._updates += 1 - def collapse(self, *, collapse_all: bool = False) -> None: + def collapse(self, *, all: bool = False) -> None: """Collapse the node (hide children). Args: - collapse_all: If `True` collapse all offspring at all depths. + all: If `True` collapse all offspring at all depths. """ - self._collapse(collapse_all) + self._collapse(all) self._tree._invalidate() - def toggle(self, *, toggle_all: bool = False) -> None: + def toggle(self, *, all: bool = False) -> None: """Toggle the expanded state. Args: - toggle_all: If `True` set the expanded state of all offspring + all: If `True` set the expanded state of all offspring nodes at all depths to match this node's toggled state. """ if self._expanded: - self.collapse(collapse_all=toggle_all) + self.collapse(all=all) else: - self.expand(expand_all=toggle_all) + self.expand(all=all) @property def label(self) -> TextType: From e59064e2043dbea58d7985c3d627ea3f3f49cbef Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 25 Jan 2023 20:39:49 +0000 Subject: [PATCH 22/83] Switch away from a keyword approach to a dedicated method approach While descriptive keywords tend to be a preference within the Textual codebase for many things, this was one of those times where a developer's code using the library was likely going to read better if there's a switch to using dedicated methods; this approach means we can just go with "all" rather than "{action}_all" without needing to shadow a Python builtin. This also does mirror mount/mount_all too. --- src/textual/widgets/_tree.py | 52 +++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/src/textual/widgets/_tree.py b/src/textual/widgets/_tree.py index ef9e1f345..b26ccd1c7 100644 --- a/src/textual/widgets/_tree.py +++ b/src/textual/widgets/_tree.py @@ -160,7 +160,7 @@ class TreeNode(Generic[TreeDataType]): self._updates += 1 def _expand(self, expand_all: bool) -> None: - """Mark a node as expanded (its children are shown). + """Mark the node as expanded (its children are shown). Args: expand_all: If `True` expand all offspring at all depths. @@ -171,47 +171,51 @@ class TreeNode(Generic[TreeDataType]): for child in self.children: child._expand(expand_all) - def expand(self, *, all: bool = False) -> None: - """Expand a node (show its children). + def expand(self) -> None: + """Expand the node (show its children).""" + self._expand(False) + self._tree._invalidate() - Args: - all: If `True` expand all offspring at all depths. - """ - self._expand(all) + def expand_all(self) -> None: + """Expand the node (show its children) and all those below it.""" + self._expand(True) self._tree._invalidate() def _collapse(self, collapse_all: bool) -> None: - """Mark a node as collapsed (its children are hidden). + """Mark the node as collapsed (its children are hidden). Args: collapse_all: If `True` collapse all offspring at all depths. """ self._expanded = False + self._updates += 1 if collapse_all: for child in self.children: child._collapse(collapse_all) - self._updates += 1 - def collapse(self, *, all: bool = False) -> None: - """Collapse the node (hide children). - - Args: - all: If `True` collapse all offspring at all depths. - """ - self._collapse(all) + def collapse(self) -> None: + """Collapse the node (hide its children).""" + self._collapse(False) self._tree._invalidate() - def toggle(self, *, all: bool = False) -> None: - """Toggle the expanded state. + def collapse_all(self) -> None: + """Collapse the node (hide its children) and all those below it.""" + self._collapse(True) + self._tree._invalidate() - Args: - all: If `True` set the expanded state of all offspring - nodes at all depths to match this node's toggled state. - """ + def toggle(self) -> None: + """Toggle the node's expanded state.""" if self._expanded: - self.collapse(all=all) + self.collapse() else: - self.expand(all=all) + self.expand() + + def toggle_all(self) -> None: + """Toggle the node's expanded state and make all those below it match.""" + if self._expanded: + self.collapse_all() + else: + self.expand_all() @property def label(self) -> TextType: From 64ac4345a8a42b7667ed5ca1f603984b085e42cb Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 25 Jan 2023 20:47:05 +0000 Subject: [PATCH 23/83] Update the CHANGELOG to reflect the method-only approach --- CHANGELOG.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 855d48d88..8efab77b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,15 +9,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added -- Added an optional `expand_all` parameter to `TreeNode.expand` https://github.com/Textualize/textual/issues/1430 -- Added an optional `collapse_all` parameter to `TreeNode.collapse` https://github.com/Textualize/textual/issues/1430 -- Added an optional `toggle_all` parameter to `TreeNode.toggle` https://github.com/Textualize/textual/issues/1430 +- Added optional `TreeNode.expand_all` https://github.com/Textualize/textual/issues/1430 +- Added optional `TreeNode.collapse_all` https://github.com/Textualize/textual/issues/1430 +- Added optional `TreeNode.toggle_all` https://github.com/Textualize/textual/issues/1430 ### Changed - Breaking change: `TreeNode` can no longer be imported from `textual.widgets`; it is now available via `from textual.widgets.tree import TreeNode`. https://github.com/Textualize/textual/pull/1637 - ### Fixed - Fixed stuck screen https://github.com/Textualize/textual/issues/1632 From 90736129cf060a957bcf67efd78b3080e1918335 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 25 Jan 2023 20:48:07 +0000 Subject: [PATCH 24/83] Prune a word hangover in CHANGELOG Nowt optional about the new methods! --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8efab77b4..667d96fa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +9,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added -- Added optional `TreeNode.expand_all` https://github.com/Textualize/textual/issues/1430 -- Added optional `TreeNode.collapse_all` https://github.com/Textualize/textual/issues/1430 -- Added optional `TreeNode.toggle_all` https://github.com/Textualize/textual/issues/1430 +- Added `TreeNode.expand_all` https://github.com/Textualize/textual/issues/1430 +- Added `TreeNode.collapse_all` https://github.com/Textualize/textual/issues/1430 +- Added `TreeNode.toggle_all` https://github.com/Textualize/textual/issues/1430 ### Changed From 672bc1b9b3fa9ef8fd9841950d32c6fa3625a31b Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 26 Jan 2023 11:43:34 +0000 Subject: [PATCH 25/83] Add macOS/Emacs-style home/end bindings to `Input` Ctrl+A and Ctrl+E See #1310. --- src/textual/widgets/_input.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index 1046c732e..b9b0bfff9 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -83,8 +83,8 @@ class Input(Widget, can_focus=True): Binding("left", "cursor_left", "cursor left", show=False), Binding("right", "cursor_right", "cursor right", show=False), Binding("backspace", "delete_left", "delete left", show=False), - Binding("home", "home", "home", show=False), - Binding("end", "end", "end", show=False), + Binding("home,ctrl+a", "home", "home", show=False), + Binding("end,ctrl+e", "end", "end", show=False), Binding("ctrl+d", "delete_right", "delete right", show=False), Binding("enter", "submit", "submit", show=False), ] From 5d67c76a70773176898c389b3069b3dd687adc76 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 26 Jan 2023 12:17:37 +0000 Subject: [PATCH 26/83] Add delete key as a delete binding to `Input` That is, delete deletes the character to the right -- the opposite of backspace if you will. See #1310. --- src/textual/widgets/_input.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index b9b0bfff9..34d13dd50 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -85,7 +85,7 @@ class Input(Widget, can_focus=True): Binding("backspace", "delete_left", "delete left", show=False), Binding("home,ctrl+a", "home", "home", show=False), Binding("end,ctrl+e", "end", "end", show=False), - Binding("ctrl+d", "delete_right", "delete right", show=False), + Binding("ctrl+d,delete", "delete_right", "delete right", show=False), Binding("enter", "submit", "submit", show=False), ] From 487b2e2493df3d0080d33e5cddf04d3f437112a2 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 26 Jan 2023 13:59:07 +0000 Subject: [PATCH 27/83] Add delete-to-end to `Input` And in doing so bind it to Ctrl+K (macOS/Emacs/readline-common). Right now I'm not aware of a common combo for this on Windows, but we can add a binding for this if one becomes apparent. See #1310. --- src/textual/widgets/_input.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index 34d13dd50..7463b6d58 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -87,6 +87,7 @@ class Input(Widget, can_focus=True): Binding("end,ctrl+e", "end", "end", show=False), Binding("ctrl+d,delete", "delete_right", "delete right", show=False), Binding("enter", "submit", "submit", show=False), + Binding("ctrl+k", "delete_to_end", "delete to end", show=False), ] COMPONENT_CLASSES = {"input--cursor", "input--placeholder"} @@ -327,6 +328,10 @@ class Input(Widget, can_focus=True): self.value = f"{before}{after}" self.cursor_position = delete_position + def action_delete_to_end(self) -> None: + """Delete from the cursor location to the end of input.""" + self.value = self.value[: self.cursor_position] + async def action_submit(self) -> None: await self.emit(self.Submitted(self, self.value)) From 53c168c24c9d91593ee10dc4936c955f94bc04c0 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 26 Jan 2023 14:11:46 +0000 Subject: [PATCH 28/83] Add delete-to-start to `Input` And in doing so bind it to Ctrl+U (readline-common). Right now I'm not aware of a common combo for this on Windows, but we can add a binding for this if one becomes apparent. See #1310. --- src/textual/widgets/_input.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index 7463b6d58..d43a375e8 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -88,6 +88,7 @@ class Input(Widget, can_focus=True): Binding("ctrl+d,delete", "delete_right", "delete right", show=False), Binding("enter", "submit", "submit", show=False), Binding("ctrl+k", "delete_to_end", "delete to end", show=False), + Binding("ctrl+u", "delete_to_start", "delete to start", show=False), ] COMPONENT_CLASSES = {"input--cursor", "input--placeholder"} @@ -332,6 +333,12 @@ class Input(Widget, can_focus=True): """Delete from the cursor location to the end of input.""" self.value = self.value[: self.cursor_position] + def action_delete_to_start(self) -> None: + """Delete from the cursor location to the start of input.""" + if self.cursor_position > 0: + self.value = self.value[self.cursor_position :] + self.cursor_position = 0 + async def action_submit(self) -> None: await self.emit(self.Submitted(self, self.value)) From 5e1420df974c9cf38f4f269349048be1375a008e Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 26 Jan 2023 14:15:22 +0000 Subject: [PATCH 29/83] Favour named keys over Ctrl-combos This makes no difference to anything; but I think it makes for code that's easier on the eye so someone scanning down the list of bindings will see the more descriptive key first. See #1310. --- src/textual/widgets/_input.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index d43a375e8..176a9a759 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -85,7 +85,7 @@ class Input(Widget, can_focus=True): Binding("backspace", "delete_left", "delete left", show=False), Binding("home,ctrl+a", "home", "home", show=False), Binding("end,ctrl+e", "end", "end", show=False), - Binding("ctrl+d,delete", "delete_right", "delete right", show=False), + Binding("delete,ctrl+d", "delete_right", "delete right", show=False), Binding("enter", "submit", "submit", show=False), Binding("ctrl+k", "delete_to_end", "delete to end", show=False), Binding("ctrl+u", "delete_to_start", "delete to start", show=False), From 00c4981a91118ee128a0be76cfcf4008d2d786b6 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 26 Jan 2023 14:16:51 +0000 Subject: [PATCH 30/83] Reorder the `Input` bindings This makes no difference to anything; but I think grouping the bindings into similar groups will make it easier for folk to read and find things. See #1310. --- src/textual/widgets/_input.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index 176a9a759..b1cae4693 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -82,11 +82,11 @@ class Input(Widget, can_focus=True): BINDINGS = [ Binding("left", "cursor_left", "cursor left", show=False), Binding("right", "cursor_right", "cursor right", show=False), - Binding("backspace", "delete_left", "delete left", show=False), Binding("home,ctrl+a", "home", "home", show=False), Binding("end,ctrl+e", "end", "end", show=False), - Binding("delete,ctrl+d", "delete_right", "delete right", show=False), Binding("enter", "submit", "submit", show=False), + Binding("backspace", "delete_left", "delete left", show=False), + Binding("delete,ctrl+d", "delete_right", "delete right", show=False), Binding("ctrl+k", "delete_to_end", "delete to end", show=False), Binding("ctrl+u", "delete_to_start", "delete to start", show=False), ] From 41be84b1a5376226c80a9af7b2911f8c4af015ac Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 26 Jan 2023 16:01:48 +0100 Subject: [PATCH 31/83] docstring --- src/textual/_wait.py | 26 ++++++++++++++++++++++++++ src/textual/app.py | 16 ++++++---------- src/textual/pilot.py | 2 ++ tests/snapshot_tests/test_snapshots.py | 2 +- 4 files changed, 35 insertions(+), 11 deletions(-) create mode 100644 src/textual/_wait.py diff --git a/src/textual/_wait.py b/src/textual/_wait.py new file mode 100644 index 000000000..ccfd57a2c --- /dev/null +++ b/src/textual/_wait.py @@ -0,0 +1,26 @@ +from asyncio import sleep +from time import process_time, time + + +SLEEP_GRANULARITY: float = 1 / 50 +SLEEP_IDLE: float = SLEEP_GRANULARITY / 2.0 + + +async def wait_for_idle(min_sleep: float = 0.01, max_sleep: float = 1) -> None: + """Wait until the cpu isn't working very hard. + + Args: + min_sleep: Minimum time to wait. Defaults to 0.01. + max_sleep: Maximum time to wait. Defaults to 1. + """ + start_time = time() + + while True: + cpu_time = process_time() + await sleep(SLEEP_GRANULARITY) + cpu_elapsed = process_time() - cpu_time + elapsed_time = time() - start_time + if elapsed_time >= max_sleep: + break + if elapsed_time > min_sleep and cpu_elapsed < SLEEP_IDLE: + break diff --git a/src/textual/app.py b/src/textual/app.py index 8712353e1..a1bbe061a 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -68,6 +68,7 @@ from .messages import CallbackType from .reactive import Reactive from .renderables.blank import Blank from .screen import Screen +from ._wait import wait_for_idle from .widget import AwaitMount, Widget if TYPE_CHECKING: @@ -796,11 +797,11 @@ class App(Generic[ReturnType], DOMNode): app = self driver = app._driver assert driver is not None - await asyncio.sleep(0.02) + # await asyncio.sleep(0.02) + await wait_for_idle(0) for key in keys: if key == "_": - print("(pause 50ms)") - await asyncio.sleep(0.05) + continue elif key.startswith("wait:"): _, wait_ms = key.split(":") print(f"(pause {wait_ms}ms)") @@ -822,13 +823,8 @@ class App(Generic[ReturnType], DOMNode): print(f"press {key!r} (char={char!r})") key_event = events.Key(app, key, char) driver.send_event(key_event) - # TODO: A bit of a fudge - extra sleep after tabbing to help guard against race - # condition between widget-level key handling and app/screen level handling. - # More information here: https://github.com/Textualize/textual/issues/1009 - # This conditional sleep can be removed after that issue is closed. - if key == "tab": - await asyncio.sleep(0.05) - await asyncio.sleep(0.025) + await wait_for_idle(0) + await app._animator.wait_for_idle() @asynccontextmanager diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 13ae9fe03..3ddb12e8d 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -6,6 +6,7 @@ import asyncio from typing import Generic from .app import App, ReturnType +from ._wait import wait_for_idle @rich.repr.auto(angular=True) @@ -52,4 +53,5 @@ class Pilot(Generic[ReturnType]): Args: result: The app result returned by `run` or `run_async`. """ + await wait_for_idle() self.app.exit(result) diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 868bd91aa..8aa2b6514 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -190,6 +190,6 @@ def test_demo(snap_compare): """Test the demo app (python -m textual)""" assert snap_compare( Path("../../src/textual/demo.py"), - press=["down", "down", "down", "_", "_", "_"], + press=["down", "down", "down"], terminal_size=(100, 30), ) From 3eac79568ca4d031edda5fa924c865cc6c0b0cd3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 26 Jan 2023 16:10:13 +0100 Subject: [PATCH 32/83] remove some pauses --- src/textual/pilot.py | 9 ++++++--- tests/listview/test_inherit_listview.py | 4 ---- tests/test_auto_pilot.py | 1 - tests/test_binding_inheritance.py | 16 +++++++--------- tests/test_dark_toggle.py | 1 - tests/test_test_runner.py | 1 - tests/tree/test_tree_messages.py | 4 ---- 7 files changed, 13 insertions(+), 23 deletions(-) diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 3ddb12e8d..6c8375c61 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -34,14 +34,17 @@ class Pilot(Generic[ReturnType]): if keys: await self._app._press_keys(keys) - async def pause(self, delay: float = 50 / 1000) -> None: + async def pause(self, delay: float | None = None) -> None: """Insert a pause. Args: - delay: Seconds to pause. Defaults to 50ms. + delay: Seconds to pause, or None to wait for cpu idle. Defaults to None. """ # These sleep zeros, are to force asyncio to give up a time-slice, - await asyncio.sleep(delay) + if delay is None: + await wait_for_idle(0) + else: + await asyncio.sleep(delay) async def wait_for_animation(self) -> None: """Wait for any animation to complete.""" diff --git a/tests/listview/test_inherit_listview.py b/tests/listview/test_inherit_listview.py index f5dfefeb1..2f147a28a 100644 --- a/tests/listview/test_inherit_listview.py +++ b/tests/listview/test_inherit_listview.py @@ -31,10 +31,8 @@ async def test_empty_inherited_list_view() -> None: """An empty self-populating inherited ListView should work as expected.""" async with ListViewApp().run_test() as pilot: await pilot.press("tab") - await pilot.pause(2 / 100) assert pilot.app.query_one(MyListView).index is None await pilot.press("down") - await pilot.pause(2 / 100) assert pilot.app.query_one(MyListView).index is None @@ -42,8 +40,6 @@ async def test_populated_inherited_list_view() -> None: """A self-populating inherited ListView should work as normal.""" async with ListViewApp(30).run_test() as pilot: await pilot.press("tab") - await pilot.pause(2 / 100) assert pilot.app.query_one(MyListView).index == 0 await pilot.press("down") - await pilot.pause(2 / 100) assert pilot.app.query_one(MyListView).index == 1 diff --git a/tests/test_auto_pilot.py b/tests/test_auto_pilot.py index dde2ad18c..5e2834c83 100644 --- a/tests/test_auto_pilot.py +++ b/tests/test_auto_pilot.py @@ -14,7 +14,6 @@ def test_auto_pilot() -> None: async def auto_pilot(pilot: Pilot) -> None: await pilot.press("tab", *"foo") - await pilot.pause(1 / 100) await pilot.exit("bar") app = TestApp() diff --git a/tests/test_binding_inheritance.py b/tests/test_binding_inheritance.py index 6aad85e37..5afb5e531 100644 --- a/tests/test_binding_inheritance.py +++ b/tests/test_binding_inheritance.py @@ -238,7 +238,7 @@ async def test_pressing_alpha_on_app() -> None: """Test that pressing the alpha key, when it's bound on the app, results in an action fire.""" async with AppWithMovementKeysBound().run_test() as pilot: await pilot.press(*AppKeyRecorder.ALPHAS) - await pilot.pause(2 / 100) + await pilot.pause() assert pilot.app.pressed_keys == [*AppKeyRecorder.ALPHAS] @@ -246,7 +246,7 @@ async def test_pressing_movement_keys_app() -> None: """Test that pressing the movement keys, when they're bound on the app, results in an action fire.""" async with AppWithMovementKeysBound().run_test() as pilot: await pilot.press(*AppKeyRecorder.ALL_KEYS) - await pilot.pause(2 / 100) + await pilot.pause() pilot.app.all_recorded() @@ -284,7 +284,7 @@ async def test_focused_child_widget_with_movement_bindings() -> None: """A focused child widget with movement bindings should handle its own actions.""" async with AppWithWidgetWithBindings().run_test() as pilot: await pilot.press(*AppKeyRecorder.ALL_KEYS) - await pilot.pause(2 / 100) + pilot.app.all_recorded("locally_") @@ -331,7 +331,7 @@ async def test_focused_child_widget_with_movement_bindings_on_screen() -> None: """A focused child widget, with movement bindings in the screen, should trigger screen actions.""" async with AppWithScreenWithBindingsWidgetNoBindings().run_test() as pilot: await pilot.press(*AppKeyRecorder.ALL_KEYS) - await pilot.pause(2 / 100) + pilot.app.all_recorded("screenly_") @@ -374,7 +374,7 @@ async def test_contained_focused_child_widget_with_movement_bindings_on_screen() """A contained focused child widget, with movement bindings in the screen, should trigger screen actions.""" async with AppWithScreenWithBindingsWrappedWidgetNoBindings().run_test() as pilot: await pilot.press(*AppKeyRecorder.ALL_KEYS) - await pilot.pause(2 / 100) + pilot.app.all_recorded("screenly_") @@ -413,7 +413,7 @@ async def test_focused_child_widget_with_movement_bindings_no_inherit() -> None: """A focused child widget with movement bindings and inherit_bindings=False should handle its own actions.""" async with AppWithWidgetWithBindingsNoInherit().run_test() as pilot: await pilot.press(*AppKeyRecorder.ALL_KEYS) - await pilot.pause(2 / 100) + pilot.app.all_recorded("locally_") @@ -465,7 +465,7 @@ async def test_focused_child_widget_no_inherit_with_movement_bindings_on_screen( """A focused child widget, that doesn't inherit bindings, with movement bindings in the screen, should trigger screen actions.""" async with AppWithScreenWithBindingsWidgetNoBindingsNoInherit().run_test() as pilot: await pilot.press(*AppKeyRecorder.ALL_KEYS) - await pilot.pause(2 / 100) + pilot.app.all_recorded("screenly_") @@ -520,7 +520,6 @@ async def test_focused_child_widget_no_inherit_empty_bindings_with_movement_bind """A focused child widget, that doesn't inherit bindings and sets BINDINGS empty, with movement bindings in the screen, should trigger screen actions.""" async with AppWithScreenWithBindingsWidgetEmptyBindingsNoInherit().run_test() as pilot: await pilot.press(*AppKeyRecorder.ALL_KEYS) - await pilot.pause(2 / 100) pilot.app.all_recorded("screenly_") @@ -602,7 +601,6 @@ async def test_overlapping_priority_bindings() -> None: """Test an app stack with overlapping bindings.""" async with PriorityOverlapApp().run_test() as pilot: await pilot.press(*"0abcdef") - await pilot.pause(2 / 100) assert pilot.app.pressed_keys == [ "widget_0", "app_a", diff --git a/tests/test_dark_toggle.py b/tests/test_dark_toggle.py index cbceb766c..1a746e4bd 100644 --- a/tests/test_dark_toggle.py +++ b/tests/test_dark_toggle.py @@ -40,5 +40,4 @@ async def test_toggle_dark_in_action() -> None: """It should be possible to toggle dark mode with an action.""" async with OnMountDarkSwitch().run_test() as pilot: await pilot.press("d") - await pilot.pause(2 / 100) assert not pilot.app.dark diff --git a/tests/test_test_runner.py b/tests/test_test_runner.py index e513284fd..0435d31d9 100644 --- a/tests/test_test_runner.py +++ b/tests/test_test_runner.py @@ -16,7 +16,6 @@ async def test_run_test() -> None: str(pilot) == "" ) await pilot.press("tab", *"foo") - await pilot.pause(1 / 100) await pilot.exit("bar") assert app.return_value == "bar" diff --git a/tests/tree/test_tree_messages.py b/tests/tree/test_tree_messages.py index f271d4e42..67620d70e 100644 --- a/tests/tree/test_tree_messages.py +++ b/tests/tree/test_tree_messages.py @@ -45,7 +45,6 @@ async def test_tree_node_selected_message() -> None: """Selecting a node should result in a selected message being emitted.""" async with TreeApp().run_test() as pilot: await pilot.press("enter") - await pilot.pause(2 / 100) assert pilot.app.messages == ["NodeExpanded", "NodeSelected"] @@ -53,7 +52,6 @@ async def test_tree_node_expanded_message() -> None: """Expanding a node should result in an expanded message being emitted.""" async with TreeApp().run_test() as pilot: await pilot.press("enter") - await pilot.pause(2 / 100) assert pilot.app.messages == ["NodeExpanded", "NodeSelected"] @@ -61,7 +59,6 @@ async def test_tree_node_collapsed_message() -> None: """Collapsing a node should result in a collapsed message being emitted.""" async with TreeApp().run_test() as pilot: await pilot.press("enter", "enter") - await pilot.pause(2 / 100) assert pilot.app.messages == [ "NodeExpanded", "NodeSelected", @@ -74,5 +71,4 @@ async def test_tree_node_highlighted_message() -> None: """Highlighting a node should result in a highlighted message being emitted.""" async with TreeApp().run_test() as pilot: await pilot.press("enter", "down") - await pilot.pause(2 / 100) assert pilot.app.messages == ["NodeExpanded", "NodeSelected", "NodeHighlighted"] From 683d44dc936f47a5c19784482cebcf0e6951a287 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 26 Jan 2023 16:13:05 +0100 Subject: [PATCH 33/83] tweak sleep granularity --- src/textual/_wait.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/_wait.py b/src/textual/_wait.py index ccfd57a2c..03329d078 100644 --- a/src/textual/_wait.py +++ b/src/textual/_wait.py @@ -2,7 +2,7 @@ from asyncio import sleep from time import process_time, time -SLEEP_GRANULARITY: float = 1 / 50 +SLEEP_GRANULARITY: float = 1 / 100 SLEEP_IDLE: float = SLEEP_GRANULARITY / 2.0 From a2807f217d2234a08dd5be94eda1aad3d3f2a137 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 26 Jan 2023 15:16:54 +0000 Subject: [PATCH 34/83] Add support for jumping to the next word --- src/textual/widgets/_input.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index b1cae4693..9593b67ff 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -1,5 +1,7 @@ from __future__ import annotations +import re + from rich.cells import cell_len, get_character_cell_size from rich.console import Console, ConsoleOptions, RenderableType, RenderResult from rich.highlighter import Highlighter @@ -84,6 +86,7 @@ class Input(Widget, can_focus=True): Binding("right", "cursor_right", "cursor right", show=False), Binding("home,ctrl+a", "home", "home", show=False), Binding("end,ctrl+e", "end", "end", show=False), + Binding("ctrl+right", "next_word", "next word", show=False), Binding("enter", "submit", "submit", show=False), Binding("backspace", "delete_left", "delete left", show=False), Binding("delete,ctrl+d", "delete_right", "delete right", show=False), @@ -304,6 +307,14 @@ class Input(Widget, can_focus=True): def action_end(self) -> None: self.cursor_position = len(self.value) + _WORD_START = re.compile(r"(?<=\s)\w") + + def action_next_word(self) -> None: + rest = self.value[self.cursor_position :] + hit = re.search(self._WORD_START, rest) + if hit is not None: + self.cursor_position += hit.start() + def action_delete_right(self) -> None: value = self.value delete_position = self.cursor_position From e231788433e041dfd3eb0755880e6698b3d80ed8 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 26 Jan 2023 16:18:23 +0100 Subject: [PATCH 35/83] tweak sleep granularity --- src/textual/_wait.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/textual/_wait.py b/src/textual/_wait.py index 03329d078..b796a9cf7 100644 --- a/src/textual/_wait.py +++ b/src/textual/_wait.py @@ -2,15 +2,17 @@ from asyncio import sleep from time import process_time, time -SLEEP_GRANULARITY: float = 1 / 100 +SLEEP_GRANULARITY: float = 1 / 50 SLEEP_IDLE: float = SLEEP_GRANULARITY / 2.0 -async def wait_for_idle(min_sleep: float = 0.01, max_sleep: float = 1) -> None: +async def wait_for_idle( + min_sleep: float = SLEEP_GRANULARITY, max_sleep: float = 1 +) -> None: """Wait until the cpu isn't working very hard. Args: - min_sleep: Minimum time to wait. Defaults to 0.01. + min_sleep: Minimum time to wait. Defaults to 1/50. max_sleep: Maximum time to wait. Defaults to 1. """ start_time = time() From ff7f17644475567fc748404e10e2e04f7f9ecc0d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 26 Jan 2023 16:24:35 +0100 Subject: [PATCH 36/83] add wait for idle --- src/textual/app.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/app.py b/src/textual/app.py index a1bbe061a..7ad46efb5 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -826,6 +826,7 @@ class App(Generic[ReturnType], DOMNode): await wait_for_idle(0) await app._animator.wait_for_idle() + await wait_for_idle(0) @asynccontextmanager async def run_test( From c850221873a758b02d4bfc1e9f989d879792dea9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 26 Jan 2023 16:31:41 +0100 Subject: [PATCH 37/83] remove comment [skip ci] --- src/textual/app.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index 7ad46efb5..a54faa2c6 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -797,7 +797,6 @@ class App(Generic[ReturnType], DOMNode): app = self driver = app._driver assert driver is not None - # await asyncio.sleep(0.02) await wait_for_idle(0) for key in keys: if key == "_": From fade5db2a9c13d4330b7e5c5542131b0c3c6a086 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 26 Jan 2023 15:37:26 +0000 Subject: [PATCH 38/83] Add support for jumping to the previous word See #1310. --- src/textual/widgets/_input.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index 9593b67ff..bc5fe0406 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -87,6 +87,7 @@ class Input(Widget, can_focus=True): Binding("home,ctrl+a", "home", "home", show=False), Binding("end,ctrl+e", "end", "end", show=False), Binding("ctrl+right", "next_word", "next word", show=False), + Binding("ctrl+left", "previous_word", "previous word", show=False), Binding("enter", "submit", "submit", show=False), Binding("backspace", "delete_left", "delete left", show=False), Binding("delete,ctrl+d", "delete_right", "delete right", show=False), @@ -315,6 +316,14 @@ class Input(Widget, can_focus=True): if hit is not None: self.cursor_position += hit.start() + def action_previous_word(self) -> None: + try: + *_, hit = re.finditer(self._WORD_START, self.value[: self.cursor_position]) + except ValueError: + self.cursor_position = 0 + return + self.cursor_position = hit.start() + def action_delete_right(self) -> None: value = self.value delete_position = self.cursor_position From 7097783414ee8da8dfc8689996d814e67659ee70 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 26 Jan 2023 17:46:25 +0100 Subject: [PATCH 39/83] remove whitespace --- src/textual/_wait.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/_wait.py b/src/textual/_wait.py index b796a9cf7..11df9b770 100644 --- a/src/textual/_wait.py +++ b/src/textual/_wait.py @@ -1,7 +1,6 @@ from asyncio import sleep from time import process_time, time - SLEEP_GRANULARITY: float = 1 / 50 SLEEP_IDLE: float = SLEEP_GRANULARITY / 2.0 From d815cced38e019eb8ef7faec61c4163ba6736d4b Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 26 Jan 2023 20:17:14 +0000 Subject: [PATCH 40/83] Simplify an expression --- src/textual/widgets/_input.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index bc5fe0406..86b2e23f6 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -311,8 +311,7 @@ class Input(Widget, can_focus=True): _WORD_START = re.compile(r"(?<=\s)\w") def action_next_word(self) -> None: - rest = self.value[self.cursor_position :] - hit = re.search(self._WORD_START, rest) + hit = re.search(self._WORD_START, self.value[self.cursor_position :]) if hit is not None: self.cursor_position += hit.start() From 1600d986399630bd571aadfe88f53cb2a587ff76 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 26 Jan 2023 20:21:35 +0000 Subject: [PATCH 41/83] Tidy up previous word --- src/textual/widgets/_input.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index 86b2e23f6..430aaed8e 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -320,8 +320,8 @@ class Input(Widget, can_focus=True): *_, hit = re.finditer(self._WORD_START, self.value[: self.cursor_position]) except ValueError: self.cursor_position = 0 - return - self.cursor_position = hit.start() + else: + self.cursor_position = hit.start() def action_delete_right(self) -> None: value = self.value From 44d4bc61918ed50072a8144971432b5793d6f087 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 26 Jan 2023 20:57:06 +0000 Subject: [PATCH 42/83] Be more forgiving about what a word is --- src/textual/widgets/_input.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index 430aaed8e..0b0f78130 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -308,7 +308,7 @@ class Input(Widget, can_focus=True): def action_end(self) -> None: self.cursor_position = len(self.value) - _WORD_START = re.compile(r"(?<=\s)\w") + _WORD_START = re.compile(r"(?<=\W)\w") def action_next_word(self) -> None: hit = re.search(self._WORD_START, self.value[self.cursor_position :]) From 372d83572c50d9782d11e1b36c05c9af68fb1378 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 26 Jan 2023 21:06:46 +0000 Subject: [PATCH 43/83] Start to improve the naming of binding-oriented actions Don't focus on home/end and things like that, and also try and keep all of the related names related. --- src/textual/widgets/_input.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index 0b0f78130..2b55233fd 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -83,16 +83,16 @@ class Input(Widget, can_focus=True): BINDINGS = [ Binding("left", "cursor_left", "cursor left", show=False), + Binding("ctrl+left", "cursor_left_word", "cursor left word", show=False), Binding("right", "cursor_right", "cursor right", show=False), + Binding("ctrl+right", "cursor_right_word", "cursor right word", show=False), Binding("home,ctrl+a", "home", "home", show=False), Binding("end,ctrl+e", "end", "end", show=False), - Binding("ctrl+right", "next_word", "next word", show=False), - Binding("ctrl+left", "previous_word", "previous word", show=False), Binding("enter", "submit", "submit", show=False), Binding("backspace", "delete_left", "delete left", show=False), + Binding("ctrl+u", "delete_left_all", "delete all to the left", show=False), Binding("delete,ctrl+d", "delete_right", "delete right", show=False), - Binding("ctrl+k", "delete_to_end", "delete to end", show=False), - Binding("ctrl+u", "delete_to_start", "delete to start", show=False), + Binding("ctrl+k", "delete_right_all", "delete all to the right", show=False), ] COMPONENT_CLASSES = {"input--cursor", "input--placeholder"} @@ -310,12 +310,7 @@ class Input(Widget, can_focus=True): _WORD_START = re.compile(r"(?<=\W)\w") - def action_next_word(self) -> None: - hit = re.search(self._WORD_START, self.value[self.cursor_position :]) - if hit is not None: - self.cursor_position += hit.start() - - def action_previous_word(self) -> None: + def action_cursor_left_word(self) -> None: try: *_, hit = re.finditer(self._WORD_START, self.value[: self.cursor_position]) except ValueError: @@ -323,6 +318,11 @@ class Input(Widget, can_focus=True): else: self.cursor_position = hit.start() + def action_cursor_right_word(self) -> None: + hit = re.search(self._WORD_START, self.value[self.cursor_position :]) + if hit is not None: + self.cursor_position += hit.start() + def action_delete_right(self) -> None: value = self.value delete_position = self.cursor_position @@ -331,6 +331,10 @@ class Input(Widget, can_focus=True): self.value = f"{before}{after}" self.cursor_position = delete_position + def action_delete_right_all(self) -> None: + """Delete from the cursor location to the end of input.""" + self.value = self.value[: self.cursor_position] + def action_delete_left(self) -> None: if self.cursor_position <= 0: # Cursor at the start, so nothing to delete @@ -348,11 +352,7 @@ class Input(Widget, can_focus=True): self.value = f"{before}{after}" self.cursor_position = delete_position - def action_delete_to_end(self) -> None: - """Delete from the cursor location to the end of input.""" - self.value = self.value[: self.cursor_position] - - def action_delete_to_start(self) -> None: + def action_delete_left_all(self) -> None: """Delete from the cursor location to the start of input.""" if self.cursor_position > 0: self.value = self.value[self.cursor_position :] From 2fa0956227670f376413afa486f242b1e98ffd3d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 26 Jan 2023 21:11:33 +0000 Subject: [PATCH 44/83] Improve the documentation for the movement and editing actions --- src/textual/widgets/_input.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index 2b55233fd..7849cd13b 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -297,20 +297,25 @@ class Input(Widget, can_focus=True): self.cursor_position += len(text) def action_cursor_left(self) -> None: + """Move the cursor one position to the left.""" self.cursor_position -= 1 def action_cursor_right(self) -> None: + """Move the cursor one position to the right.""" self.cursor_position += 1 def action_home(self) -> None: + """Move the cursor to the start of the input.""" self.cursor_position = 0 def action_end(self) -> None: + """Move the cursor to the end of the input.""" self.cursor_position = len(self.value) _WORD_START = re.compile(r"(?<=\W)\w") def action_cursor_left_word(self) -> None: + """Move the cursor left to the start of a word.""" try: *_, hit = re.finditer(self._WORD_START, self.value[: self.cursor_position]) except ValueError: @@ -319,11 +324,13 @@ class Input(Widget, can_focus=True): self.cursor_position = hit.start() def action_cursor_right_word(self) -> None: + """Move the cursor right to the start of a word.""" hit = re.search(self._WORD_START, self.value[self.cursor_position :]) if hit is not None: self.cursor_position += hit.start() def action_delete_right(self) -> None: + """Delete one character at the current cursor position.""" value = self.value delete_position = self.cursor_position before = value[:delete_position] @@ -332,10 +339,11 @@ class Input(Widget, can_focus=True): self.cursor_position = delete_position def action_delete_right_all(self) -> None: - """Delete from the cursor location to the end of input.""" + """Delete the current character and all characters to the right of the cursor position.""" self.value = self.value[: self.cursor_position] def action_delete_left(self) -> None: + """Delete one character to the left of the current cursor position.""" if self.cursor_position <= 0: # Cursor at the start, so nothing to delete return @@ -353,7 +361,7 @@ class Input(Widget, can_focus=True): self.cursor_position = delete_position def action_delete_left_all(self) -> None: - """Delete from the cursor location to the start of input.""" + """Delete all characters to the left of the cursor position.""" if self.cursor_position > 0: self.value = self.value[self.cursor_position :] self.cursor_position = 0 From 0675b40ae85358277c2ca2027a3d23eee2231de0 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 26 Jan 2023 21:35:36 +0000 Subject: [PATCH 45/83] Add support for deleting an Input word leftward --- src/textual/widgets/_input.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index 7849cd13b..168c2aaf8 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -90,6 +90,9 @@ class Input(Widget, can_focus=True): Binding("end,ctrl+e", "end", "end", show=False), Binding("enter", "submit", "submit", show=False), Binding("backspace", "delete_left", "delete left", show=False), + Binding( + "ctrl+w", "delete_left_word", "delete left to start of word", show=False + ), Binding("ctrl+u", "delete_left_all", "delete all to the left", show=False), Binding("delete,ctrl+d", "delete_right", "delete right", show=False), Binding("ctrl+k", "delete_right_all", "delete all to the right", show=False), @@ -360,6 +363,19 @@ class Input(Widget, can_focus=True): self.value = f"{before}{after}" self.cursor_position = delete_position + def action_delete_left_word(self) -> None: + """Delete leftward of the cursor position to the start of a word.""" + if self.cursor_position <= 0: + return + after = self.value[self.cursor_position :] + try: + *_, hit = re.finditer(self._WORD_START, self.value[: self.cursor_position]) + except ValueError: + self.cursor_position = 0 + else: + self.cursor_position = hit.start() + self.value = f"{self.value[: self.cursor_position]}{after}" + def action_delete_left_all(self) -> None: """Delete all characters to the left of the cursor position.""" if self.cursor_position > 0: From 3399fb868f23d5f2918c51a2ba5fd8675d013c31 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 26 Jan 2023 21:48:58 +0000 Subject: [PATCH 46/83] Add support for deleting an Input word rightward --- src/textual/widgets/_input.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index 168c2aaf8..0c87a7ab4 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -95,6 +95,9 @@ class Input(Widget, can_focus=True): ), Binding("ctrl+u", "delete_left_all", "delete all to the left", show=False), Binding("delete,ctrl+d", "delete_right", "delete right", show=False), + Binding( + "ctrl+f", "delete_right_word", "delete right to start of word", show=False + ), Binding("ctrl+k", "delete_right_all", "delete all to the right", show=False), ] @@ -341,6 +344,15 @@ class Input(Widget, can_focus=True): self.value = f"{before}{after}" self.cursor_position = delete_position + def action_delete_right_word(self) -> None: + """Delete the current character and all rightward to the start of the next word.""" + after = self.value[self.cursor_position :] + hit = re.search(self._WORD_START, after) + if hit is None: + self.value = self.value[: self.cursor_position] + else: + self.value = f"{self.value[: self.cursor_position]}{after[hit.end()-1 :]}" + def action_delete_right_all(self) -> None: """Delete the current character and all characters to the right of the cursor position.""" self.value = self.value[: self.cursor_position] From 7de4924cf2b014b7bc4ea9ef57bc59e79e8816d8 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 26 Jan 2023 21:52:40 +0000 Subject: [PATCH 47/83] If going rightward one word an no more word go to end --- src/textual/widgets/_input.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index 0c87a7ab4..ba55f2cf8 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -332,7 +332,9 @@ class Input(Widget, can_focus=True): def action_cursor_right_word(self) -> None: """Move the cursor right to the start of a word.""" hit = re.search(self._WORD_START, self.value[self.cursor_position :]) - if hit is not None: + if hit is None: + self.cursor_position = len(self.value) + else: self.cursor_position += hit.start() def action_delete_right(self) -> None: From ae73c4783f4df82bebe261b2d863ff5d03a6a1e1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 27 Jan 2023 09:58:07 +0100 Subject: [PATCH 48/83] use monotonic rather than sleep --- src/textual/_wait.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/textual/_wait.py b/src/textual/_wait.py index 11df9b770..f85def32c 100644 --- a/src/textual/_wait.py +++ b/src/textual/_wait.py @@ -1,5 +1,5 @@ from asyncio import sleep -from time import process_time, time +from time import process_time, monotonic SLEEP_GRANULARITY: float = 1 / 50 SLEEP_IDLE: float = SLEEP_GRANULARITY / 2.0 @@ -14,13 +14,13 @@ async def wait_for_idle( min_sleep: Minimum time to wait. Defaults to 1/50. max_sleep: Maximum time to wait. Defaults to 1. """ - start_time = time() + start_time = monotonic() while True: cpu_time = process_time() await sleep(SLEEP_GRANULARITY) cpu_elapsed = process_time() - cpu_time - elapsed_time = time() - start_time + elapsed_time = monotonic() - start_time if elapsed_time >= max_sleep: break if elapsed_time > min_sleep and cpu_elapsed < SLEEP_IDLE: From cbe62fadc3c75c3b45560cf2e6cbf9d750445f6d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Fri, 27 Jan 2023 13:28:25 +0000 Subject: [PATCH 49/83] Add unit tests for all the expand/collapse/toggle Tree methods --- tests/tree/test_tree_expand_etc.py | 106 +++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 tests/tree/test_tree_expand_etc.py diff --git a/tests/tree/test_tree_expand_etc.py b/tests/tree/test_tree_expand_etc.py new file mode 100644 index 000000000..a55a44e98 --- /dev/null +++ b/tests/tree/test_tree_expand_etc.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from textual.app import App, ComposeResult +from textual.widgets import Tree + + +class TreeApp(App[None]): + """Test tree app.""" + + def compose(self) -> ComposeResult: + yield Tree("Test") + + def on_mount(self) -> None: + tree = self.query_one(Tree) + for n in range(10): + tree.root.add(f"Trunk {n}") + node = tree.root.children[0] + for n in range(10): + node = node.add(str(n)) + + +async def test_tree_node_expand() -> None: + """Expanding one node should not expand all nodes.""" + async with TreeApp().run_test() as pilot: + pilot.app.query_one(Tree).root.expand() + assert pilot.app.query_one(Tree).root.is_expanded is True + check_node = pilot.app.query_one(Tree).root.children[0] + while check_node.children: + assert any(child.is_expanded for child in check_node.children) is False + check_node = check_node.children[0] + + +async def test_tree_node_expand_all() -> None: + """Expanding all on a node should expand all child nodes too.""" + async with TreeApp().run_test() as pilot: + pilot.app.query_one(Tree).root.expand_all() + assert pilot.app.query_one(Tree).root.is_expanded is True + check_node = pilot.app.query_one(Tree).root.children[0] + while check_node.children: + assert check_node.children[0].is_expanded is True + assert any(child.is_expanded for child in check_node.children[1:]) is False + check_node = check_node.children[0] + + +async def test_tree_node_collapse() -> None: + """Collapsing one node should not collapse all nodes.""" + async with TreeApp().run_test() as pilot: + pilot.app.query_one(Tree).root.expand_all() + pilot.app.query_one(Tree).root.children[0].collapse() + assert pilot.app.query_one(Tree).root.children[0].is_expanded is False + check_node = pilot.app.query_one(Tree).root.children[0].children[0] + while check_node.children: + assert all(child.is_expanded for child in check_node.children) is True + check_node = check_node.children[0] + + +async def test_tree_node_collapse_all() -> None: + """Collapsing all on a node should collapse all child noes too.""" + async with TreeApp().run_test() as pilot: + pilot.app.query_one(Tree).root.expand_all() + pilot.app.query_one(Tree).root.children[0].collapse_all() + assert pilot.app.query_one(Tree).root.children[0].is_expanded is False + check_node = pilot.app.query_one(Tree).root.children[0].children[0] + while check_node.children: + assert check_node.children[0].is_expanded is False + assert all(child.is_expanded for child in check_node.children[1:]) is True + check_node = check_node.children[0] + + +async def test_tree_node_toggle() -> None: + """Toggling one node should not toggle all nodes.""" + async with TreeApp().run_test() as pilot: + assert pilot.app.query_one(Tree).root.is_expanded is False + check_node = pilot.app.query_one(Tree).root.children[0] + while check_node.children: + assert any(child.is_expanded for child in check_node.children) is False + check_node = check_node.children[0] + pilot.app.query_one(Tree).root.toggle() + assert pilot.app.query_one(Tree).root.is_expanded is True + check_node = pilot.app.query_one(Tree).root.children[0] + while check_node.children: + assert any(child.is_expanded for child in check_node.children) is False + check_node = check_node.children[0] + + +async def test_tree_node_toggle_all() -> None: + """Toggling all on a node should toggle all child nodes too.""" + async with TreeApp().run_test() as pilot: + assert pilot.app.query_one(Tree).root.is_expanded is False + check_node = pilot.app.query_one(Tree).root.children[0] + while check_node.children: + assert any(child.is_expanded for child in check_node.children) is False + check_node = check_node.children[0] + pilot.app.query_one(Tree).root.toggle_all() + assert pilot.app.query_one(Tree).root.is_expanded is True + check_node = pilot.app.query_one(Tree).root.children[0] + while check_node.children: + assert check_node.children[0].is_expanded is True + assert any(child.is_expanded for child in check_node.children[1:]) is False + check_node = check_node.children[0] + pilot.app.query_one(Tree).root.toggle_all() + assert pilot.app.query_one(Tree).root.is_expanded is False + check_node = pilot.app.query_one(Tree).root.children[0] + while check_node.children: + assert any(child.is_expanded for child in check_node.children) is False + check_node = check_node.children[0] From f4b29d8b9949f0d3105aa47a6ef2751387e3c2bc Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Fri, 27 Jan 2023 18:41:13 +0000 Subject: [PATCH 50/83] Move the current Input tests into a subdirectory I'm going to be adding more tests for Input, and I don't want to be doing one large monolithic file of them, so this makes a space where Input-targeting tests can live together and be easy to spot. --- tests/{test_input.py => input/test_input_value_visibility.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_input.py => input/test_input_value_visibility.py} (100%) diff --git a/tests/test_input.py b/tests/input/test_input_value_visibility.py similarity index 100% rename from tests/test_input.py rename to tests/input/test_input_value_visibility.py From 7ddf4bbecc9b5877c4fda00e24e2145bbf6cb3d0 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Fri, 27 Jan 2023 18:42:38 +0000 Subject: [PATCH 51/83] Add some initial Input key/action unit tests This is just getting a feel for how I'll go about testing these. The main focus here won't be on the bindings themselves -- they're not really interesting and I feel could change over time anyway as people's tastes settle down. What I want to test here are the actions that get bound. This is just an initial small set of what's going to be a much bigger collection of Input action tests. --- tests/input/test_input_key_actions.py | 72 +++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 tests/input/test_input_key_actions.py diff --git a/tests/input/test_input_key_actions.py b/tests/input/test_input_key_actions.py new file mode 100644 index 000000000..65cef5bcd --- /dev/null +++ b/tests/input/test_input_key_actions.py @@ -0,0 +1,72 @@ +from textual.app import App, ComposeResult +from textual.widgets import Input + + +class InputTester(App[None]): + """Input widget testing app.""" + + def compose(self) -> ComposeResult: + for value, input_id in ( + ("", "empty"), + ("Shiny", "single-word"), + ("Curse your sudden but inevitable betrayal", "multi-no-punctuation"), + ( + "We have done the impossible, and that makes us mighty.", + "multi-punctuation", + ), + ("Long as she does it quiet-like", "multi-and-hyphenated"), + ): + yield Input(value, id=input_id) + + +async def test_input_home() -> None: + """Going home should always land at position zero.""" + async with InputTester().run_test() as pilot: + for input in pilot.app.query(Input): + input.action_home() + assert input.cursor_position == 0 + + +async def test_input_end() -> None: + """Going end should always land at the last position.""" + async with InputTester().run_test() as pilot: + for input in pilot.app.query(Input): + input.action_end() + assert input.cursor_position == len(input.value) + + +async def test_input_right_from_home() -> None: + """Going right should always land at the next position, if there is one.""" + async with InputTester().run_test() as pilot: + for input in pilot.app.query(Input): + input.action_cursor_right() + assert input.cursor_position == (1 if input.value else 0) + + +async def test_input_right_from_end() -> None: + """Going right should always stay put if doing so from the end.""" + async with InputTester().run_test() as pilot: + for input in pilot.app.query(Input): + input.action_end() + input.action_cursor_right() + assert input.cursor_position == len(input.value) + + +async def test_input_left_from_home() -> None: + """Going left from home should stay put.""" + async with InputTester().run_test() as pilot: + for input in pilot.app.query(Input): + input.action_cursor_left() + assert input.cursor_position == 0 + + +async def test_input_left_from_end() -> None: + """Going left from the end should go back one place, where possible.""" + async with InputTester().run_test() as pilot: + for input in pilot.app.query(Input): + input.action_end() + input.action_cursor_left() + assert input.cursor_position == (len(input.value) - 1 if input.value else 0) + + +# TODO: more tests. From b4a3c2e8bb9f9e8d2658cf3f76c188ad5d15738a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 28 Jan 2023 17:23:52 +0100 Subject: [PATCH 52/83] fix for render width --- src/textual/render.py | 18 +- src/textual/widget.py | 4 +- .../__snapshots__/test_snapshots.ambr | 159 ++++++++++++++++++ tests/snapshot_tests/test_snapshots.py | 5 + 4 files changed, 183 insertions(+), 3 deletions(-) diff --git a/src/textual/render.py b/src/textual/render.py index 611ee4420..6d5dbc326 100644 --- a/src/textual/render.py +++ b/src/textual/render.py @@ -2,13 +2,20 @@ from rich.console import Console, RenderableType from rich.protocol import rich_cast -def measure(console: Console, renderable: RenderableType, default: int) -> int: +def measure( + console: Console, + renderable: RenderableType, + default: int, + *, + container_width: int | None = None +) -> int: """Measure a rich renderable. Args: console: A console object. renderable: Rich renderable. default: Default width to use if renderable does not expose dimensions. + container_width: Width of container or None to use console width. Returns: Width in cells @@ -17,6 +24,13 @@ def measure(console: Console, renderable: RenderableType, default: int) -> int: renderable = rich_cast(renderable) get_console_width = getattr(renderable, "__rich_measure__", None) if get_console_width is not None: - render_width = get_console_width(console, console.options).maximum + render_width = get_console_width( + console, + ( + console.options + if container_width is None + else console.options.update_width(container_width) + ), + ).maximum width = max(0, render_width) return width diff --git a/src/textual/widget.py b/src/textual/widget.py index ca34cd05b..a71889031 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -795,7 +795,9 @@ class Widget(DOMNode): console = self.app.console renderable = self._render() - width = measure(console, renderable, container.width) + width = measure( + console, renderable, container.width, container_width=container.width + ) if self.expand: width = max(container.width, width) if self.shrink: diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 52b617982..df2a02df9 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -12380,6 +12380,165 @@ ''' # --- +# name: test_label_wrap + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + LabelWrap + + + + + + + + + + + + + + + + Apple Banana Cherry Mango Fig Guava Pineapple:Dragon Unicorn Centaur Phoenix Ch + + + Apple Banana Cherry Mango Fig Guava Pineapple:Dragon Unicorn Centaur Phoenix  + Chimera Castle + + + ╭────────────────────────────────────────────────────────────────────────────╮ + Apple Banana Cherry Mango Fig Guava Pineapple:Dragon Unicorn Centaur  + Phoenix Chimera Castle + ╰────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + + + + ''' +# --- # name: test_layers ''' diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 868bd91aa..7374899cf 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -193,3 +193,8 @@ def test_demo(snap_compare): press=["down", "down", "down", "_", "_", "_"], terminal_size=(100, 30), ) + + +def test_label_wrap(snap_compare): + """Test Label wrapping with a Panel""" + assert snap_compare("snapshot_apps/label_wrap.py") From e149e413ad6de449f34e6b00ce8762b495341afd Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 28 Jan 2023 17:26:22 +0100 Subject: [PATCH 53/83] changelog [skip ci] --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23a53611d..3d4eddaad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Programmatically setting `overflow_x`/`overflow_y` refreshes the layout correctly https://github.com/Textualize/textual/issues/1616 - Fixed double-paste into `Input` https://github.com/Textualize/textual/issues/1657 - Added a workaround for an apparent Windows Terminal paste issue https://github.com/Textualize/textual/issues/1661 +- Fixes issue with renderable width calculation https://github.com/Textualize/textual/issues/1685 ## [0.10.1] - 2023-01-20 From 938a3b4ed511796feaa0dc6c4f006ffd80a76a11 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sun, 29 Jan 2023 11:20:44 +0000 Subject: [PATCH 54/83] Add a test for going left a word from home This should result in a NOP. --- tests/input/test_input_key_actions.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/input/test_input_key_actions.py b/tests/input/test_input_key_actions.py index 65cef5bcd..e93498c81 100644 --- a/tests/input/test_input_key_actions.py +++ b/tests/input/test_input_key_actions.py @@ -69,4 +69,12 @@ async def test_input_left_from_end() -> None: assert input.cursor_position == (len(input.value) - 1 if input.value else 0) +async def test_input_left_word_from_home() -> None: + """Going left one word from the start should do nothing.""" + async with InputTester().run_test() as pilot: + for input in pilot.app.query(Input): + input.action_cursor_left_word() + assert input.cursor_position == 0 + + # TODO: more tests. From fad87c90f34f5e952c05d38084e5104802647f2a Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sun, 29 Jan 2023 11:29:07 +0000 Subject: [PATCH 55/83] Add a test for going left a word from the end --- tests/input/test_input_key_actions.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/input/test_input_key_actions.py b/tests/input/test_input_key_actions.py index e93498c81..b80164613 100644 --- a/tests/input/test_input_key_actions.py +++ b/tests/input/test_input_key_actions.py @@ -77,4 +77,20 @@ async def test_input_left_word_from_home() -> None: assert input.cursor_position == 0 +async def test_input_left_word_from_end() -> None: + """Going left one word from the end should land correctly..""" + async with InputTester().run_test() as pilot: + expected_at: dict[str | None, int] = { + "empty": 0, + "single-word": 0, + "multi-no-punctuation": 33, + "multi-punctuation": 47, + "multi-and-hyphenated": 26, + } + for input in pilot.app.query(Input): + input.action_end() + input.action_cursor_left_word() + assert input.cursor_position == expected_at[input.id] + + # TODO: more tests. From d5a99425c09c0b9512d280d3069ae3898334e202 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sun, 29 Jan 2023 11:37:12 +0000 Subject: [PATCH 56/83] Add a test for going right a word from the start --- tests/input/test_input_key_actions.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/input/test_input_key_actions.py b/tests/input/test_input_key_actions.py index b80164613..7f50d4c10 100644 --- a/tests/input/test_input_key_actions.py +++ b/tests/input/test_input_key_actions.py @@ -93,4 +93,19 @@ async def test_input_left_word_from_end() -> None: assert input.cursor_position == expected_at[input.id] +async def test_input_right_word_from_start() -> None: + """Going right one word from the start should land correctly..""" + async with InputTester().run_test() as pilot: + expected_at: dict[str | None, int] = { + "empty": 0, + "single-word": 5, + "multi-no-punctuation": 6, + "multi-punctuation": 3, + "multi-and-hyphenated": 5, + } + for input in pilot.app.query(Input): + input.action_cursor_right_word() + assert input.cursor_position == expected_at[input.id] + + # TODO: more tests. From 112c789e3c0b6e6a38f6f9d1aedf9afaff905875 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sun, 29 Jan 2023 11:47:16 +0000 Subject: [PATCH 57/83] Add a test for going right a word from the end --- tests/input/test_input_key_actions.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/input/test_input_key_actions.py b/tests/input/test_input_key_actions.py index 7f50d4c10..bcdce2020 100644 --- a/tests/input/test_input_key_actions.py +++ b/tests/input/test_input_key_actions.py @@ -108,4 +108,13 @@ async def test_input_right_word_from_start() -> None: assert input.cursor_position == expected_at[input.id] +async def test_input_right_word_from_home() -> None: + """Going right one word from the end should do nothing.""" + async with InputTester().run_test() as pilot: + for input in pilot.app.query(Input): + input.action_end() + input.action_cursor_right_word() + assert input.cursor_position == len(input.value) + + # TODO: more tests. From af4a6b0f68f4e2793cf1f527a4650166f367007e Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sun, 29 Jan 2023 11:48:48 +0000 Subject: [PATCH 58/83] Fix a typo/thinko in a test name --- tests/input/test_input_key_actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/input/test_input_key_actions.py b/tests/input/test_input_key_actions.py index bcdce2020..43f82203e 100644 --- a/tests/input/test_input_key_actions.py +++ b/tests/input/test_input_key_actions.py @@ -108,7 +108,7 @@ async def test_input_right_word_from_start() -> None: assert input.cursor_position == expected_at[input.id] -async def test_input_right_word_from_home() -> None: +async def test_input_right_word_from_end() -> None: """Going right one word from the end should do nothing.""" async with InputTester().run_test() as pilot: for input in pilot.app.query(Input): From 5bf0542e47e8e6cf311d4d9d92cea804da7bcdd9 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sun, 29 Jan 2023 11:49:29 +0000 Subject: [PATCH 59/83] Rename a test to be more in line with the others --- tests/input/test_input_key_actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/input/test_input_key_actions.py b/tests/input/test_input_key_actions.py index 43f82203e..a387f4e90 100644 --- a/tests/input/test_input_key_actions.py +++ b/tests/input/test_input_key_actions.py @@ -93,7 +93,7 @@ async def test_input_left_word_from_end() -> None: assert input.cursor_position == expected_at[input.id] -async def test_input_right_word_from_start() -> None: +async def test_input_right_word_from_home() -> None: """Going right one word from the start should land correctly..""" async with InputTester().run_test() as pilot: expected_at: dict[str | None, int] = { From 054c23ab2990202ee1c5be824a18b1964afe85f9 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sun, 29 Jan 2023 11:53:21 +0000 Subject: [PATCH 60/83] Add a test for using right-word to get to the end of an input --- tests/input/test_input_key_actions.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/input/test_input_key_actions.py b/tests/input/test_input_key_actions.py index a387f4e90..521229ae6 100644 --- a/tests/input/test_input_key_actions.py +++ b/tests/input/test_input_key_actions.py @@ -117,4 +117,22 @@ async def test_input_right_word_from_end() -> None: assert input.cursor_position == len(input.value) +async def test_input_right_word_to_the_end() -> None: + """Using right-word to get to the end should hop the correct number of times.""" + async with InputTester().run_test() as pilot: + expected_hops: dict[str | None, int] = { + "empty": 0, + "single-word": 1, + "multi-no-punctuation": 6, + "multi-punctuation": 10, + "multi-and-hyphenated": 7, + } + for input in pilot.app.query(Input): + hops = 0 + while input.cursor_position < len(input.value): + input.action_cursor_right_word() + hops += 1 + assert hops == expected_hops[input.id] + + # TODO: more tests. From b7203edd4a399528f784b84de314ce562e45dba4 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sun, 29 Jan 2023 11:57:00 +0000 Subject: [PATCH 61/83] Add a test for using left-word to get home from the end of an input --- tests/input/test_input_key_actions.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/input/test_input_key_actions.py b/tests/input/test_input_key_actions.py index 521229ae6..641f2ee91 100644 --- a/tests/input/test_input_key_actions.py +++ b/tests/input/test_input_key_actions.py @@ -135,4 +135,23 @@ async def test_input_right_word_to_the_end() -> None: assert hops == expected_hops[input.id] +async def test_input_left_word_from_the_end() -> None: + """Using left-word to get home from the end should hop the correct number of times.""" + async with InputTester().run_test() as pilot: + expected_hops: dict[str | None, int] = { + "empty": 0, + "single-word": 1, + "multi-no-punctuation": 6, + "multi-punctuation": 10, + "multi-and-hyphenated": 7, + } + for input in pilot.app.query(Input): + input.action_end() + hops = 0 + while input.cursor_position: + input.action_cursor_left_word() + hops += 1 + assert hops == expected_hops[input.id] + + # TODO: more tests. From 1230ca3694260d8b975f180365c5787665d1ce11 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Sun, 29 Jan 2023 11:58:46 +0000 Subject: [PATCH 62/83] Rename the key action tests There are actions that relate to editing the input too, which I also want to test, but I'm not minded to lump them all in the same file. So here I'm renaming to make it clear these tests are just about movement. The editing ones will come next in their own file. --- ..._input_key_actions.py => test_input_key_movement_actions.py} | 2 ++ 1 file changed, 2 insertions(+) rename tests/input/{test_input_key_actions.py => test_input_key_movement_actions.py} (98%) diff --git a/tests/input/test_input_key_actions.py b/tests/input/test_input_key_movement_actions.py similarity index 98% rename from tests/input/test_input_key_actions.py rename to tests/input/test_input_key_movement_actions.py index 641f2ee91..6297eee9e 100644 --- a/tests/input/test_input_key_actions.py +++ b/tests/input/test_input_key_movement_actions.py @@ -1,3 +1,5 @@ +"""Unit tests for Input widget position movement actions.""" + from textual.app import App, ComposeResult from textual.widgets import Input From e199dc226b4c88b1b2a366786894e70c2eaae2ed Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 30 Jan 2023 09:51:46 +0000 Subject: [PATCH 63/83] Start Input unit tests for actions that modify the text --- .../test_input_key_modification_actions.py | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 tests/input/test_input_key_modification_actions.py diff --git a/tests/input/test_input_key_modification_actions.py b/tests/input/test_input_key_modification_actions.py new file mode 100644 index 000000000..53344d2a3 --- /dev/null +++ b/tests/input/test_input_key_modification_actions.py @@ -0,0 +1,83 @@ +"""Unit tests for Input widget value modification actions.""" + +from textual.app import App, ComposeResult +from textual.widgets import Input + + +TEST_INPUTS: dict[str | None, str] = { + "empty": "", + "multi-no-punctuation": "Curse your sudden but inevitable betrayal", + "multi-punctuation": "We have done the impossible, and that makes us mighty.", + "multi-and-hyphenated": "Long as she does it quiet-like", +} + + +class InputTester(App[None]): + """Input widget testing app.""" + + def compose(self) -> ComposeResult: + for input_id, value in TEST_INPUTS.items(): + yield Input(value, id=input_id) + + +async def test_delete_left_from_home() -> None: + """Deleting left from home should do nothing.""" + async with InputTester().run_test() as pilot: + for input in pilot.app.query(Input): + input.action_delete_left() + assert input.cursor_position == 0 + assert input.value == TEST_INPUTS[input.id] + + +async def test_delete_left_from_end() -> None: + """Deleting left from home should do remove the last character (if there is one).""" + async with InputTester().run_test() as pilot: + for input in pilot.app.query(Input): + input.action_end() + input.action_delete_left() + assert input.cursor_position == len(input.value) + assert input.value == TEST_INPUTS[input.id][:-1] + + +async def test_delete_left_word_from_home() -> None: + """Deleting word left from home should do nothing.""" + async with InputTester().run_test() as pilot: + for input in pilot.app.query(Input): + input.action_delete_left_word() + assert input.cursor_position == 0 + assert input.value == TEST_INPUTS[input.id] + + +async def test_delete_left_word_from_end() -> None: + """Deleting word left from end should remove the expected text.""" + async with InputTester().run_test() as pilot: + expected: dict[str | None, str] = { + "empty": "", + "multi-no-punctuation": "Curse your sudden but inevitable ", + "multi-punctuation": "We have done the impossible, and that makes us ", + "multi-and-hyphenated": "Long as she does it quiet-", + } + for input in pilot.app.query(Input): + input.action_end() + input.action_delete_left_word() + assert input.cursor_position == len(input.value) + assert input.value == expected[input.id] + + +async def test_delete_left_all_from_home() -> None: + """Deleting all left from home should do nothing.""" + async with InputTester().run_test() as pilot: + for input in pilot.app.query(Input): + input.action_delete_left_all() + assert input.cursor_position == 0 + assert input.value == TEST_INPUTS[input.id] + + +async def test_delete_left_all_from_end() -> None: + """Deleting all left from end should empty the input value.""" + async with InputTester().run_test() as pilot: + for input in pilot.app.query(Input): + input.action_end() + input.action_delete_left_all() + assert input.cursor_position == 0 + assert input.value == "" From af2189fdeb8833dc32a4dc0de17830f38466038c Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 30 Jan 2023 10:12:35 +0000 Subject: [PATCH 64/83] Fix a docstring typo --- tests/input/test_input_key_modification_actions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/input/test_input_key_modification_actions.py b/tests/input/test_input_key_modification_actions.py index 53344d2a3..d54f5a44b 100644 --- a/tests/input/test_input_key_modification_actions.py +++ b/tests/input/test_input_key_modification_actions.py @@ -30,7 +30,7 @@ async def test_delete_left_from_home() -> None: async def test_delete_left_from_end() -> None: - """Deleting left from home should do remove the last character (if there is one).""" + """Deleting left from end should remove the last character (if there is one).""" async with InputTester().run_test() as pilot: for input in pilot.app.query(Input): input.action_end() From 9e23a79ec1028e3ca2bddac524136698876f9157 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 30 Jan 2023 10:14:53 +0000 Subject: [PATCH 65/83] Add more Input unit tests for actions that modify the text This time all the things to do with deleting right. --- .../test_input_key_modification_actions.py | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/input/test_input_key_modification_actions.py b/tests/input/test_input_key_modification_actions.py index d54f5a44b..2fd37cb99 100644 --- a/tests/input/test_input_key_modification_actions.py +++ b/tests/input/test_input_key_modification_actions.py @@ -81,3 +81,66 @@ async def test_delete_left_all_from_end() -> None: input.action_delete_left_all() assert input.cursor_position == 0 assert input.value == "" + + +async def test_delete_right_from_home() -> None: + """Deleting right from home should delete one character (if there is any to delete).""" + async with InputTester().run_test() as pilot: + for input in pilot.app.query(Input): + input.action_delete_right() + assert input.cursor_position == 0 + assert input.value == TEST_INPUTS[input.id][1:] + + +async def test_delete_right_from_end() -> None: + """Deleting right from end should not change the input's value.""" + async with InputTester().run_test() as pilot: + for input in pilot.app.query(Input): + input.action_end() + input.action_delete_right() + assert input.cursor_position == len(input.value) + assert input.value == TEST_INPUTS[input.id] + + +async def test_delete_right_word_from_home() -> None: + """Deleting word right from home should delete one word (if there is one).""" + async with InputTester().run_test() as pilot: + expected: dict[str | None, str] = { + "empty": "", + "multi-no-punctuation": "your sudden but inevitable betrayal", + "multi-punctuation": "have done the impossible, and that makes us mighty.", + "multi-and-hyphenated": "as she does it quiet-like", + } + for input in pilot.app.query(Input): + input.action_delete_right_word() + assert input.cursor_position == 0 + assert input.value == expected[input.id] + + +async def test_delete_right_word_from_end() -> None: + """Deleting word right from end should not change the input's value.""" + async with InputTester().run_test() as pilot: + for input in pilot.app.query(Input): + input.action_end() + input.action_delete_right_word() + assert input.cursor_position == len(input.value) + assert input.value == TEST_INPUTS[input.id] + + +async def test_delete_right_all_from_home() -> None: + """Deleting all right home should remove everything in the input.""" + async with InputTester().run_test() as pilot: + for input in pilot.app.query(Input): + input.action_delete_right_all() + assert input.cursor_position == 0 + assert input.value == "" + + +async def test_delete_right_all_from_end() -> None: + """Deleting all right from end should not change the input's value.""" + async with InputTester().run_test() as pilot: + for input in pilot.app.query(Input): + input.action_end() + input.action_delete_right_all() + assert input.cursor_position == len(input.value) + assert input.value == TEST_INPUTS[input.id] From a1752248d436a25ba20d3fe29eba8a9de3cb92c2 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 30 Jan 2023 10:23:37 +0000 Subject: [PATCH 66/83] Help some older Pythons along --- tests/input/test_input_key_modification_actions.py | 2 ++ tests/input/test_input_key_movement_actions.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/tests/input/test_input_key_modification_actions.py b/tests/input/test_input_key_modification_actions.py index 2fd37cb99..51b124d7d 100644 --- a/tests/input/test_input_key_modification_actions.py +++ b/tests/input/test_input_key_modification_actions.py @@ -1,5 +1,7 @@ """Unit tests for Input widget value modification actions.""" +from __future__ import annotations + from textual.app import App, ComposeResult from textual.widgets import Input diff --git a/tests/input/test_input_key_movement_actions.py b/tests/input/test_input_key_movement_actions.py index 6297eee9e..2d0bc1338 100644 --- a/tests/input/test_input_key_movement_actions.py +++ b/tests/input/test_input_key_movement_actions.py @@ -1,5 +1,7 @@ """Unit tests for Input widget position movement actions.""" +from __future__ import annotations + from textual.app import App, ComposeResult from textual.widgets import Input From 9df61ba830fbf9b061b9ce2fa4f89abb69c6cf24 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 30 Jan 2023 11:21:55 +0000 Subject: [PATCH 67/83] Update the CHANGELOG --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 40d10bbdb..e4c47ecdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [0.11.0] - Unreleased +### Added + +- Added more keyboard actions and related bindings to `Input` https://github.com/Textualize/textual/pull/1676 + ### Changed - Breaking change: `TreeNode` can no longer be imported from `textual.widgets`; it is now available via `from textual.widgets.tree import TreeNode`. https://github.com/Textualize/textual/pull/1637 From b585f25d7beb8a0c85204e86c19c8698c95ed87f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 30 Jan 2023 12:36:49 +0100 Subject: [PATCH 68/83] scroll sensitivity --- CHANGELOG.md | 2 ++ src/textual/app.py | 2 ++ src/textual/widget.py | 34 ++++++++++++++++++++++------------ 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23a53611d..429ae1f8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added the coroutines `Animator.wait_until_complete` and `pilot.wait_for_scheduled_animations` that allow waiting for all current and scheduled animations https://github.com/Textualize/textual/issues/1658 - Added the method `Animator.is_being_animated` that checks if an attribute of an object is being animated or is scheduled for animation +- Added App.scroll_sensitivity to adjust how many lines the scroll wheel moves the scroll position https://github.com/orgs/Textualize/projects/5?pane=issue&itemId=12998591 +- Added Shift+scroll wheel and ctrl+scroll wheen to scroll horizontally ### Changed diff --git a/src/textual/app.py b/src/textual/app.py index 8712353e1..b43960396 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -369,6 +369,8 @@ class App(Generic[ReturnType], DOMNode): self.css_path = css_paths self._registry: WeakSet[DOMNode] = WeakSet() + self.scroll_sensitivity: float = 3 + self._installed_screens: WeakValueDictionary[ str, Screen | Callable[[], Screen] ] = WeakValueDictionary() diff --git a/src/textual/widget.py b/src/textual/widget.py index ca34cd05b..593660261 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1575,7 +1575,7 @@ class Widget(DOMNode): """ return self.scroll_to( - x=self.scroll_target_x - 1, + x=self.scroll_target_x - self.app.scroll_sensitivity, animate=animate, speed=speed, duration=duration, @@ -1607,7 +1607,7 @@ class Widget(DOMNode): """ return self.scroll_to( - x=self.scroll_target_x + 1, + x=self.scroll_target_x + self.app.scroll_sensitivity, animate=animate, speed=speed, duration=duration, @@ -1639,7 +1639,7 @@ class Widget(DOMNode): """ return self.scroll_to( - y=self.scroll_target_y + 1, + y=self.scroll_target_y + self.app.scroll_sensitivity, animate=animate, speed=speed, duration=duration, @@ -1671,7 +1671,7 @@ class Widget(DOMNode): """ return self.scroll_to( - y=self.scroll_target_y - 1, + y=self.scroll_target_y - +self.app.scroll_sensitivity, animate=animate, speed=speed, duration=duration, @@ -2478,15 +2478,25 @@ class Widget(DOMNode): if self._has_focus_within: self.app.update_styles(self) - def _on_mouse_scroll_down(self, event) -> None: - if self.allow_vertical_scroll: - if self.scroll_down(animate=False): - event.stop() + def _on_mouse_scroll_down(self, event: events.MouseScrollDown) -> None: + if event.ctrl or event.shift: + if self.allow_horizontal_scroll: + if self.scroll_right(animate=False): + event.stop() + else: + if self.allow_vertical_scroll: + if self.scroll_down(animate=False): + event.stop() - def _on_mouse_scroll_up(self, event) -> None: - if self.allow_vertical_scroll: - if self.scroll_up(animate=False): - event.stop() + def _on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None: + if event.ctrl or event.shift: + if self.allow_horizontal_scroll: + if self.scroll_left(animate=False): + event.stop() + else: + if self.allow_vertical_scroll: + if self.scroll_up(animate=False): + event.stop() def _on_scroll_to(self, message: ScrollTo) -> None: if self._allow_scroll: From ea6afc957f4dfc7f91d35d47b84d2dc593304213 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 30 Jan 2023 12:44:58 +0100 Subject: [PATCH 69/83] separate scroll sensitivity --- src/textual/app.py | 3 ++- src/textual/widget.py | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index b43960396..142a51460 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -369,7 +369,8 @@ class App(Generic[ReturnType], DOMNode): self.css_path = css_paths self._registry: WeakSet[DOMNode] = WeakSet() - self.scroll_sensitivity: float = 3 + self.scroll_sensitivity_x: float = 4 + self.scroll_sensitivity_y: float = 2 self._installed_screens: WeakValueDictionary[ str, Screen | Callable[[], Screen] diff --git a/src/textual/widget.py b/src/textual/widget.py index 593660261..d2d78c14b 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1575,7 +1575,7 @@ class Widget(DOMNode): """ return self.scroll_to( - x=self.scroll_target_x - self.app.scroll_sensitivity, + x=self.scroll_target_x - self.app.scroll_sensitivity_x, animate=animate, speed=speed, duration=duration, @@ -1607,7 +1607,7 @@ class Widget(DOMNode): """ return self.scroll_to( - x=self.scroll_target_x + self.app.scroll_sensitivity, + x=self.scroll_target_x + self.app.scroll_sensitivity_x, animate=animate, speed=speed, duration=duration, @@ -1639,7 +1639,7 @@ class Widget(DOMNode): """ return self.scroll_to( - y=self.scroll_target_y + self.app.scroll_sensitivity, + y=self.scroll_target_y + self.app.scroll_sensitivity_y, animate=animate, speed=speed, duration=duration, @@ -1671,7 +1671,7 @@ class Widget(DOMNode): """ return self.scroll_to( - y=self.scroll_target_y - +self.app.scroll_sensitivity, + y=self.scroll_target_y - +self.app.scroll_sensitivity_y, animate=animate, speed=speed, duration=duration, From 52ae868ddf3e9a33e6f25d73b54d806149c73d23 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 30 Jan 2023 12:46:50 +0100 Subject: [PATCH 70/83] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 429ae1f8e..78857f76c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added the coroutines `Animator.wait_until_complete` and `pilot.wait_for_scheduled_animations` that allow waiting for all current and scheduled animations https://github.com/Textualize/textual/issues/1658 - Added the method `Animator.is_being_animated` that checks if an attribute of an object is being animated or is scheduled for animation -- Added App.scroll_sensitivity to adjust how many lines the scroll wheel moves the scroll position https://github.com/orgs/Textualize/projects/5?pane=issue&itemId=12998591 +- Added App.scroll_sensitivity_x and App.scroll_sensitivity_y to adjust how many lines the scroll wheel moves the scroll position https://github.com/orgs/Textualize/projects/5?pane=issue&itemId=12998591 - Added Shift+scroll wheel and ctrl+scroll wheen to scroll horizontally ### Changed From 18946eaa09cfb0245c900631105e7b391e57f431 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 30 Jan 2023 12:53:45 +0100 Subject: [PATCH 71/83] doc attributes --- src/textual/app.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/textual/app.py b/src/textual/app.py index 142a51460..286278b04 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -370,7 +370,9 @@ class App(Generic[ReturnType], DOMNode): self._registry: WeakSet[DOMNode] = WeakSet() self.scroll_sensitivity_x: float = 4 + """Number of lines to scroll in the X direction with wheel or trackpad.""" self.scroll_sensitivity_y: float = 2 + """Number of lines to scroll in the Y direction with wheel or trackpad.""" self._installed_screens: WeakValueDictionary[ str, Screen | Callable[[], Screen] From 7523c6d680687ac0c252d6675e7622e23e0077b8 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 30 Jan 2023 12:56:21 +0100 Subject: [PATCH 72/83] use floats --- src/textual/app.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 286278b04..70554beec 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -369,9 +369,9 @@ class App(Generic[ReturnType], DOMNode): self.css_path = css_paths self._registry: WeakSet[DOMNode] = WeakSet() - self.scroll_sensitivity_x: float = 4 + self.scroll_sensitivity_x: float = 4.0 """Number of lines to scroll in the X direction with wheel or trackpad.""" - self.scroll_sensitivity_y: float = 2 + self.scroll_sensitivity_y: float = 2.0 """Number of lines to scroll in the Y direction with wheel or trackpad.""" self._installed_screens: WeakValueDictionary[ From eb91fc05795526d43df3f83efad381fc56604e81 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 30 Jan 2023 13:01:44 +0100 Subject: [PATCH 73/83] snapshot fix --- .../__snapshots__/test_snapshots.ambr | 162 +++++++++--------- 1 file changed, 81 insertions(+), 81 deletions(-) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 52b617982..1883f87a2 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -10487,169 +10487,169 @@ font-weight: 700; } - .terminal-832370059-matrix { + .terminal-3394521078-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-832370059-title { + .terminal-3394521078-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-832370059-r1 { fill: #c5c8c6 } - .terminal-832370059-r2 { fill: #e3e3e3 } - .terminal-832370059-r3 { fill: #e1e1e1 } - .terminal-832370059-r4 { fill: #23568b } - .terminal-832370059-r5 { fill: #e2e2e2 } - .terminal-832370059-r6 { fill: #004578 } - .terminal-832370059-r7 { fill: #14191f } - .terminal-832370059-r8 { fill: #262626 } - .terminal-832370059-r9 { fill: #e2e2e2;font-weight: bold;text-decoration: underline; } - .terminal-832370059-r10 { fill: #e2e2e2;font-weight: bold } - .terminal-832370059-r11 { fill: #7ae998 } - .terminal-832370059-r12 { fill: #4ebf71;font-weight: bold } - .terminal-832370059-r13 { fill: #008139 } - .terminal-832370059-r14 { fill: #dde8f3;font-weight: bold } - .terminal-832370059-r15 { fill: #ddedf9 } + .terminal-3394521078-r1 { fill: #c5c8c6 } + .terminal-3394521078-r2 { fill: #e3e3e3 } + .terminal-3394521078-r3 { fill: #e1e1e1 } + .terminal-3394521078-r4 { fill: #23568b } + .terminal-3394521078-r5 { fill: #004578 } + .terminal-3394521078-r6 { fill: #e2e2e2 } + .terminal-3394521078-r7 { fill: #262626 } + .terminal-3394521078-r8 { fill: #e2e2e2;font-weight: bold;text-decoration: underline; } + .terminal-3394521078-r9 { fill: #14191f } + .terminal-3394521078-r10 { fill: #e2e2e2;font-weight: bold } + .terminal-3394521078-r11 { fill: #7ae998 } + .terminal-3394521078-r12 { fill: #4ebf71;font-weight: bold } + .terminal-3394521078-r13 { fill: #008139 } + .terminal-3394521078-r14 { fill: #dde8f3;font-weight: bold } + .terminal-3394521078-r15 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - Textual Demo + Textual Demo - - - - Textual Demo - ▅▅ - - TOP - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▃▃ - - Widgets - Textual Demo - - Welcome! Textual is a framework for creating sophisticated - Rich contentapplications with the terminal. - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - Start - CSS▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - - - - - - - - - - -                           Widgets                            -  CTRL+C  Quit  CTRL+B  Sidebar  CTRL+T  Toggle Dark mode  CTRL+S  Screenshot  F1  Notes  + + + + Textual Demo + ▁▁ + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + TOP + + Textual Demo + ▇▇ + WidgetsWelcome! Textual is a framework for creating sophisticated + applications with the terminal. + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + Rich contentStart + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + CSS + + + + + + + + + +                           Widgets                            + + + Textual widgets are powerful interactive components. +  CTRL+C  Quit  CTRL+B  Sidebar  CTRL+T  Toggle Dark mode  CTRL+S  Screenshot  F1  Notes  From 144be3242911d1a84c8585690238c24b43d12e99 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 30 Jan 2023 13:10:07 +0100 Subject: [PATCH 74/83] update docstring [skip CI] --- src/textual/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index 70554beec..41a66b20c 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -370,7 +370,7 @@ class App(Generic[ReturnType], DOMNode): self._registry: WeakSet[DOMNode] = WeakSet() self.scroll_sensitivity_x: float = 4.0 - """Number of lines to scroll in the X direction with wheel or trackpad.""" + """Number of columns to scroll in the X direction with wheel or trackpad.""" self.scroll_sensitivity_y: float = 2.0 """Number of lines to scroll in the Y direction with wheel or trackpad.""" From 5116fcaa029ee41bf2c7b09129443a26c6f70be2 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 30 Jan 2023 13:19:25 +0100 Subject: [PATCH 75/83] merge and annotations --- src/textual/render.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/textual/render.py b/src/textual/render.py index 6d5dbc326..8911c4263 100644 --- a/src/textual/render.py +++ b/src/textual/render.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from rich.console import Console, RenderableType from rich.protocol import rich_cast @@ -7,7 +9,7 @@ def measure( renderable: RenderableType, default: int, *, - container_width: int | None = None + container_width: int | None = None, ) -> int: """Measure a rich renderable. From be62f7aaa492ea27fd6be354668c279c1149de71 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 30 Jan 2023 14:38:30 +0100 Subject: [PATCH 76/83] docstrings [skip ci] --- src/textual/_wait.py | 4 ++-- src/textual/pilot.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/textual/_wait.py b/src/textual/_wait.py index f85def32c..e8b24c9fd 100644 --- a/src/textual/_wait.py +++ b/src/textual/_wait.py @@ -11,8 +11,8 @@ async def wait_for_idle( """Wait until the cpu isn't working very hard. Args: - min_sleep: Minimum time to wait. Defaults to 1/50. - max_sleep: Maximum time to wait. Defaults to 1. + min_sleep: Minimum time to wait. + max_sleep: Maximum time to wait. """ start_time = monotonic() diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 6c8375c61..bd1494f6f 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -38,7 +38,7 @@ class Pilot(Generic[ReturnType]): """Insert a pause. Args: - delay: Seconds to pause, or None to wait for cpu idle. Defaults to None. + delay: Seconds to pause, or None to wait for cpu idle. """ # These sleep zeros, are to force asyncio to give up a time-slice, if delay is None: From 4a302148f8be1cf7c8526aea11502912c62278bc Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 30 Jan 2023 15:08:14 +0100 Subject: [PATCH 77/83] fix typing --- src/textual/render.py | 4 +++- tests/snapshot_tests/test_snapshots.py | 5 ----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/textual/render.py b/src/textual/render.py index 6d5dbc326..8911c4263 100644 --- a/src/textual/render.py +++ b/src/textual/render.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from rich.console import Console, RenderableType from rich.protocol import rich_cast @@ -7,7 +9,7 @@ def measure( renderable: RenderableType, default: int, *, - container_width: int | None = None + container_width: int | None = None, ) -> int: """Measure a rich renderable. diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 7374899cf..868bd91aa 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -193,8 +193,3 @@ def test_demo(snap_compare): press=["down", "down", "down", "_", "_", "_"], terminal_size=(100, 30), ) - - -def test_label_wrap(snap_compare): - """Test Label wrapping with a Panel""" - assert snap_compare("snapshot_apps/label_wrap.py") From 787232b6440d79709ae14c324fb9b39305976023 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 30 Jan 2023 15:34:04 +0100 Subject: [PATCH 78/83] fix removed test --- .../__snapshots__/test_snapshots.ambr | 159 ------------------ 1 file changed, 159 deletions(-) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index df2a02df9..52b617982 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -12380,165 +12380,6 @@ ''' # --- -# name: test_label_wrap - ''' - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - LabelWrap - - - - - - - - - - - - - - - - Apple Banana Cherry Mango Fig Guava Pineapple:Dragon Unicorn Centaur Phoenix Ch - - - Apple Banana Cherry Mango Fig Guava Pineapple:Dragon Unicorn Centaur Phoenix  - Chimera Castle - - - ╭────────────────────────────────────────────────────────────────────────────╮ - Apple Banana Cherry Mango Fig Guava Pineapple:Dragon Unicorn Centaur  - Phoenix Chimera Castle - ╰────────────────────────────────────────────────────────────────────────────╯ - - - - - - - - - - - - ''' -# --- # name: test_layers ''' From c48ed3af78ff86da585f18b5e4b5ef05f52cc575 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 30 Jan 2023 15:39:10 +0100 Subject: [PATCH 79/83] comments and docstrings --- src/textual/_wait.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/textual/_wait.py b/src/textual/_wait.py index e8b24c9fd..08be15de2 100644 --- a/src/textual/_wait.py +++ b/src/textual/_wait.py @@ -8,7 +8,14 @@ SLEEP_IDLE: float = SLEEP_GRANULARITY / 2.0 async def wait_for_idle( min_sleep: float = SLEEP_GRANULARITY, max_sleep: float = 1 ) -> None: - """Wait until the cpu isn't working very hard. + """Wait until the process isn't working very hard. + + This will compare wall clock time with process time, if the process time + is not advancing the same as wall clock time it means the process is in a + sleep state or waiting for idle. + + When the process is idle it suggests that input has been processes and the state + is predictable enough to test. Args: min_sleep: Minimum time to wait. @@ -18,10 +25,17 @@ async def wait_for_idle( while True: cpu_time = process_time() + # asyncio will pause the coroutine for a brief period await sleep(SLEEP_GRANULARITY) + # Calculate the wall clock elapsed time and the process elapsed time cpu_elapsed = process_time() - cpu_time elapsed_time = monotonic() - start_time + + # If we have slept the maximum, we can break if elapsed_time >= max_sleep: break + + # If we have slept the minimum and the cpu elapsed is significantly less + # than wall clock, then we can assume the process has finished working for now if elapsed_time > min_sleep and cpu_elapsed < SLEEP_IDLE: break From f890a72a77ab672c32d4451c7749f274ace1fb4c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 30 Jan 2023 15:45:48 +0100 Subject: [PATCH 80/83] fixed comments --- src/textual/_wait.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/textual/_wait.py b/src/textual/_wait.py index 08be15de2..67a124954 100644 --- a/src/textual/_wait.py +++ b/src/textual/_wait.py @@ -12,7 +12,7 @@ async def wait_for_idle( This will compare wall clock time with process time, if the process time is not advancing the same as wall clock time it means the process is in a - sleep state or waiting for idle. + sleep state or waiting for input. When the process is idle it suggests that input has been processes and the state is predictable enough to test. @@ -25,7 +25,7 @@ async def wait_for_idle( while True: cpu_time = process_time() - # asyncio will pause the coroutine for a brief period + # Sleep for a predetermined amount of time await sleep(SLEEP_GRANULARITY) # Calculate the wall clock elapsed time and the process elapsed time cpu_elapsed = process_time() - cpu_time @@ -35,7 +35,7 @@ async def wait_for_idle( if elapsed_time >= max_sleep: break - # If we have slept the minimum and the cpu elapsed is significantly less + # If we have slept at least the minimum and the cpu elapsed is significantly less # than wall clock, then we can assume the process has finished working for now if elapsed_time > min_sleep and cpu_elapsed < SLEEP_IDLE: break From b8d15c6d614f0434b716a512a66e96ebf6de02d7 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 30 Jan 2023 16:00:57 +0100 Subject: [PATCH 81/83] comment --- src/textual/app.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/textual/app.py b/src/textual/app.py index ad66cf5f2..46ac67903 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -370,6 +370,8 @@ class App(Generic[ReturnType], DOMNode): self.css_path = css_paths self._registry: WeakSet[DOMNode] = WeakSet() + # Sensitivity on X is double the sensitivity on Y to account for + # cells being twice as tall as wide self.scroll_sensitivity_x: float = 4.0 """Number of columns to scroll in the X direction with wheel or trackpad.""" self.scroll_sensitivity_y: float = 2.0 From 44b1888e61f836071708f7c95346a54b59cd14c8 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 30 Jan 2023 16:07:15 +0100 Subject: [PATCH 82/83] fixed URL --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 570b5be1c..8a542d34f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added the coroutines `Animator.wait_until_complete` and `pilot.wait_for_scheduled_animations` that allow waiting for all current and scheduled animations https://github.com/Textualize/textual/issues/1658 - Added the method `Animator.is_being_animated` that checks if an attribute of an object is being animated or is scheduled for animation -- Added App.scroll_sensitivity_x and App.scroll_sensitivity_y to adjust how many lines the scroll wheel moves the scroll position https://github.com/orgs/Textualize/projects/5?pane=issue&itemId=12998591 +- Added App.scroll_sensitivity_x and App.scroll_sensitivity_y to adjust how many lines the scroll wheel moves the scroll position https://github.com/Textualize/textual/issues/928 - Added Shift+scroll wheel and ctrl+scroll wheen to scroll horizontally ### Changed From 081110ff962e70a4bbda858c90518b4b98d2fd60 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 30 Jan 2023 16:13:53 +0100 Subject: [PATCH 83/83] fix typo [skip ci] --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a542d34f..2bda20075 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added the coroutines `Animator.wait_until_complete` and `pilot.wait_for_scheduled_animations` that allow waiting for all current and scheduled animations https://github.com/Textualize/textual/issues/1658 - Added the method `Animator.is_being_animated` that checks if an attribute of an object is being animated or is scheduled for animation - Added App.scroll_sensitivity_x and App.scroll_sensitivity_y to adjust how many lines the scroll wheel moves the scroll position https://github.com/Textualize/textual/issues/928 -- Added Shift+scroll wheel and ctrl+scroll wheen to scroll horizontally +- Added Shift+scroll wheel and ctrl+scroll wheel to scroll horizontally ### Changed