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 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 `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 `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 ### 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 - 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 - 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 - `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 - 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 - 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 - `COMPONENT_CLASSES` are now inherited from base classes https://github.com/Textualize/textual/issues/1399
- Watch methods may now take no parameters - Watch methods may now take no parameters
- Added `compute` parameter to reactive - Added `compute` parameter to reactive
- A `TypeError` raised during `compose` now carries the full traceback.
### Fixed ### Fixed

View File

@@ -8,13 +8,13 @@ The `<text-style>` CSS type represents styles that can be applied to text.
## Syntax ## 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 | | Value | Description |
|-------------|-----------------------------------------------------------------| |-------------|-----------------------------------------------------------------|
| `bold` | **Bold text.** | | `bold` | **Bold text.** |
| `italic` | _Italic text._ | | `italic` | _Italic text._ |
| `none` | Plain text with no styling. |
| `reverse` | Reverse video text (foreground and background colors reversed). | | `reverse` | Reverse video text (foreground and background colors reversed). |
| `strike` | <s>Strikethrough text.</s> | | `strike` | <s>Strikethrough text.</s> |
| `underline` | <u>Underline text.</u> | | `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" widget.styles.text_style = "strike"
# You can also combine multiple values # 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: except TypeError as error:
raise TypeError( raise TypeError(
f"{self!r} compose() returned an invalid response; {error}" f"{self!r} compose() returned an invalid response; {error}"
) from None ) from error
await self.mount_all(widgets) await self.mount_all(widgets)
def _on_idle(self) -> None: def _on_idle(self) -> None:

View File

@@ -1,15 +1,18 @@
from rich.panel import Panel from rich.panel import Panel
from rich.text import Text
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.reactive import var, Reactive
from textual import events from textual import events
from textual.containers import Horizontal from textual.containers import Horizontal
from textual.widgets import Button, Header, TextLog from textual.widgets import Button, Header, TextLog
INSTRUCTIONS = """\ 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: def compose(self) -> ComposeResult:
yield Header() yield Header()
yield Horizontal( yield Horizontal(
@@ -42,10 +47,17 @@ class KeysApp(App, inherit_bindings=False):
yield KeyLog() yield KeyLog()
def on_ready(self) -> None: 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: def on_key(self, event: events.Key) -> None:
self.query_one(KeyLog).write(event) 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: def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "quit": 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" f"Style flag values such as [i]{property_name}[/] expect space-separated values"
), ),
Bullet(f"Permitted values are {friendly_list(VALID_STYLE_FLAGS)}"), Bullet(f"Permitted values are {friendly_list(VALID_STYLE_FLAGS)}"),
Bullet("The value 'none' cannot be mixed with others"),
*ContextSpecificBullets( *ContextSpecificBullets(
inline=[ inline=[
Bullet( Bullet(

View File

@@ -12,6 +12,7 @@ from __future__ import annotations
from operator import attrgetter from operator import attrgetter
from typing import TYPE_CHECKING, Generic, Iterable, NamedTuple, TypeVar, cast from typing import TYPE_CHECKING, Generic, Iterable, NamedTuple, TypeVar, cast
import rich.errors
import rich.repr import rich.repr
from rich.style import Style from rich.style import Style
@@ -909,7 +910,17 @@ class StyleFlagsProperty:
self.name, word, context="inline" self.name, word, context="inline"
), ),
) )
try:
style = Style.parse(style_flags) 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): if obj.set_rule(self.name, style):
obj.refresh() obj.refresh()

View File

@@ -7,7 +7,7 @@ from rich.style import Style
from ._types import MessageTarget from ._types import MessageTarget
from .geometry import Offset, Size from .geometry import Offset, Size
from .keys import _get_key_aliases from .keys import _get_key_aliases, _get_key_display
from .message import Message from .message import Message
MouseEventT = TypeVar("MouseEventT", bound="MouseEvent") MouseEventT = TypeVar("MouseEventT", bound="MouseEvent")

View File

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

View File

@@ -9,6 +9,9 @@ from rich.style import Style
from . import errors, events, messages from . import errors, events, messages
from ._callback import invoke from ._callback import invoke
from ._compositor import Compositor, MapGeometry 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 .timer import Timer
from ._types import CallbackType from ._types import CallbackType
from .geometry import Offset, Region, Size from .geometry import Offset, Region, Size
@@ -178,54 +181,105 @@ class Screen(Widget):
return widgets 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. """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: Args:
direction (int, optional): 1 to move forward, -1 to move backward, or direction (int, optional): 1 to move forward, -1 to move backward, or
0 to keep the current focus. 0 to keep the current focus.
selector (str | type[DOMNode.ExpectType], optional): CSS selector to filter
what nodes can be focused.
Returns: 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 # Nothing focusable, so nothing to do
return self.focused return self.focused
if self.focused is None: if self.focused is None:
# Nothing currently focused, so focus the first one # Nothing currently focused, so focus the first one.
self.set_focus(focusable_widgets[0]) to_focus = next(filtered_focus_chain, None)
else: 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: try:
# Find the index of the currently focused widget # Find the index of the currently focused widget
current_index = focusable_widgets.index(self.focused) current_index = focus_chain.index(self.focused)
except ValueError: except ValueError:
# Focused widget was removed in the interim, start again # Focused widget was removed in the interim, start again
self.set_focus(focusable_widgets[0]) self.set_focus(next(filtered_focus_chain, None))
else: else:
# Only move the focus if we are currently showing the focus # Only move the focus if we are currently showing the focus
if direction: if direction:
current_index = (current_index + direction) % len(focusable_widgets) to_focus: Widget | None = None
self.set_focus(focusable_widgets[current_index]) 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 return self.focused
def focus_next(self) -> Widget | None: def focus_next(
"""Focus the next widget. 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: 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: def focus_previous(
"""Focus the previous widget. 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: 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( def _reset_focus(
self, widget: Widget, avoiding: list[Widget] | None = None self, widget: Widget, avoiding: list[Widget] | None = None

View File

@@ -2395,7 +2395,7 @@ class Widget(DOMNode):
except TypeError as error: except TypeError as error:
raise TypeError( raise TypeError(
f"{self!r} compose() returned an invalid response; {error}" f"{self!r} compose() returned an invalid response; {error}"
) from None ) from error
except Exception: except Exception:
self.app.panic(Traceback()) self.app.panic(Traceback())
else: else:

View File

@@ -1,3 +1,5 @@
import pytest
from textual.app import App from textual.app import App
from textual.screen import Screen from textual.screen import Screen
from textual.widget import Widget from textual.widget import Widget
@@ -15,6 +17,28 @@ class ChildrenFocusableOnly(Widget, can_focus=False, can_focus_children=True):
pass 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(): def test_focus_chain():
app = App() app = App()
app._set_active() app._set_active()
@@ -38,22 +62,7 @@ def test_focus_chain():
assert focus_chain == ["foo", "container1", "Paul", "baz", "child"] assert focus_chain == ["foo", "container1", "Paul", "baz", "child"]
def test_focus_next_and_previous(): def test_focus_next_and_previous(screen: Screen):
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")),
)
assert screen.focus_next().id == "foo" assert screen.focus_next().id == "foo"
assert screen.focus_next().id == "container1" assert screen.focus_next().id == "container1"
assert screen.focus_next().id == "Paul" 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 == "Paul"
assert screen.focus_previous().id == "container1" assert screen.focus_previous().id == "container1"
assert screen.focus_previous().id == "foo" 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 import pytest
from textual.app import App 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( @pytest.mark.parametrize(
@@ -48,3 +48,7 @@ async def test_character_bindings():
await pilot.press("x") await pilot.press("x")
await pilot.pause() await pilot.pause()
assert counter == 3 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.color import Color
from textual.css.errors import StyleValueError
from textual.css.styles import Styles from textual.css.styles import Styles
@@ -7,3 +11,22 @@ def test_box_normalization():
styles = Styles() styles = Styles()
styles.border_left = ("none", "red") styles.border_left = ("none", "red")
assert styles.border_left == ("", Color.parse("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()