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/)
|
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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>"]
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user