Merge branch 'main' into tree-lines-fix

This commit is contained in:
Dave Pearson
2023-05-04 09:03:59 +01:00
17 changed files with 612 additions and 133 deletions

View File

@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
## Unreleased
### Changed
- The DataTable cursor is now scrolled into view when the cursor coordinate is changed programmatically https://github.com/Textualize/textual/issues/2459
## [0.23.0] - 2023-05-03
### Fixed
- Fixed `outline` top and bottom not handling alpha - https://github.com/Textualize/textual/issues/2371
@@ -21,11 +27,17 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Changed
- Setting attributes with a `compute_` method will now raise an `AttributeError` https://github.com/Textualize/textual/issues/2383
- Unknown psuedo-selectors will now raise a tokenizer error (previously they were silently ignored) https://github.com/Textualize/textual/pull/2445
- Breaking change: `DirectoryTree.FileSelected.path` is now always a `Path` https://github.com/Textualize/textual/issues/2448
- Breaking change: `Directorytree.load_directory` renamed to `Directorytree._load_directory` https://github.com/Textualize/textual/issues/2448
- Unknown pseudo-selectors will now raise a tokenizer error (previously they were silently ignored) https://github.com/Textualize/textual/pull/2445
### Added
- Watch methods can now optionally be private https://github.com/Textualize/textual/issues/2382
- Added `DirectoryTree.path` reactive attribute https://github.com/Textualize/textual/issues/2448
- Added `DirectoryTree.FileSelected.node` https://github.com/Textualize/textual/pull/2463
- Added `DirectoryTree.reload` https://github.com/Textualize/textual/issues/2448
- Added textual.on decorator https://github.com/Textualize/textual/issues/2398
## [0.22.3] - 2023-04-29
@@ -867,6 +879,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.23.0]: https://github.com/Textualize/textual/compare/v0.22.3...v0.23.0
[0.22.3]: https://github.com/Textualize/textual/compare/v0.22.2...v0.22.3
[0.22.2]: https://github.com/Textualize/textual/compare/v0.22.1...v0.22.2
[0.22.1]: https://github.com/Textualize/textual/compare/v0.22.0...v0.22.1

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 128 KiB

View File

