mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into placeholder-cycle
This commit is contained in:
16
CHANGELOG.md
16
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
|
||||
|
||||
@@ -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>"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user