diff --git a/CHANGELOG.md b/CHANGELOG.md index 418606fff..2ba7ec153 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased +### Added + +- `work` decorator accepts `description` parameter to add debug string https://github.com/Textualize/textual/issues/2597 + ### Changed - `Placeholder` now sets its color cycle per app https://github.com/Textualize/textual/issues/2590 @@ -15,6 +19,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `Placeholder.reset_color_cycle` + +## [0.26.0] - 2023-05-20 + +### Added + +- Added Widget.can_view + +### Changed + +- Textual will now scroll focused widgets to center if not in view + ## [0.25.0] - 2023-05-17 ### Changed @@ -974,6 +989,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040 - New handler system for messages that doesn't require inheritance - Improved traceback handling +[0.26.0]: https://github.com/Textualize/textual/compare/v0.25.0...v0.26.0 [0.25.0]: https://github.com/Textualize/textual/compare/v0.24.1...v0.25.0 [0.24.1]: https://github.com/Textualize/textual/compare/v0.24.0...v0.24.1 [0.24.0]: https://github.com/Textualize/textual/compare/v0.23.0...v0.24.0 diff --git a/pyproject.toml b/pyproject.toml index 5f84cbc23..7ee61b8bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.25.0" +version = "0.26.0" homepage = "https://github.com/Textualize/textual" description = "Modern Text User Interface framework" authors = ["Will McGugan "] diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 300b52029..fca3b9e3d 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -806,7 +806,7 @@ class Compositor: if self.root is None: raise errors.NoWidget("Widget is not in layout") try: - if self._full_map is not None: + if not self._full_map_invalidated: try: return self._full_map[widget] except KeyError: diff --git a/src/textual/_work_decorator.py b/src/textual/_work_decorator.py index 2688eccc4..afce88686 100644 --- a/src/textual/_work_decorator.py +++ b/src/textual/_work_decorator.py @@ -58,6 +58,7 @@ def work( group: str = "default", exit_on_error: bool = True, exclusive: bool = False, + description: str | None = None, ) -> Callable[FactoryParamSpec, Worker[ReturnType]] | Decorator: """A decorator used to create [workers](/guide/workers). @@ -67,6 +68,9 @@ def work( group: A short string to identify a group of workers. exit_on_error: Exit the app if the worker raises an error. Set to `False` to suppress exceptions. exclusive: Cancel all workers in the same group. + description: Readable description of the worker for debugging purposes. + By default, it uses a string representation of the decorated method + and its arguments. """ def decorator( @@ -87,22 +91,25 @@ def work( self = args[0] assert isinstance(self, DOMNode) - try: - positional_arguments = ", ".join(repr(arg) for arg in args[1:]) - keyword_arguments = ", ".join( - f"{name}={value!r}" for name, value in kwargs.items() - ) - tokens = [positional_arguments, keyword_arguments] - worker_description = f"{method.__name__}({', '.join(token for token in tokens if token)})" - except Exception: - worker_description = "" + if description is not None: + debug_description = description + else: + try: + positional_arguments = ", ".join(repr(arg) for arg in args[1:]) + keyword_arguments = ", ".join( + f"{name}={value!r}" for name, value in kwargs.items() + ) + tokens = [positional_arguments, keyword_arguments] + debug_description = f"{method.__name__}({', '.join(token for token in tokens if token)})" + except Exception: + debug_description = "" worker = cast( "Worker[ReturnType]", self.run_worker( partial(method, *args, **kwargs), name=name or method.__name__, group=group, - description=worker_description, + description=debug_description, exclusive=exclusive, exit_on_error=exit_on_error, ), diff --git a/src/textual/app.py b/src/textual/app.py index d92c858dc..05ae29922 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -71,7 +71,7 @@ from ._wait import wait_for_idle from ._worker_manager import WorkerManager from .actions import ActionParseResult, SkipAction from .await_remove import AwaitRemove -from .binding import Binding, _Bindings +from .binding import Binding, _Bindings, BindingType from .css.query import NoMatches from .css.stylesheet import Stylesheet from .design import ColorSystem @@ -230,7 +230,9 @@ class App(Generic[ReturnType], DOMNode): To update the sub-title while the app is running, you can set the [sub_title][textual.app.App.sub_title] attribute. """ - BINDINGS = [Binding("ctrl+c", "quit", "Quit", show=False, priority=True)] + BINDINGS: ClassVar[list[BindingType]] = [ + Binding("ctrl+c", "quit", "Quit", show=False, priority=True) + ] title: Reactive[str] = Reactive("", compute=False) sub_title: Reactive[str] = Reactive("", compute=False) diff --git a/src/textual/screen.py b/src/textual/screen.py index 4d38d25e5..1d04c0602 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -246,6 +246,9 @@ class Screen(Generic[ScreenResultType], Widget): @property def focus_chain(self) -> list[Widget]: """A list of widgets that may receive focus, in focus order.""" + # TODO: Calculating a focus chain is moderately expensive. + # Suspect we can move focus without calculating the entire thing again. + widgets: list[Widget] = [] add_widget = widgets.append stack: list[Iterator[Widget]] = [iter(self.focusable_children)] @@ -283,6 +286,8 @@ class Screen(Generic[ScreenResultType], Widget): is not `None`, then it is guaranteed that the widget returned matches the CSS selectors given in the argument. """ + # TODO: This shouldn't be required + self._compositor._full_map_invalidated = True if not isinstance(selector, str): selector = selector.__name__ selector_set = parse_selectors(selector) @@ -469,11 +474,16 @@ class Screen(Generic[ScreenResultType], Widget): self.focused = widget # Send focus event if scroll_visible: - self.screen.scroll_to_widget(widget) + + def scroll_to_center(widget: Widget) -> None: + """Scroll to center (after a refresh).""" + if widget.has_focus and not self.screen.can_view(widget): + self.screen.scroll_to_center(widget) + + self.call_after_refresh(scroll_to_center, widget) widget.post_message(events.Focus()) focused = widget - self._update_focus_styles(self.focused, widget) self.log.debug(widget, "was focused") self._update_focus_styles(focused, blurred) diff --git a/src/textual/widget.py b/src/textual/widget.py index 6d14cad74..f38993b2a 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2267,13 +2267,12 @@ class Widget(DOMNode): while isinstance(widget.parent, Widget) and widget is not self: container = widget.parent - if widget.styles.dock: scroll_offset = Offset(0, 0) else: scroll_offset = container.scroll_to_region( region, - spacing=widget.parent.gutter + widget.dock_gutter, + spacing=widget.gutter + widget.dock_gutter, animate=animate, speed=speed, duration=duration, @@ -2286,15 +2285,17 @@ class Widget(DOMNode): # Adjust the region by the amount we just scrolled it, and convert to # it's parent's virtual coordinate system. + region = ( ( region.translate(-scroll_offset) .translate(-widget.scroll_offset) - .translate(container.virtual_region.offset) + .translate(container.virtual_region_with_margin.offset) ) .grow(container.styles.margin) - .intersection(container.virtual_region) + .intersection(container.virtual_region_with_margin) ) + widget = container return scrolled @@ -2483,6 +2484,30 @@ class Widget(DOMNode): force=force, ) + def can_view(self, widget: Widget) -> bool: + """Check if a given widget is in the current view (scrollable area). + + Note: This doesn't necessarily equate to a widget being visible. + There are other reasons why a widget may not be visible. + + Args: + widget: A widget that is a descendant of self. + + Returns: + True if the entire widget is in view, False if it is partially visible or not in view. + """ + if widget is self: + return True + + region = widget.region + node: Widget = widget + + while isinstance(node.parent, Widget) and node is not self: + if region not in node.parent.scrollable_content_region: + return False + node = node.parent + return True + def __init_subclass__( cls, can_focus: bool | None = None, diff --git a/src/textual/widgets/_select.py b/src/textual/widgets/_select.py index dddd9e157..16cbb6ec4 100644 --- a/src/textual/widgets/_select.py +++ b/src/textual/widgets/_select.py @@ -9,6 +9,7 @@ from rich.text import Text from .. import events, on from ..app import ComposeResult from ..containers import Horizontal, Vertical +from ..css.query import NoMatches from ..message import Message from ..reactive import var from ..widgets import Static @@ -298,17 +299,22 @@ class Select(Generic[SelectType], Vertical, can_focus=True): def _watch_value(self, value: SelectType | None) -> None: """Update the current value when it changes.""" self._value = value - if value is None: - self.query_one(SelectCurrent).update(None) + try: + select_current = self.query_one(SelectCurrent) + except NoMatches: + pass else: - for index, (prompt, _value) in enumerate(self._options): - if _value == value: - select_overlay = self.query_one(SelectOverlay) - select_overlay.highlighted = index - self.query_one(SelectCurrent).update(prompt) - break - else: + if value is None: self.query_one(SelectCurrent).update(None) + else: + for index, (prompt, _value) in enumerate(self._options): + if _value == value: + select_overlay = self.query_one(SelectOverlay) + select_overlay.highlighted = index + self.query_one(SelectCurrent).update(prompt) + break + else: + self.query_one(SelectCurrent).update(None) def compose(self) -> ComposeResult: """Compose Select with overlay and current value."""