Merge branch 'main' into placeholder-cycle

This commit is contained in:
Rodrigo Girão Serrão
2023-05-22 10:43:12 +01:00
committed by GitHub
8 changed files with 95 additions and 29 deletions

View File

@@ -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

View File

@@ -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 <will@textualize.io>"]

View File

@@ -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:

View File

@@ -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 = "<worker>"
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>"
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,
),

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,

View File

@@ -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."""