@@ -0,0 +1,88 @@
---
draft: false
date: 2023-05-03
categories:
- Release
title: "Textual 0.23.0 improves message handling"
authors:
- willmcgugan
---
# Textual 0.23.0 improves message handling
It's been a busy couple of weeks at Textualize.
We've been building apps with [Textual](https://github.com/Textualize/textual), as part of our *dog-fooding* week.
The first app, [Frogmouth](https://github.com/Textualize/frogmouth), was released at the weekend and already has 1K GitHub stars!
Expect two more such apps this month.
<!-- more -->
<div>
--8<-- "docs/blog/images/frogmouth.svg"
</div>
!!! tip
Join our [mailing list](http://eepurl.com/hL0BF1) if you would like to be the first to hear about our apps.
We haven't stopped developing Textual in that time.
Today we released version 0.23.0 which has a really interesting API update I'd like to introduce.
Textual *widgets* can send messages to each other.
To respond to those messages, you implement a message handler with a naming convention.
For instance, the [Button](/widget_gallery/#button) widget sends a `Pressed` event.
To handle that event, you implement a method called `on_button_pressed`.
Simple enough, but handler methods are called to handle pressed events from *all* Buttons.
To manage multiple buttons you typically had to write a large `if` statement to wire up each button to the code it should run.
It didn't take many Buttons before the handler became hard to follow.
## On decorator
Version 0.23.0 introduces the [`@on`](/guide/events/#on-decorator) decorator which allows you to dispatch events based on the widget that initiated them.
This is probably best explained in code.
The following two listings respond to buttons being pressed.
The first uses a single message handler, the second uses the decorator approach:
=== "on_decorator01.py"
```python title="on_decorator01.py"
--8<-- "docs/examples/events/on_decorator01.py"
```
1. The message handler is called when any button is pressed
=== "on_decorator02.py"
```python title="on_decorator02.py"
--8<-- "docs/examples/events/on_decorator02.py"
```
1. Matches the button with an id of "bell" (note the `#` to match the id)
2. Matches the button with class names "toggle" *and* "dark"
3. Matches the button with an id of "quit"
=== "Output"
```{.textual path="docs/examples/events/on_decorator01.py"}
```
The decorator dispatches events based on a CSS selector.
This means that you could have a handler per button, or a handler for buttons with a shared class, or parent.
We think this is a very flexible mechanism that will help keep code readable and maintainable.
## Why didn't we do this earlier?
It's a reasonable question to ask: why didn't we implement this in an earlier version?
We were certainly aware there was a deficiency in the API.
The truth is simply that we didn't have an elegant solution in mind until recently.
The `@on` decorator is, I believe, an elegant and powerful mechanism for dispatching handlers.
It might seem obvious in hindsight, but it took many iterations and brainstorming in the office to come up with it!
## Join us
If you want to talk about this update or anything else Textual related, join us on our [Discord server](https://discord.gg/Enf6Z3qhVr).

View File

@@ -12,7 +12,7 @@ class OnDecoratorApp(App):
yield Button("Toggle dark", classes="toggle dark")
yield Button("Quit", id="quit")
def on_button_pressed(self, event: Button.Pressed) -> None:
def on_button_pressed(self, event: Button.Pressed) -> None: # (1)!
"""Handle all button pressed events."""
if event.button.id == "bell":
self.bell()

View File

@@ -181,6 +181,8 @@ In the following example we have three buttons, each of which does something dif
--8<-- "docs/examples/events/on_decorator01.py"
```
1. The message handler is called when any button is pressed
=== "Output"
```{.textual path="docs/examples/events/on_decorator01.py"}

View File

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

View File

@@ -1253,6 +1253,7 @@ class App(Generic[ReturnType], DOMNode):
will be added, and this method is called to apply the corresponding
:hover styles.
"""
descendants = node.walk_children(with_self=True)
self.stylesheet.update_nodes(descendants, animate=True)

View File

@@ -1,5 +1,5 @@
from textual.app import App, ComposeResult
from textual.containers import VerticalScroll
from textual.containers import Vertical
from textual.css.constants import VALID_BORDER
from textual.widgets import Button, Label
@@ -12,7 +12,7 @@ And when it has gone past, I will turn the inner eye to see its path.
Where the fear has gone there will be nothing. Only I will remain."""
class BorderButtons(VerticalScroll):
class BorderButtons(Vertical):
DEFAULT_CSS = """
BorderButtons {
dock: left;

View File

@@ -1,5 +1,5 @@
from textual.app import App, ComposeResult
from textual.containers import Horizontal, VerticalScroll
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.design import ColorSystem
from textual.widget import Widget
from textual.widgets import Button, Footer, Label, Static
@@ -20,11 +20,11 @@ class ColorItem(Horizontal):
pass
class ColorGroup(VerticalScroll):
class ColorGroup(Vertical):
pass
class Content(VerticalScroll):
class Content(Vertical):
pass

View File

@@ -5,7 +5,7 @@ from rich.console import RenderableType
from textual._easing import EASING
from textual.app import App, ComposeResult
from textual.cli.previews.borders import TEXT
from textual.containers import Container, Horizontal, VerticalScroll
from textual.containers import Horizontal, Vertical
from textual.reactive import reactive, var
from textual.scrollbar import ScrollBarRender
from textual.widget import Widget
@@ -72,13 +72,13 @@ class EasingApp(App):
)
yield EasingButtons()
with VerticalScroll():
with Vertical():
with Horizontal(id="inputs"):
yield Label("Animation Duration:", id="label")
yield duration_input
with Horizontal():
yield self.animated_bar
yield VerticalScroll(self.opacity_widget, id="other")
yield Vertical(self.opacity_widget, id="other")
yield Footer()
def on_button_pressed(self, event: Button.Pressed) -> None:

View File

@@ -31,6 +31,7 @@ from .binding import Binding
from .css.match import match
from .css.parse import parse_selectors
from .css.query import QueryType
from .dom import DOMNode
from .geometry import Offset, Region, Size
from .reactive import Reactive
from .renderables.background_screen import BackgroundScreen
@@ -404,6 +405,32 @@ class Screen(Generic[ScreenResultType], Widget):
# Go with the what was found.
self.set_focus(chosen)
def _update_focus_styles(
self, focused: Widget | None = None, blurred: Widget | None = None
) -> None:
"""Update CSS for focus changes.
Args:
focused: The widget that was focused.
blurred: The widget that was blurred.
"""
widgets: set[DOMNode] = set()
if focused is not None:
for widget in reversed(focused.ancestors_with_self):
if widget._has_focus_within:
widgets.update(widget.walk_children(with_self=True))
break
if blurred is not None:
for widget in reversed(blurred.ancestors_with_self):
if widget._has_focus_within:
widgets.update(widget.walk_children(with_self=True))
break
if widgets:
self.app.stylesheet.update_nodes(
[widget for widget in widgets if widget._has_focus_within], animate=True
)
def set_focus(self, widget: Widget | None, scroll_visible: bool = True) -> None:
"""Focus (or un-focus) a widget. A focused widget will receive key events first.
@@ -415,25 +442,35 @@ class Screen(Generic[ScreenResultType], Widget):
# Widget is already focused
return
focused: Widget | None = None
blurred: Widget | None = None
if widget is None:
# No focus, so blur currently focused widget if it exists
if self.focused is not None:
self.focused.post_message(events.Blur())
self.focused = None
blurred = self.focused
self.log.debug("focus was removed")
elif widget.focusable:
if self.focused != widget:
if self.focused is not None:
# Blur currently focused widget
self.focused.post_message(events.Blur())
blurred = self.focused
# Change focus
self.focused = widget
# Send focus event
if scroll_visible:
self.screen.scroll_to_widget(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)
async def _on_idle(self, event: events.Idle) -> None:
# Check for any widgets marked as 'dirty' (needs a repaint)
event.prevent_default()

View File

@@ -3077,21 +3077,11 @@ class Widget(DOMNode):
def _on_focus(self, event: events.Focus) -> None:
self.has_focus = True
self.refresh()
for widget in reversed(self.ancestors_with_self):
if widget._has_focus_within:
widget._update_styles()
break
self.post_message(events.DescendantFocus())
def _on_blur(self, event: events.Blur) -> None:
self.has_focus = False
self.refresh()
for widget in reversed(self.ancestors_with_self):
if widget._has_focus_within:
widget._update_styles()
break
self.post_message(events.DescendantBlur())
def _on_mouse_scroll_down(self, event: events.MouseScrollDown) -> None:

View File

@@ -950,6 +950,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
elif self.cursor_type == "column":
self.refresh_column(old_coordinate.column)
self._highlight_column(new_coordinate.column)
self._scroll_cursor_into_view()
def _highlight_coordinate(self, coordinate: Coordinate) -> None:
"""Apply highlighting to the cell at the coordinate, and post event."""

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from typing import ClassVar, Iterable
@@ -10,6 +9,7 @@ from rich.text import Text, TextType
from ..events import Mount
from ..message import Message
from ..reactive import var
from ._tree import TOGGLE_STYLE, Tree, TreeNode
@@ -17,26 +17,19 @@ from ._tree import TOGGLE_STYLE, Tree, TreeNode
class DirEntry:
"""Attaches directory information to a node."""
path: str
is_dir: bool
path: Path
"""The path of the directory entry."""
loaded: bool = False
"""Has this been loaded?"""
class DirectoryTree(Tree[DirEntry]):
"""A Tree widget that presents files and directories.
Args:
path: Path to directory.
name: The name of the widget, or None for no name.
id: The ID of the widget in the DOM, or None for no ID.
classes: A space-separated list of classes, or None for no classes.
disabled: Whether the directory tree is disabled or not.
"""
"""A Tree widget that presents files and directories."""
COMPONENT_CLASSES: ClassVar[set[str]] = {
"directory-tree--folder",
"directory-tree--file",
"directory-tree--extension",
"directory-tree--file",
"directory-tree--folder",
"directory-tree--hidden",
}
"""
@@ -55,10 +48,6 @@ class DirectoryTree(Tree[DirEntry]):
text-style: bold;
}
DirectoryTree > .directory-tree--file {
}
DirectoryTree > .directory-tree--extension {
text-style: italic;
}
@@ -73,14 +62,28 @@ class DirectoryTree(Tree[DirEntry]):
Can be handled using `on_directory_tree_file_selected` in a subclass of
`DirectoryTree` or in a parent widget in the DOM.
Attributes:
path: The path of the file that was selected.
"""
def __init__(self, path: str) -> None:
self.path: str = path
def __init__(self, node: TreeNode[DirEntry], path: Path) -> None:
"""Initialise the FileSelected object.
Args:
node: The tree node for the file that was selected.
path: The path of the file that was selected.
"""
super().__init__()
self.node: TreeNode[DirEntry] = node
"""The tree node of the file that was selected."""
self.path: Path = path
"""The path of the file that was selected."""
path: var[str | Path] = var["str | Path"](Path("."), init=False)
"""The path that is the root of the directory tree.
Note:
This can be set to either a `str` or a `pathlib.Path` object, but
the value will always be a `pathlib.Path` object.
"""
def __init__(
self,
@@ -91,18 +94,54 @@ class DirectoryTree(Tree[DirEntry]):
classes: str | None = None,
disabled: bool = False,
) -> None:
str_path = os.fspath(path)
self.path = str_path
"""Initialise the directory tree.
Args:
path: Path to directory.
name: The name of the widget, or None for no name.
id: The ID of the widget in the DOM, or None for no ID.
classes: A space-separated list of classes, or None for no classes.
disabled: Whether the directory tree is disabled or not.
"""
self.path = path
super().__init__(
str_path,
data=DirEntry(str_path, True),
str(path),
data=DirEntry(Path(path)),
name=name,
id=id,
classes=classes,
disabled=disabled,
)
def process_label(self, label: TextType):
def reload(self) -> None:
"""Reload the `DirectoryTree` contents."""
self.reset(str(self.path), DirEntry(Path(self.path)))
self._load_directory(self.root)
def validate_path(self, path: str | Path) -> Path:
"""Ensure that the path is of the `Path` type.
Args:
path: The path to validate.
Returns:
The validated Path value.
Note:
The result will always be a Python `Path` object, regardless of
the value given.
"""
return Path(path)
def watch_path(self) -> None:
"""Watch for changes to the `path` of the directory tree.
If the path is changed the directory tree will be repopulated using
the new value as the root.
"""
self.reload()
def process_label(self, label: TextType) -> Text:
"""Process a str or Text into a label. Maybe overridden in a subclass to modify how labels are rendered.
Args:
@@ -118,7 +157,19 @@ class DirectoryTree(Tree[DirEntry]):
first_line = text_label.split()[0]
return first_line
def render_label(self, node: TreeNode[DirEntry], base_style: Style, style: Style):
def render_label(
self, node: TreeNode[DirEntry], base_style: Style, style: Style
) -> Text:
"""Render a label for the given node.
Args:
node: A tree node.
base_style: The base style of the widget.
style: The additional style for the label.
Returns:
A Rich Text object containing the label.
"""
node_label = node._label.copy()
node_label.stylize(style)
@@ -165,40 +216,44 @@ class DirectoryTree(Tree[DirEntry]):
"""
return paths
def load_directory(self, node: TreeNode[DirEntry]) -> None:
def _load_directory(self, node: TreeNode[DirEntry]) -> None:
"""Load the directory contents for a given node.
Args:
node: The node to load the directory contents for.
"""
assert node.data is not None
dir_path = Path(node.data.path)
node.data.loaded = True
directory = sorted(
self.filter_paths(dir_path.iterdir()),
self.filter_paths(node.data.path.iterdir()),
key=lambda path: (not path.is_dir(), path.name.lower()),
)
for path in directory:
node.add(
path.name,
data=DirEntry(str(path), path.is_dir()),
data=DirEntry(path),
allow_expand=path.is_dir(),
)
node.expand()
def _on_mount(self, _: Mount) -> None:
self.load_directory(self.root)
self._load_directory(self.root)
def _on_tree_node_expanded(self, event: Tree.NodeSelected) -> None:
def _on_tree_node_expanded(self, event: Tree.NodeExpanded) -> None:
event.stop()
dir_entry = event.node.data
if dir_entry is None:
return
if dir_entry.is_dir:
if dir_entry.path.is_dir():
if not dir_entry.loaded:
self.load_directory(event.node)
self._load_directory(event.node)
else:
self.post_message(self.FileSelected(dir_entry.path))
self.post_message(self.FileSelected(event.node, dir_entry.path))
def _on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
event.stop()
dir_entry = event.node.data
if dir_entry is None:
return
if not dir_entry.is_dir:
self.post_message(self.FileSelected(dir_entry.path))
if not dir_entry.path.is_dir():
self.post_message(self.FileSelected(event.node, dir_entry.path))

File diff suppressed because one or more lines are too long

View File

@@ -294,7 +294,7 @@ def test_programmatic_scrollbar_gutter_change(snap_compare):
def test_borders_preview(snap_compare):
assert snap_compare(CLI_PREVIEWS_DIR / "borders.py", press=["tab", "tab", "enter"])
assert snap_compare(CLI_PREVIEWS_DIR / "borders.py", press=["tab", "enter"])
def test_colors_preview(snap_compare):

View File

@@ -991,3 +991,28 @@ def test_key_string_lookup():
assert dictionary[RowKey("foo")] == "bar"
assert dictionary["hello"] == "world"
assert dictionary[RowKey("hello")] == "world"
async def test_scrolling_cursor_into_view():
"""Regression test for https://github.com/Textualize/textual/issues/2459"""
class TableApp(App):
CSS = "DataTable { height: 100%; }"
def compose(self):
yield DataTable()
def on_mount(self) -> None:
table = self.query_one(DataTable)
table.add_column("n")
table.add_rows([(n,) for n in range(300)])
def key_c(self):
self.query_one(DataTable).cursor_coordinate = Coordinate(200, 0)
app = TableApp()
async with app.run_test() as pilot:
await pilot.press("c")
await pilot.pause()
assert app.query_one(DataTable).scroll_y > 100