mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into multiselect
This commit is contained in:
16
CHANGELOG.md
16
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
|
||||
|
||||
@@ -44,8 +44,6 @@ class TweetScreen(Screen):
|
||||
|
||||
|
||||
class LayoutApp(App):
|
||||
CSS_PATH = "layout.css"
|
||||
|
||||
def on_ready(self) -> None:
|
||||
self.push_screen(TweetScreen())
|
||||
|
||||
|
||||
@@ -58,8 +58,6 @@ class TweetScreen(Screen):
|
||||
|
||||
|
||||
class LayoutApp(App):
|
||||
CSS_PATH = "layout.css"
|
||||
|
||||
def on_ready(self) -> None:
|
||||
self.push_screen(TweetScreen())
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
<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
|
||||
|
||||
|
||||
@@ -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 <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:
|
||||
|
||||
@@ -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>"
|
||||
worker = cast(
|
||||
"Worker[ReturnType]",
|
||||
self.run_worker(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user