Merge branch 'main' into multiselect

This commit is contained in:
Dave Pearson
2023-05-22 09:49:39 +01:00
13 changed files with 95 additions and 37 deletions

View File

@@ -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/) The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/). 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 ### 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 - 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 - 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 - 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 ### 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 - `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 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 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 ### 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 - New handler system for messages that doesn't require inheritance
- Improved traceback handling - 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.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.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 [0.23.0]: https://github.com/Textualize/textual/compare/v0.22.3...v0.23.0

View File

@@ -44,8 +44,6 @@ class TweetScreen(Screen):
class LayoutApp(App): class LayoutApp(App):
CSS_PATH = "layout.css"
def on_ready(self) -> None: def on_ready(self) -> None:
self.push_screen(TweetScreen()) self.push_screen(TweetScreen())

View File

@@ -58,8 +58,6 @@ class TweetScreen(Screen):
class LayoutApp(App): class LayoutApp(App):
CSS_PATH = "layout.css"
def on_ready(self) -> None: def on_ready(self) -> None:
self.push_screen(TweetScreen()) self.push_screen(TweetScreen())

View File

@@ -162,6 +162,7 @@ Let's set the width of the columns to 32.
We also want to reduce the height of each "tweet". 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. 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" === "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"} ```{.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. === "Sketch"
You can also scroll the "tweets" in each column vertically.
<div class="excalidraw">
--8<-- "docs/images/how-to/layout.excalidraw.svg"
</div>
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 ## Summary

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "textual" name = "textual"
version = "0.24.1" version = "0.26.0"
homepage = "https://github.com/Textualize/textual" homepage = "https://github.com/Textualize/textual"
description = "Modern Text User Interface framework" description = "Modern Text User Interface framework"
authors = ["Will McGugan <will@textualize.io>"] authors = ["Will McGugan <will@textualize.io>"]

View File

@@ -806,7 +806,7 @@ class Compositor:
if self.root is None: if self.root is None:
raise errors.NoWidget("Widget is not in layout") raise errors.NoWidget("Widget is not in layout")
try: try:
if self._full_map is not None: if not self._full_map_invalidated:
try: try:
return self._full_map[widget] return self._full_map[widget]
except KeyError: except KeyError:

View File

