Merge branch 'main' into datatable-events

This commit is contained in:
Will McGugan
2023-01-18 01:46:21 -08:00
committed by GitHub
13 changed files with 298 additions and 52 deletions

View File

@@ -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

View File

@@ -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
```

View File

@@ -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:

View File

@@ -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":

View File

@@ -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(

View File

@@ -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()

View File

@@ -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")

View File

@@ -228,6 +228,7 @@ KEY_DISPLAY_ALIASES = {
"backspace": "",
"escape": "ESC",
"enter": "",
"minus": "-",
}

View File

@@ -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

View File

@@ -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:

View File

@@ -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"

View File

@@ -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") == "-"

View File

@@ -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()