mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into datatable-events
This commit is contained in:
@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
- Added a `inherit_component_classes` subclassing parameter to control whether component classes are inherited from base classes https://github.com/Textualize/textual/issues/1399
|
||||
- Added `diagnose` as a `textual` command https://github.com/Textualize/textual/issues/1542
|
||||
- Added `row` and `column` cursors to `DataTable` https://github.com/Textualize/textual/pull/1547
|
||||
- Added an optional parameter `selector` to the methods `Screen.focus_next` and `Screen.focus_previous` that enable using a CSS selector to narrow down which widgets can get focus https://github.com/Textualize/textual/issues/1196
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -24,11 +25,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
- Fail-fast and print pretty tracebacks for Widget compose errors https://github.com/Textualize/textual/pull/1505
|
||||
- Added Widget._refresh_scroll to avoid expensive layout when scrolling https://github.com/Textualize/textual/pull/1524
|
||||
- `events.Paste` now bubbles https://github.com/Textualize/textual/issues/1434
|
||||
- Improved error message when style flag `none` is mixed with other flags (e.g., when setting `text-style`) https://github.com/Textualize/textual/issues/1420
|
||||
- Clock color in the `Header` widget now matches the header color https://github.com/Textualize/textual/issues/1459
|
||||
- Programmatic calls to scroll now optionally scroll even if overflow styling says otherwise (introduces a new `force` parameter to all the `scroll_*` methods) https://github.com/Textualize/textual/issues/1201
|
||||
- `COMPONENT_CLASSES` are now inherited from base classes https://github.com/Textualize/textual/issues/1399
|
||||
- Watch methods may now take no parameters
|
||||
- Added `compute` parameter to reactive
|
||||
- A `TypeError` raised during `compose` now carries the full traceback.
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -8,13 +8,13 @@ The `<text-style>` CSS type represents styles that can be applied to text.
|
||||
|
||||
## Syntax
|
||||
|
||||
A [`<text-style>`](/css_types/text_style) can be any _space-separated_ combination of the following values:
|
||||
A [`<text-style>`](/css_types/text_style) can be the value `none` for plain text with no styling,
|
||||
or any _space-separated_ combination of the following values:
|
||||
|
||||
| Value | Description |
|
||||
|-------------|-----------------------------------------------------------------|
|
||||
| `bold` | **Bold text.** |
|
||||
| `italic` | _Italic text._ |
|
||||
| `none` | Plain text with no styling. |
|
||||
| `reverse` | Reverse video text (foreground and background colors reversed). |
|
||||
| `strike` | <s>Strikethrough text.</s> |
|
||||
| `underline` | <u>Underline text.</u> |
|
||||
@@ -42,5 +42,5 @@ A [`<text-style>`](/css_types/text_style) can be any _space-separated_ combinati
|
||||
widget.styles.text_style = "strike"
|
||||
|
||||
# You can also combine multiple values
|
||||
widget.styles.text_style = "bold underline italic"
|
||||
widget.styles.text_style = "strike bold italic reverse
|
||||
```
|
||||
|
||||
@@ -1569,7 +1569,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
except TypeError as error:
|
||||
raise TypeError(
|
||||
f"{self!r} compose() returned an invalid response; {error}"
|
||||
) from None
|
||||
) from error
|
||||
await self.mount_all(widgets)
|
||||
|
||||
def _on_idle(self) -> None:
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
from rich.panel import Panel
|
||||
|
||||
from rich.text import Text
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.reactive import var, Reactive
|
||||
from textual import events
|
||||
from textual.containers import Horizontal
|
||||
from textual.widgets import Button, Header, TextLog
|
||||
|
||||
|
||||
INSTRUCTIONS = """\
|
||||
Press some keys!
|
||||
[u]Press some keys![/]
|
||||
|
||||
Because we want to display all the keys, ctrl+C won't quit this example. Use the Quit button below to exit the app.\
|
||||
To quit the app press [b]ctrl+c[/b] [i]twice[/i] or press the Quit button below.\
|
||||
"""
|
||||
|
||||
|
||||
@@ -32,6 +35,8 @@ class KeysApp(App, inherit_bindings=False):
|
||||
}
|
||||
"""
|
||||
|
||||
last_key: Reactive[str | None] = var(None)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header()
|
||||
yield Horizontal(
|
||||
@@ -42,10 +47,17 @@ class KeysApp(App, inherit_bindings=False):
|
||||
yield KeyLog()
|
||||
|
||||
def on_ready(self) -> None:
|
||||
self.query_one(KeyLog).write(Panel(INSTRUCTIONS), expand=True)
|
||||
self.query_one(KeyLog).write(Panel(Text.from_markup(INSTRUCTIONS)), expand=True)
|
||||
|
||||
def on_key(self, event: events.Key) -> None:
|
||||
self.query_one(KeyLog).write(event)
|
||||
if event.key == "ctrl+c":
|
||||
if self.last_key == "ctrl+c":
|
||||
self.exit()
|
||||
else:
|
||||
self.query_one(KeyLog).write("Press Ctrl+C again to quit")
|
||||
|
||||
self.last_key = event.key
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
if event.button.id == "quit":
|
||||
|
||||
@@ -729,6 +729,7 @@ def style_flags_property_help_text(
|
||||
f"Style flag values such as [i]{property_name}[/] expect space-separated values"
|
||||
),
|
||||
Bullet(f"Permitted values are {friendly_list(VALID_STYLE_FLAGS)}"),
|
||||
Bullet("The value 'none' cannot be mixed with others"),
|
||||
*ContextSpecificBullets(
|
||||
inline=[
|
||||
Bullet(
|
||||
|
||||
@@ -12,6 +12,7 @@ from __future__ import annotations
|
||||
from operator import attrgetter
|
||||
from typing import TYPE_CHECKING, Generic, Iterable, NamedTuple, TypeVar, cast
|
||||
|
||||
import rich.errors
|
||||
import rich.repr
|
||||
from rich.style import Style
|
||||
|
||||
@@ -909,7 +910,17 @@ class StyleFlagsProperty:
|
||||
self.name, word, context="inline"
|
||||
),
|
||||
)
|
||||
style = Style.parse(style_flags)
|
||||
try:
|
||||
style = Style.parse(style_flags)
|
||||
except rich.errors.StyleSyntaxError as error:
|
||||
if "none" in words and len(words) > 1:
|
||||
raise StyleValueError(
|
||||
"cannot mix 'none' with other style flags",
|
||||
help_text=style_flags_property_help_text(
|
||||
self.name, " ".join(words), context="inline"
|
||||
),
|
||||
) from None
|
||||
raise error from None
|
||||
if obj.set_rule(self.name, style):
|
||||
obj.refresh()
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from rich.style import Style
|
||||
|
||||
from ._types import MessageTarget
|
||||
from .geometry import Offset, Size
|
||||
from .keys import _get_key_aliases
|
||||
from .keys import _get_key_aliases, _get_key_display
|
||||
from .message import Message
|
||||
|
||||
MouseEventT = TypeVar("MouseEventT", bound="MouseEvent")
|
||||
|
||||
@@ -228,6 +228,7 @@ KEY_DISPLAY_ALIASES = {
|
||||
"backspace": "⌫",
|
||||
"escape": "ESC",
|
||||
"enter": "⏎",
|
||||
"minus": "-",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ from rich.style import Style
|
||||
from . import errors, events, messages
|
||||
from ._callback import invoke
|
||||
from ._compositor import Compositor, MapGeometry
|
||||
from .css.match import match
|
||||
from .css.parse import parse_selectors
|
||||
from .dom import DOMNode
|
||||
from .timer import Timer
|
||||
from ._types import CallbackType
|
||||
from .geometry import Offset, Region, Size
|
||||
@@ -178,54 +181,105 @@ class Screen(Widget):
|
||||
|
||||
return widgets
|
||||
|
||||
def _move_focus(self, direction: int = 0) -> Widget | None:
|
||||
def _move_focus(
|
||||
self, direction: int = 0, selector: str | type[DOMNode.ExpectType] = "*"
|
||||
) -> Widget | None:
|
||||
"""Move the focus in the given direction.
|
||||
|
||||
If no widget is currently focused, this will focus the first focusable widget.
|
||||
If no focusable widget matches the given CSS selector, focus is set to `None`.
|
||||
|
||||
Args:
|
||||
direction (int, optional): 1 to move forward, -1 to move backward, or
|
||||
0 to keep the current focus.
|
||||
selector (str | type[DOMNode.ExpectType], optional): CSS selector to filter
|
||||
what nodes can be focused.
|
||||
|
||||
Returns:
|
||||
Widget | None: Newly focused widget, or None for no focus.
|
||||
Widget | None: Newly focused widget, or None for no focus. If the return
|
||||
is not `None`, then it is guaranteed that the widget returned matches
|
||||
the CSS selectors given in the argument.
|
||||
"""
|
||||
focusable_widgets = self.focus_chain
|
||||
if not isinstance(selector, str):
|
||||
selector = selector.__name__
|
||||
selector_set = parse_selectors(selector)
|
||||
focus_chain = self.focus_chain
|
||||
filtered_focus_chain = (
|
||||
node for node in focus_chain if match(selector_set, node)
|
||||
)
|
||||
|
||||
if not focusable_widgets:
|
||||
if not focus_chain:
|
||||
# Nothing focusable, so nothing to do
|
||||
return self.focused
|
||||
if self.focused is None:
|
||||
# Nothing currently focused, so focus the first one
|
||||
self.set_focus(focusable_widgets[0])
|
||||
# Nothing currently focused, so focus the first one.
|
||||
to_focus = next(filtered_focus_chain, None)
|
||||
self.set_focus(to_focus)
|
||||
return self.focused
|
||||
|
||||
# Ensure focus will be in a node that matches the selectors.
|
||||
if not direction and not match(selector_set, self.focused):
|
||||
direction = 1
|
||||
|
||||
try:
|
||||
# Find the index of the currently focused widget
|
||||
current_index = focus_chain.index(self.focused)
|
||||
except ValueError:
|
||||
# Focused widget was removed in the interim, start again
|
||||
self.set_focus(next(filtered_focus_chain, None))
|
||||
else:
|
||||
try:
|
||||
# Find the index of the currently focused widget
|
||||
current_index = focusable_widgets.index(self.focused)
|
||||
except ValueError:
|
||||
# Focused widget was removed in the interim, start again
|
||||
self.set_focus(focusable_widgets[0])
|
||||
else:
|
||||
# Only move the focus if we are currently showing the focus
|
||||
if direction:
|
||||
current_index = (current_index + direction) % len(focusable_widgets)
|
||||
self.set_focus(focusable_widgets[current_index])
|
||||
# Only move the focus if we are currently showing the focus
|
||||
if direction:
|
||||
to_focus: Widget | None = None
|
||||
chain_length = len(focus_chain)
|
||||
for step in range(1, len(focus_chain) + 1):
|
||||
node = focus_chain[
|
||||
(current_index + direction * step) % chain_length
|
||||
]
|
||||
if match(selector_set, node):
|
||||
to_focus = node
|
||||
break
|
||||
self.set_focus(to_focus)
|
||||
|
||||
return self.focused
|
||||
|
||||
def focus_next(self) -> Widget | None:
|
||||
"""Focus the next widget.
|
||||
def focus_next(
|
||||
self, selector: str | type[DOMNode.ExpectType] = "*"
|
||||
) -> Widget | None:
|
||||
"""Focus the next widget, optionally filtered by a CSS selector.
|
||||
|
||||
If no widget is currently focused, this will focus the first focusable widget.
|
||||
If no focusable widget matches the given CSS selector, focus is set to `None`.
|
||||
|
||||
Args:
|
||||
selector (str | type[DOMNode.ExpectType], optional): CSS selector to filter
|
||||
what nodes can be focused.
|
||||
|
||||
Returns:
|
||||
Widget | None: Newly focused widget, or None for no focus.
|
||||
Widget | None: Newly focused widget, or None for no focus. If the return
|
||||
is not `None`, then it is guaranteed that the widget returned matches
|
||||
the CSS selectors given in the argument.
|
||||
"""
|
||||
return self._move_focus(1)
|
||||
return self._move_focus(1, selector)
|
||||
|
||||
def focus_previous(self) -> Widget | None:
|
||||
"""Focus the previous widget.
|
||||
def focus_previous(
|
||||
self, selector: str | type[DOMNode.ExpectType] = "*"
|
||||
) -> Widget | None:
|
||||
"""Focus the previous widget, optionally filtered by a CSS selector.
|
||||
|
||||
If no widget is currently focused, this will focus the first focusable widget.
|
||||
If no focusable widget matches the given CSS selector, focus is set to `None`.
|
||||
|
||||
Args:
|
||||
selector (str | type[DOMNode.ExpectType], optional): CSS selector to filter
|
||||
what nodes can be focused.
|
||||
|
||||
Returns:
|
||||
Widget | None: Newly focused widget, or None for no focus.
|
||||
Widget | None: Newly focused widget, or None for no focus. If the return
|
||||
is not `None`, then it is guaranteed that the widget returned matches
|
||||
the CSS selectors given in the argument.
|
||||
"""
|
||||
return self._move_focus(-1)
|
||||
return self._move_focus(-1, selector)
|
||||
|
||||
def _reset_focus(
|
||||
self, widget: Widget, avoiding: list[Widget] | None = None
|
||||
|
||||
@@ -2395,7 +2395,7 @@ class Widget(DOMNode):
|
||||
except TypeError as error:
|
||||
raise TypeError(
|
||||
f"{self!r} compose() returned an invalid response; {error}"
|
||||
) from None
|
||||
) from error
|
||||
except Exception:
|
||||
self.app.panic(Traceback())
|
||||
else:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import pytest
|
||||
|
||||
from textual.app import App
|
||||
from textual.screen import Screen
|
||||
from textual.widget import Widget
|
||||
@@ -15,6 +17,28 @@ class ChildrenFocusableOnly(Widget, can_focus=False, can_focus_children=True):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def screen() -> Screen:
|
||||
app = App()
|
||||
app._set_active()
|
||||
app.push_screen(Screen())
|
||||
|
||||
screen = app.screen
|
||||
|
||||
# The classes even/odd alternate along the focus chain.
|
||||
# The classes in/out identify nested widgets.
|
||||
screen._add_children(
|
||||
Focusable(id="foo", classes="a"),
|
||||
NonFocusable(id="bar"),
|
||||
Focusable(Focusable(id="Paul", classes="c"), id="container1", classes="b"),
|
||||
NonFocusable(Focusable(id="Jessica", classes="a"), id="container2"),
|
||||
Focusable(id="baz", classes="b"),
|
||||
ChildrenFocusableOnly(Focusable(id="child", classes="c")),
|
||||
)
|
||||
|
||||
return screen
|
||||
|
||||
|
||||
def test_focus_chain():
|
||||
app = App()
|
||||
app._set_active()
|
||||
@@ -38,22 +62,7 @@ def test_focus_chain():
|
||||
assert focus_chain == ["foo", "container1", "Paul", "baz", "child"]
|
||||
|
||||
|
||||
def test_focus_next_and_previous():
|
||||
app = App()
|
||||
app._set_active()
|
||||
app.push_screen(Screen())
|
||||
|
||||
screen = app.screen
|
||||
|
||||
screen._add_children(
|
||||
Focusable(id="foo"),
|
||||
NonFocusable(id="bar"),
|
||||
Focusable(Focusable(id="Paul"), id="container1"),
|
||||
NonFocusable(Focusable(id="Jessica"), id="container2"),
|
||||
Focusable(id="baz"),
|
||||
ChildrenFocusableOnly(Focusable(id="child")),
|
||||
)
|
||||
|
||||
def test_focus_next_and_previous(screen: Screen):
|
||||
assert screen.focus_next().id == "foo"
|
||||
assert screen.focus_next().id == "container1"
|
||||
assert screen.focus_next().id == "Paul"
|
||||
@@ -64,3 +73,131 @@ def test_focus_next_and_previous():
|
||||
assert screen.focus_previous().id == "Paul"
|
||||
assert screen.focus_previous().id == "container1"
|
||||
assert screen.focus_previous().id == "foo"
|
||||
|
||||
|
||||
def test_focus_next_wrap_around(screen: Screen):
|
||||
"""Ensure focusing the next widget wraps around the focus chain."""
|
||||
screen.set_focus(screen.query_one("#child"))
|
||||
assert screen.focused.id == "child"
|
||||
|
||||
assert screen.focus_next().id == "foo"
|
||||
|
||||
|
||||
def test_focus_previous_wrap_around(screen: Screen):
|
||||
"""Ensure focusing the previous widget wraps around the focus chain."""
|
||||
screen.set_focus(screen.query_one("#foo"))
|
||||
assert screen.focused.id == "foo"
|
||||
|
||||
assert screen.focus_previous().id == "child"
|
||||
|
||||
|
||||
def test_wrap_around_selector(screen: Screen):
|
||||
"""Ensure moving focus in both directions wraps around the focus chain."""
|
||||
screen.set_focus(screen.query_one("#foo"))
|
||||
assert screen.focused.id == "foo"
|
||||
|
||||
assert screen.focus_previous("#Paul").id == "Paul"
|
||||
assert screen.focus_next("#foo").id == "foo"
|
||||
|
||||
|
||||
def test_no_focus_empty_selector(screen: Screen):
|
||||
"""Ensure focus is cleared when selector matches nothing."""
|
||||
assert screen.focus_next("#bananas") is None
|
||||
assert screen.focus_previous("#bananas") is None
|
||||
|
||||
screen.set_focus(screen.query_one("#foo"))
|
||||
assert screen.focused is not None
|
||||
assert screen.focus_next("bananas") is None
|
||||
assert screen.focused is None
|
||||
|
||||
screen.set_focus(screen.query_one("#foo"))
|
||||
assert screen.focused is not None
|
||||
assert screen.focus_previous("bananas") is None
|
||||
assert screen.focused is None
|
||||
|
||||
|
||||
def test_focus_next_and_previous_with_type_selector(screen: Screen):
|
||||
"""Move focus with a selector that matches the currently focused node."""
|
||||
screen.set_focus(screen.query_one("#Paul"))
|
||||
assert screen.focused.id == "Paul"
|
||||
|
||||
assert screen.focus_next(Focusable).id == "baz"
|
||||
assert screen.focus_next(Focusable).id == "child"
|
||||
|
||||
assert screen.focus_previous(Focusable).id == "baz"
|
||||
assert screen.focus_previous(Focusable).id == "Paul"
|
||||
assert screen.focus_previous(Focusable).id == "container1"
|
||||
assert screen.focus_previous(Focusable).id == "foo"
|
||||
|
||||
|
||||
def test_focus_next_and_previous_with_str_selector(screen: Screen):
|
||||
"""Move focus with a selector that matches the currently focused node."""
|
||||
screen.set_focus(screen.query_one("#foo"))
|
||||
assert screen.focused.id == "foo"
|
||||
|
||||
assert screen.focus_next(".a").id == "foo"
|
||||
assert screen.focus_next(".c").id == "Paul"
|
||||
assert screen.focus_next(".c").id == "child"
|
||||
|
||||
assert screen.focus_previous(".c").id == "Paul"
|
||||
assert screen.focus_previous(".a").id == "foo"
|
||||
|
||||
|
||||
def test_focus_next_and_previous_with_type_selector_without_self():
|
||||
"""Test moving the focus with a selector that does not match the currently focused node."""
|
||||
app = App()
|
||||
app._set_active()
|
||||
app.push_screen(Screen())
|
||||
|
||||
screen = app.screen
|
||||
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.widgets import Button, Checkbox, Input
|
||||
|
||||
screen._add_children(
|
||||
Vertical(
|
||||
Horizontal(
|
||||
Input(id="w3"),
|
||||
Checkbox(id="w4"),
|
||||
Input(id="w5"),
|
||||
Button(id="w6"),
|
||||
Checkbox(id="w7"),
|
||||
id="w2",
|
||||
),
|
||||
Horizontal(
|
||||
Button(id="w9"),
|
||||
Checkbox(id="w10"),
|
||||
Button(id="w11"),
|
||||
Input(id="w12"),
|
||||
Input(id="w13"),
|
||||
id="w8",
|
||||
),
|
||||
id="w1",
|
||||
)
|
||||
)
|
||||
|
||||
screen.set_focus(screen.query_one("#w3"))
|
||||
assert screen.focused.id == "w3"
|
||||
|
||||
assert screen.focus_next(Button).id == "w6"
|
||||
assert screen.focus_next(Checkbox).id == "w7"
|
||||
assert screen.focus_next(Input).id == "w12"
|
||||
|
||||
assert screen.focus_previous(Button).id == "w11"
|
||||
assert screen.focus_previous(Checkbox).id == "w10"
|
||||
assert screen.focus_previous(Button).id == "w9"
|
||||
assert screen.focus_previous(Input).id == "w5"
|
||||
|
||||
|
||||
def test_focus_next_and_previous_with_str_selector_without_self(screen: Screen):
|
||||
"""Test moving the focus with a selector that does not match the currently focused node."""
|
||||
screen.set_focus(screen.query_one("#foo"))
|
||||
assert screen.focused.id == "foo"
|
||||
|
||||
assert screen.focus_next(".c").id == "Paul"
|
||||
assert screen.focus_next(".b").id == "baz"
|
||||
assert screen.focus_next(".c").id == "child"
|
||||
|
||||
assert screen.focus_previous(".a").id == "foo"
|
||||
assert screen.focus_previous(".a").id == "foo"
|
||||
assert screen.focus_previous(".b").id == "baz"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import pytest
|
||||
|
||||
from textual.app import App
|
||||
from textual.keys import _character_to_key
|
||||
from textual.keys import _character_to_key, _get_key_display
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -48,3 +48,7 @@ async def test_character_bindings():
|
||||
await pilot.press("x")
|
||||
await pilot.pause()
|
||||
assert counter == 3
|
||||
|
||||
|
||||
def test_get_key_display():
|
||||
assert _get_key_display("minus") == "-"
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import pytest
|
||||
from rich.style import Style
|
||||
|
||||
from textual.color import Color
|
||||
from textual.css.errors import StyleValueError
|
||||
from textual.css.styles import Styles
|
||||
|
||||
|
||||
@@ -7,3 +11,22 @@ def test_box_normalization():
|
||||
styles = Styles()
|
||||
styles.border_left = ("none", "red")
|
||||
assert styles.border_left == ("", Color.parse("red"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("style_attr", ["text_style", "link_style"])
|
||||
def test_text_style_none_with_others(style_attr):
|
||||
"""Style "none" mixed with others should give custom Textual exception."""
|
||||
styles = Styles()
|
||||
|
||||
with pytest.raises(StyleValueError):
|
||||
setattr(styles, style_attr, "bold none underline italic")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("style_attr", ["text_style", "link_style"])
|
||||
def test_text_style_set_to_none(style_attr):
|
||||
"""Setting text style to "none" should clear the styles."""
|
||||
styles = Styles()
|
||||
setattr(styles, style_attr, "bold underline italic")
|
||||
assert getattr(styles, style_attr) != Style.null()
|
||||
setattr(styles, style_attr, "none")
|
||||
assert getattr(styles, style_attr) == Style.null()
|
||||
|
||||
Reference in New Issue
Block a user