@@ -87,14 +87,15 @@ def work(
self = args[0] self = args[0]
assert isinstance(self, DOMNode) assert isinstance(self, DOMNode)
positional_arguments = ", ".join(repr(arg) for arg in args[1:]) try:
keyword_arguments = ", ".join( positional_arguments = ", ".join(repr(arg) for arg in args[1:])
f"{name}={value!r}" for name, value in kwargs.items() keyword_arguments = ", ".join(
) f"{name}={value!r}" for name, value in kwargs.items()
tokens = [positional_arguments, keyword_arguments] )
worker_description = ( tokens = [positional_arguments, keyword_arguments]
f"{method.__name__}({', '.join(token for token in tokens if token)})" worker_description = f"{method.__name__}({', '.join(token for token in tokens if token)})"
) except Exception:
worker_description = "<worker>"
worker = cast( worker = cast(
"Worker[ReturnType]", "Worker[ReturnType]",
self.run_worker( self.run_worker(

View File

@@ -246,6 +246,9 @@ class Screen(Generic[ScreenResultType], Widget):
@property @property
def focus_chain(self) -> list[Widget]: def focus_chain(self) -> list[Widget]:
"""A list of widgets that may receive focus, in focus order.""" """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] = [] widgets: list[Widget] = []
add_widget = widgets.append add_widget = widgets.append
stack: list[Iterator[Widget]] = [iter(self.focusable_children)] 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 is not `None`, then it is guaranteed that the widget returned matches
the CSS selectors given in the argument. the CSS selectors given in the argument.
""" """
# TODO: This shouldn't be required
self._compositor._full_map_invalidated = True
if not isinstance(selector, str): if not isinstance(selector, str):
selector = selector.__name__ selector = selector.__name__
selector_set = parse_selectors(selector) selector_set = parse_selectors(selector)
@@ -469,11 +474,16 @@ class Screen(Generic[ScreenResultType], Widget):
self.focused = widget self.focused = widget
# Send focus event # Send focus event
if scroll_visible: 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()) widget.post_message(events.Focus())
focused = widget focused = widget
self._update_focus_styles(self.focused, widget)
self.log.debug(widget, "was focused") self.log.debug(widget, "was focused")
self._update_focus_styles(focused, blurred) self._update_focus_styles(focused, blurred)

View File

@@ -2267,13 +2267,12 @@ class Widget(DOMNode):
while isinstance(widget.parent, Widget) and widget is not self: while isinstance(widget.parent, Widget) and widget is not self:
container = widget.parent container = widget.parent
if widget.styles.dock: if widget.styles.dock:
scroll_offset = Offset(0, 0) scroll_offset = Offset(0, 0)
else: else:
scroll_offset = container.scroll_to_region( scroll_offset = container.scroll_to_region(
region, region,
spacing=widget.parent.gutter + widget.dock_gutter, spacing=widget.gutter + widget.dock_gutter,
animate=animate, animate=animate,
speed=speed, speed=speed,
duration=duration, duration=duration,
@@ -2286,15 +2285,17 @@ class Widget(DOMNode):
# Adjust the region by the amount we just scrolled it, and convert to # Adjust the region by the amount we just scrolled it, and convert to
# it's parent's virtual coordinate system. # it's parent's virtual coordinate system.
region = ( region = (
( (
region.translate(-scroll_offset) region.translate(-scroll_offset)
.translate(-widget.scroll_offset) .translate(-widget.scroll_offset)
.translate(container.virtual_region.offset) .translate(container.virtual_region_with_margin.offset)
) )
.grow(container.styles.margin) .grow(container.styles.margin)
.intersection(container.virtual_region) .intersection(container.virtual_region_with_margin)
) )
widget = container widget = container
return scrolled return scrolled
@@ -2483,6 +2484,30 @@ class Widget(DOMNode):
force=force, 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__( def __init_subclass__(
cls, cls,
can_focus: bool | None = None, can_focus: bool | None = None,

View File

@@ -292,7 +292,7 @@ class DirectoryTree(Tree[DirEntry]):
worker: The worker that the loading is taking place in. worker: The worker that the loading is taking place in.
Yields: Yields:
Path: A entry within the location. Path: An entry within the location.
""" """
try: try:
for entry in location.iterdir(): for entry in location.iterdir():

View File

@@ -9,6 +9,7 @@ from rich.text import Text
from .. import events, on from .. import events, on
from ..app import ComposeResult from ..app import ComposeResult
from ..containers import Horizontal, Vertical from ..containers import Horizontal, Vertical
from ..css.query import NoMatches
from ..message import Message from ..message import Message
from ..reactive import var from ..reactive import var
from ..widgets import Static from ..widgets import Static
@@ -298,17 +299,22 @@ class Select(Generic[SelectType], Vertical, can_focus=True):
def _watch_value(self, value: SelectType | None) -> None: def _watch_value(self, value: SelectType | None) -> None:
"""Update the current value when it changes.""" """Update the current value when it changes."""
self._value = value self._value = value
if value is None: try:
self.query_one(SelectCurrent).update(None) select_current = self.query_one(SelectCurrent)
except NoMatches:
pass
else: else:
for index, (prompt, _value) in enumerate(self._options): if value is None:
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) 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: def compose(self) -> ComposeResult:
"""Compose Select with overlay and current value.""" """Compose Select with overlay and current value."""

View File

@@ -117,15 +117,17 @@ class Tab(Static):
label: TextType, label: TextType,
*, *,
id: str | None = None, id: str | None = None,
classes: str | None = None,
) -> None: ) -> None:
"""Initialise a Tab. """Initialise a Tab.
Args: Args:
label: The label to use in the tab. label: The label to use in the tab.
id: Optional ID for the widget. 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 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) self.update(label)
@property @property