mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into tree-lines-fix
This commit is contained in:
13
CHANGELOG.md
13
CHANGELOG.md
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
|
|
||||||
## Unreleased
|
## 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
|
||||||
|
|
||||||
- Fixed `outline` top and bottom not handling alpha - https://github.com/Textualize/textual/issues/2371
|
- 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
|
### Changed
|
||||||
|
|
||||||
- Setting attributes with a `compute_` method will now raise an `AttributeError` https://github.com/Textualize/textual/issues/2383
|
- 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
|
- Unknown pseudo-selectors will now raise a tokenizer error (previously they were silently ignored) https://github.com/Textualize/textual/pull/2445
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Watch methods can now optionally be private https://github.com/Textualize/textual/issues/2382
|
- 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
|
- Added textual.on decorator https://github.com/Textualize/textual/issues/2398
|
||||||
|
|
||||||
## [0.22.3] - 2023-04-29
|
## [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
|
- New handler system for messages that doesn't require inheritance
|
||||||
- Improved traceback handling
|
- 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.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.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
|
[0.22.1]: https://github.com/Textualize/textual/compare/v0.22.0...v0.22.1
|
||||||
|
|||||||
268
docs/blog/images/frogmouth.svg
Normal file
268
docs/blog/images/frogmouth.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 128 KiB |
88
docs/blog/posts/release0-23-0.md
Normal file
88
docs/blog/posts/release0-23-0.md
Normal 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).
|
||||||
@@ -12,7 +12,7 @@ class OnDecoratorApp(App):
|
|||||||
yield Button("Toggle dark", classes="toggle dark")
|
yield Button("Toggle dark", classes="toggle dark")
|
||||||
yield Button("Quit", id="quit")
|
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."""
|
"""Handle all button pressed events."""
|
||||||
if event.button.id == "bell":
|
if event.button.id == "bell":
|
||||||
self.bell()
|
self.bell()
|
||||||
|
|||||||
@@ -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"
|
--8<-- "docs/examples/events/on_decorator01.py"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
1. The message handler is called when any button is pressed
|
||||||
|
|
||||||
=== "Output"
|
=== "Output"
|
||||||
|
|
||||||
```{.textual path="docs/examples/events/on_decorator01.py"}
|
```{.textual path="docs/examples/events/on_decorator01.py"}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "textual"
|
name = "textual"
|
||||||
version = "0.22.3"
|
version = "0.23.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>"]
|
||||||
|
|||||||
@@ -1253,6 +1253,7 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
will be added, and this method is called to apply the corresponding
|
will be added, and this method is called to apply the corresponding
|
||||||
:hover styles.
|
:hover styles.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
descendants = node.walk_children(with_self=True)
|
descendants = node.walk_children(with_self=True)
|
||||||
self.stylesheet.update_nodes(descendants, animate=True)
|
self.stylesheet.update_nodes(descendants, animate=True)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from textual.app import App, ComposeResult
|
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.css.constants import VALID_BORDER
|
||||||
from textual.widgets import Button, Label
|
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."""
|
Where the fear has gone there will be nothing. Only I will remain."""
|
||||||
|
|
||||||
|
|
||||||
class BorderButtons(VerticalScroll):
|
class BorderButtons(Vertical):
|
||||||
DEFAULT_CSS = """
|
DEFAULT_CSS = """
|
||||||
BorderButtons {
|
BorderButtons {
|
||||||
dock: left;
|
dock: left;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from textual.app import App, ComposeResult
|
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.design import ColorSystem
|
||||||
from textual.widget import Widget
|
from textual.widget import Widget
|
||||||
from textual.widgets import Button, Footer, Label, Static
|
from textual.widgets import Button, Footer, Label, Static
|
||||||
@@ -20,11 +20,11 @@ class ColorItem(Horizontal):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ColorGroup(VerticalScroll):
|
class ColorGroup(Vertical):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class Content(VerticalScroll):
|
class Content(Vertical):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from rich.console import RenderableType
|
|||||||
from textual._easing import EASING
|
from textual._easing import EASING
|
||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
from textual.cli.previews.borders import TEXT
|
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.reactive import reactive, var
|
||||||
from textual.scrollbar import ScrollBarRender
|
from textual.scrollbar import ScrollBarRender
|
||||||
from textual.widget import Widget
|
from textual.widget import Widget
|
||||||
@@ -72,13 +72,13 @@ class EasingApp(App):
|
|||||||
)
|
)
|
||||||
|
|
||||||
yield EasingButtons()
|
yield EasingButtons()
|
||||||
with VerticalScroll():
|
with Vertical():
|
||||||
with Horizontal(id="inputs"):
|
with Horizontal(id="inputs"):
|
||||||
yield Label("Animation Duration:", id="label")
|
yield Label("Animation Duration:", id="label")
|
||||||
yield duration_input
|
yield duration_input
|
||||||
with Horizontal():
|
with Horizontal():
|
||||||
yield self.animated_bar
|
yield self.animated_bar
|
||||||
yield VerticalScroll(self.opacity_widget, id="other")
|
yield Vertical(self.opacity_widget, id="other")
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from .binding import Binding
|
|||||||
from .css.match import match
|
from .css.match import match
|
||||||
from .css.parse import parse_selectors
|
from .css.parse import parse_selectors
|
||||||
from .css.query import QueryType
|
from .css.query import QueryType
|
||||||
|
from .dom import DOMNode
|
||||||
from .geometry import Offset, Region, Size
|
from .geometry import Offset, Region, Size
|
||||||
from .reactive import Reactive
|
from .reactive import Reactive
|
||||||
from .renderables.background_screen import BackgroundScreen
|
from .renderables.background_screen import BackgroundScreen
|
||||||
@@ -404,6 +405,32 @@ class Screen(Generic[ScreenResultType], Widget):
|
|||||||
# Go with the what was found.
|
# Go with the what was found.
|
||||||
self.set_focus(chosen)
|
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:
|
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.
|
"""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
|
# Widget is already focused
|
||||||
return
|
return
|
||||||
|
|
||||||
|
focused: Widget | None = None
|
||||||
|
blurred: Widget | None = None
|
||||||
|
|
||||||
if widget is None:
|
if widget is None:
|
||||||
# No focus, so blur currently focused widget if it exists
|
# No focus, so blur currently focused widget if it exists
|
||||||
if self.focused is not None:
|
if self.focused is not None:
|
||||||
self.focused.post_message(events.Blur())
|
self.focused.post_message(events.Blur())
|
||||||
self.focused = None
|
self.focused = None
|
||||||
|
blurred = self.focused
|
||||||
self.log.debug("focus was removed")
|
self.log.debug("focus was removed")
|
||||||
elif widget.focusable:
|
elif widget.focusable:
|
||||||
if self.focused != widget:
|
if self.focused != widget:
|
||||||
if self.focused is not None:
|
if self.focused is not None:
|
||||||
# Blur currently focused widget
|
# Blur currently focused widget
|
||||||
self.focused.post_message(events.Blur())
|
self.focused.post_message(events.Blur())
|
||||||
|
blurred = self.focused
|
||||||
# Change focus
|
# Change focus
|
||||||
self.focused = widget
|
self.focused = widget
|
||||||
# Send focus event
|
# Send focus event
|
||||||
if scroll_visible:
|
if scroll_visible:
|
||||||
self.screen.scroll_to_widget(widget)
|
self.screen.scroll_to_widget(widget)
|
||||||
widget.post_message(events.Focus())
|
widget.post_message(events.Focus())
|
||||||
|
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)
|
||||||
|
|
||||||
async def _on_idle(self, event: events.Idle) -> None:
|
async def _on_idle(self, event: events.Idle) -> None:
|
||||||
# Check for any widgets marked as 'dirty' (needs a repaint)
|
# Check for any widgets marked as 'dirty' (needs a repaint)
|
||||||
event.prevent_default()
|
event.prevent_default()
|
||||||
|
|||||||
@@ -3077,21 +3077,11 @@ class Widget(DOMNode):
|
|||||||
def _on_focus(self, event: events.Focus) -> None:
|
def _on_focus(self, event: events.Focus) -> None:
|
||||||
self.has_focus = True
|
self.has_focus = True
|
||||||
self.refresh()
|
self.refresh()
|
||||||
for widget in reversed(self.ancestors_with_self):
|
|
||||||
if widget._has_focus_within:
|
|
||||||
widget._update_styles()
|
|
||||||
break
|
|
||||||
|
|
||||||
self.post_message(events.DescendantFocus())
|
self.post_message(events.DescendantFocus())
|
||||||
|
|
||||||
def _on_blur(self, event: events.Blur) -> None:
|
def _on_blur(self, event: events.Blur) -> None:
|
||||||
self.has_focus = False
|
self.has_focus = False
|
||||||
self.refresh()
|
self.refresh()
|
||||||
for widget in reversed(self.ancestors_with_self):
|
|
||||||
if widget._has_focus_within:
|
|
||||||
widget._update_styles()
|
|
||||||
break
|
|
||||||
|
|
||||||
self.post_message(events.DescendantBlur())
|
self.post_message(events.DescendantBlur())
|
||||||
|
|
||||||
def _on_mouse_scroll_down(self, event: events.MouseScrollDown) -> None:
|
def _on_mouse_scroll_down(self, event: events.MouseScrollDown) -> None:
|
||||||
|
|||||||
@@ -950,6 +950,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
|||||||
elif self.cursor_type == "column":
|
elif self.cursor_type == "column":
|
||||||
self.refresh_column(old_coordinate.column)
|
self.refresh_column(old_coordinate.column)
|
||||||
self._highlight_column(new_coordinate.column)
|
self._highlight_column(new_coordinate.column)
|
||||||
|
self._scroll_cursor_into_view()
|
||||||
|
|
||||||
def _highlight_coordinate(self, coordinate: Coordinate) -> None:
|
def _highlight_coordinate(self, coordinate: Coordinate) -> None:
|
||||||
"""Apply highlighting to the cell at the coordinate, and post event."""
|
"""Apply highlighting to the cell at the coordinate, and post event."""
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import ClassVar, Iterable
|
from typing import ClassVar, Iterable
|
||||||
@@ -10,6 +9,7 @@ from rich.text import Text, TextType
|
|||||||
|
|
||||||
from ..events import Mount
|
from ..events import Mount
|
||||||
from ..message import Message
|
from ..message import Message
|
||||||
|
from ..reactive import var
|
||||||
from ._tree import TOGGLE_STYLE, Tree, TreeNode
|
from ._tree import TOGGLE_STYLE, Tree, TreeNode
|
||||||
|
|
||||||
|
|
||||||
@@ -17,26 +17,19 @@ from ._tree import TOGGLE_STYLE, Tree, TreeNode
|
|||||||
class DirEntry:
|
class DirEntry:
|
||||||
"""Attaches directory information to a node."""
|
"""Attaches directory information to a node."""
|
||||||
|
|
||||||
path: str
|
path: Path
|
||||||
is_dir: bool
|
"""The path of the directory entry."""
|
||||||
loaded: bool = False
|
loaded: bool = False
|
||||||
|
"""Has this been loaded?"""
|
||||||
|
|
||||||
|
|
||||||
class DirectoryTree(Tree[DirEntry]):
|
class DirectoryTree(Tree[DirEntry]):
|
||||||
"""A Tree widget that presents files and directories.
|
"""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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
COMPONENT_CLASSES: ClassVar[set[str]] = {
|
COMPONENT_CLASSES: ClassVar[set[str]] = {
|
||||||
"directory-tree--folder",
|
|
||||||
"directory-tree--file",
|
|
||||||
"directory-tree--extension",
|
"directory-tree--extension",
|
||||||
|
"directory-tree--file",
|
||||||
|
"directory-tree--folder",
|
||||||
"directory-tree--hidden",
|
"directory-tree--hidden",
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
@@ -55,10 +48,6 @@ class DirectoryTree(Tree[DirEntry]):
|
|||||||
text-style: bold;
|
text-style: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
DirectoryTree > .directory-tree--file {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
DirectoryTree > .directory-tree--extension {
|
DirectoryTree > .directory-tree--extension {
|
||||||
text-style: italic;
|
text-style: italic;
|
||||||
}
|
}
|
||||||
@@ -73,14 +62,28 @@ class DirectoryTree(Tree[DirEntry]):
|
|||||||
|
|
||||||
Can be handled using `on_directory_tree_file_selected` in a subclass of
|
Can be handled using `on_directory_tree_file_selected` in a subclass of
|
||||||
`DirectoryTree` or in a parent widget in the DOM.
|
`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:
|
def __init__(self, node: TreeNode[DirEntry], path: Path) -> None:
|
||||||
self.path: str = path
|
"""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__()
|
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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -91,18 +94,54 @@ class DirectoryTree(Tree[DirEntry]):
|
|||||||
classes: str | None = None,
|
classes: str | None = None,
|
||||||
disabled: bool = False,
|
disabled: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
str_path = os.fspath(path)
|
"""Initialise the directory tree.
|
||||||
self.path = str_path
|
|
||||||
|
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__(
|
super().__init__(
|
||||||
str_path,
|
str(path),
|
||||||
data=DirEntry(str_path, True),
|
data=DirEntry(Path(path)),
|
||||||
name=name,
|
name=name,
|
||||||
id=id,
|
id=id,
|
||||||
classes=classes,
|
classes=classes,
|
||||||
disabled=disabled,
|
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.
|
"""Process a str or Text into a label. Maybe overridden in a subclass to modify how labels are rendered.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -118,7 +157,19 @@ class DirectoryTree(Tree[DirEntry]):
|
|||||||
first_line = text_label.split()[0]
|
first_line = text_label.split()[0]
|
||||||
return first_line
|
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 = node._label.copy()
|
||||||
node_label.stylize(style)
|
node_label.stylize(style)
|
||||||
|
|
||||||
@@ -165,40 +216,44 @@ class DirectoryTree(Tree[DirEntry]):
|
|||||||
"""
|
"""
|
||||||
return paths
|
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
|
assert node.data is not None
|
||||||
dir_path = Path(node.data.path)
|
|
||||||
node.data.loaded = True
|
node.data.loaded = True
|
||||||
directory = sorted(
|
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()),
|
key=lambda path: (not path.is_dir(), path.name.lower()),
|
||||||
)
|
)
|
||||||
for path in directory:
|
for path in directory:
|
||||||
node.add(
|
node.add(
|
||||||
path.name,
|
path.name,
|
||||||
data=DirEntry(str(path), path.is_dir()),
|
data=DirEntry(path),
|
||||||
allow_expand=path.is_dir(),
|
allow_expand=path.is_dir(),
|
||||||
)
|
)
|
||||||
node.expand()
|
node.expand()
|
||||||
|
|
||||||
def _on_mount(self, _: Mount) -> None:
|
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()
|
event.stop()
|
||||||
dir_entry = event.node.data
|
dir_entry = event.node.data
|
||||||
if dir_entry is None:
|
if dir_entry is None:
|
||||||
return
|
return
|
||||||
if dir_entry.is_dir:
|
if dir_entry.path.is_dir():
|
||||||
if not dir_entry.loaded:
|
if not dir_entry.loaded:
|
||||||
self.load_directory(event.node)
|
self._load_directory(event.node)
|
||||||
else:
|
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:
|
def _on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
|
||||||
event.stop()
|
event.stop()
|
||||||
dir_entry = event.node.data
|
dir_entry = event.node.data
|
||||||
if dir_entry is None:
|
if dir_entry is None:
|
||||||
return
|
return
|
||||||
if not dir_entry.is_dir:
|
if not dir_entry.path.is_dir():
|
||||||
self.post_message(self.FileSelected(dir_entry.path))
|
self.post_message(self.FileSelected(event.node, dir_entry.path))
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -294,7 +294,7 @@ def test_programmatic_scrollbar_gutter_change(snap_compare):
|
|||||||
|
|
||||||
|
|
||||||
def test_borders_preview(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):
|
def test_colors_preview(snap_compare):
|
||||||
|
|||||||
@@ -991,3 +991,28 @@ def test_key_string_lookup():
|
|||||||
assert dictionary[RowKey("foo")] == "bar"
|
assert dictionary[RowKey("foo")] == "bar"
|
||||||
assert dictionary["hello"] == "world"
|
assert dictionary["hello"] == "world"
|
||||||
assert dictionary[RowKey("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
|
||||||
|
|||||||
Reference in New Issue
Block a user