diff --git a/CHANGELOG.md b/CHANGELOG.md index a3c2cd5a3..0eb93b504 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [0.26.0] - 2023-05-20 -## Unrealeased +### 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 @@ -15,7 +24,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Only a single error will be written by default, unless in dev mode ("debug" in App.features) https://github.com/Textualize/textual/issues/2480 - Using `Widget.move_child` where the target and the child being moved are the same is now a no-op https://github.com/Textualize/textual/issues/1743 - Calling `dismiss` on a screen that is not at the top of the stack now raises an exception https://github.com/Textualize/textual/issues/2575 -- `MessagePump.call_after_refresh` and `MessagePump.call_later` will not return `False` if the callback could not be scheduled. https://github.com/Textualize/textual/pull/2584 +- `MessagePump.call_after_refresh` and `MessagePump.call_later` will now return `False` if the callback could not be scheduled. https://github.com/Textualize/textual/pull/2584 ### Fixed @@ -29,6 +38,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `Screen.AUTO_FOCUS` now works on the default screen on startup https://github.com/Textualize/textual/pull/2581 - Fix for setting dark in App `__init__` https://github.com/Textualize/textual/issues/2583 - Fix issue with scrolling and docks https://github.com/Textualize/textual/issues/2525 +- Fix not being able to use CSS classes with `Tab` https://github.com/Textualize/textual/pull/2589 ### Added @@ -964,6 +974,8 @@ 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 [0.23.0]: https://github.com/Textualize/textual/compare/v0.22.3...v0.23.0 diff --git a/docs/examples/how-to/layout.css b/docs/examples/how-to/layout.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/docs/examples/how-to/layout05.py b/docs/examples/how-to/layout05.py index 5b2cf6497..1fb6de3a3 100644 --- a/docs/examples/how-to/layout05.py +++ b/docs/examples/how-to/layout05.py @@ -44,8 +44,6 @@ class TweetScreen(Screen): class LayoutApp(App): - CSS_PATH = "layout.css" - def on_ready(self) -> None: self.push_screen(TweetScreen()) diff --git a/docs/examples/how-to/layout06.py b/docs/examples/how-to/layout06.py index 430670673..79be0ab9a 100644 --- a/docs/examples/how-to/layout06.py +++ b/docs/examples/how-to/layout06.py @@ -58,8 +58,6 @@ class TweetScreen(Screen): class LayoutApp(App): - CSS_PATH = "layout.css" - def on_ready(self) -> None: self.push_screen(TweetScreen()) diff --git a/docs/how-to/design-a-layout.md b/docs/how-to/design-a-layout.md index 3a8fe689b..5123e574a 100644 --- a/docs/how-to/design-a-layout.md +++ b/docs/how-to/design-a-layout.md @@ -162,6 +162,7 @@ Let's set the width of the columns to 32. We also want to reduce the height of each "tweet". In the real app, you might set the height to "auto" so it fits the content, but lets set it to 5 lines for now. +Here's the final example and a reminder of the sketch. === "layout06.py" @@ -174,11 +175,16 @@ In the real app, you might set the height to "auto" so it fits the content, but ```{.textual path="docs/examples/how-to/layout06.py" columns="100" lines="32"} ``` -You should see from the output that we have fixed width columns that will scroll horizontally. -You can also scroll the "tweets" in each column vertically. +=== "Sketch" + +
+ --8<-- "docs/images/how-to/layout.excalidraw.svg" +
+ + +A layout like this is a great starting point. +In a real app, you would start replacing each of the placeholders with [builtin](../widget_gallery.md) or [custom](../guide/widgets.md) widgets. -This last example is a relatively complete design. -There are plenty of things you might want to tweak, but this contains all the elements you might need. ## Summary diff --git a/pyproject.toml b/pyproject.toml index 56ada690a..7ee61b8bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.24.1" +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 2b47848b9..2688eccc4 100644 --- a/src/textual/_work_decorator.py +++ b/src/textual/_work_decorator.py @@ -87,14 +87,15 @@ def work( self = args[0] assert isinstance(self, DOMNode) - 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)})" - ) + 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 = "" worker = cast( "Worker[ReturnType]", self.run_worker( 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/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 72a19ddf1..46001882d 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -292,7 +292,7 @@ class DirectoryTree(Tree[DirEntry]): worker: The worker that the loading is taking place in. Yields: - Path: A entry within the location. + Path: An entry within the location. """ try: for entry in location.iterdir(): 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.""" diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index ffeb3fe5b..78c7a6869 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -117,15 +117,17 @@ class Tab(Static): label: TextType, *, id: str | None = None, + classes: str | None = None, ) -> None: """Initialise a Tab. Args: label: The label to use in the tab. id: Optional ID for the widget. + classes: Space separated list of class names. """ self.label = Text.from_markup(label) if isinstance(label, str) else label - super().__init__(id=id) + super().__init__(id=id, classes=classes) self.update(label) @property