Merge branch 'main' into M-x

This commit is contained in:
Dave Pearson
2023-09-06 14:46:33 +01:00
23 changed files with 1075 additions and 9 deletions

View File

@@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
## Unreleased
## [0.36.0] - 2023-09-05
### Added
@@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- `App.return_code` for the app return code https://github.com/Textualize/textual/pull/3202
- Added the command palette https://github.com/Textualize/textual/pull/3058
- Added `animate` switch to `Tree.scroll_to_line` and `Tree.scroll_to_node` https://github.com/Textualize/textual/pull/3210
- Added `Rule` widget https://github.com/Textualize/textual/pull/3209
- Added App.current_mode to get the current mode https://github.com/Textualize/textual/pull/3233
### Changed
@@ -1246,6 +1248,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040
- New handler system for messages that doesn't require inheritance
- Improved traceback handling
[0.36.0]: https://github.com/Textualize/textual/compare/v0.35.1...v0.36.0
[0.35.1]: https://github.com/Textualize/textual/compare/v0.35.0...v0.35.1
[0.35.0]: https://github.com/Textualize/textual/compare/v0.34.0...v0.35.0
[0.34.0]: https://github.com/Textualize/textual/compare/v0.33.0...v0.34.0

View File

@@ -0,0 +1,42 @@
from textual.app import App, ComposeResult
from textual.screen import Screen
from textual.widgets import Footer, Placeholder
class DashboardScreen(Screen):
def compose(self) -> ComposeResult:
yield Placeholder("Dashboard Screen")
yield Footer()
class SettingsScreen(Screen):
def compose(self) -> ComposeResult:
yield Placeholder("Settings Screen")
yield Footer()
class HelpScreen(Screen):
def compose(self) -> ComposeResult:
yield Placeholder("Help Screen")
yield Footer()
class ModesApp(App):
BINDINGS = [
("d", "switch_mode('dashboard')", "Dashboard"), # (1)!
("s", "switch_mode('settings')", "Settings"),
("h", "switch_mode('help')", "Help"),
]
MODES = {
"dashboard": DashboardScreen, # (2)!
"settings": SettingsScreen,
"help": HelpScreen,
}
def on_mount(self) -> None:
self.switch_mode("dashboard") # (3)!
if __name__ == "__main__":
app = ModesApp()
app.run()

View File

@@ -0,0 +1,27 @@
from textual.app import App, ComposeResult
from textual.widgets import Rule, Label
from textual.containers import Vertical
class HorizontalRulesApp(App):
CSS_PATH = "horizontal_rules.tcss"
def compose(self) -> ComposeResult:
with Vertical():
yield Label("solid (default)")
yield Rule()
yield Label("heavy")
yield Rule(line_style="heavy")
yield Label("thick")
yield Rule(line_style="thick")
yield Label("dashed")
yield Rule(line_style="dashed")
yield Label("double")
yield Rule(line_style="double")
yield Label("ascii")
yield Rule(line_style="ascii")
if __name__ == "__main__":
app = HorizontalRulesApp()
app.run()

View File

@@ -0,0 +1,13 @@
Screen {
align: center middle;
}
Vertical {
height: auto;
width: 80%;
}
Label {
width: 100%;
text-align: center;
}

View File

@@ -0,0 +1,27 @@
from textual.app import App, ComposeResult
from textual.widgets import Rule, Label
from textual.containers import Horizontal
class VerticalRulesApp(App):
CSS_PATH = "vertical_rules.tcss"
def compose(self) -> ComposeResult:
with Horizontal():
yield Label("solid")
yield Rule(orientation="vertical")
yield Label("heavy")
yield Rule(orientation="vertical", line_style="heavy")
yield Label("thick")
yield Rule(orientation="vertical", line_style="thick")
yield Label("dashed")
yield Rule(orientation="vertical", line_style="dashed")
yield Label("double")
yield Rule(orientation="vertical", line_style="double")
yield Label("ascii")
yield Rule(orientation="vertical", line_style="ascii")
if __name__ == "__main__":
app = VerticalRulesApp()
app.run()

View File

@@ -0,0 +1,14 @@
Screen {
align: center middle;
}
Horizontal {
width: auto;
height: 80%;
}
Label {
width: 6;
height: 100%;
text-align: center;
}

View File

@@ -256,3 +256,63 @@ Returning data in this way can help keep your code manageable by making it easy
You may have noticed in the previous example that we changed the base class to `ModalScreen[bool]`.
The addition of `[bool]` adds typing information that tells the type checker to expect a boolean in the call to `dismiss`, and that any callback set in `push_screen` should also expect the same type. As always, typing is optional in Textual, but this may help you catch bugs.
## Modes
Some apps may benefit from having multiple screen stacks, rather than just one.
Consider an app with a dashboard screen, a settings screen, and a help screen.
These are independent in the sense that we don't want to prevent the user from switching between them, even if there are one or more modal screens on the screen stack.
But we may still want each individual screen to have a navigation stack where we can push and pop screens.
In Textual we can manage this with *modes*.
A mode is simply a named screen stack, which we can switch between as required.
When we switch modes, the topmost screen in the new mode becomes the active visible screen.
The following diagram illustrates such an app with modes.
On startup the app switches to the "dashboard" mode which makes the top of the stack visible.
<div class="excalidraw">
--8<-- "docs/images/screens/modes1.excalidraw.svg"
</div>
If we later change the mode to "settings", the top of that mode's screen stack becomes visible.
<div class="excalidraw">
--8<-- "docs/images/screens/modes2.excalidraw.svg"
</div>
To add modes to your app, define a [`MODES`][textual.app.App.MODES] class variable in your App class which should be a `dict` that maps the name of the mode on to either a screen object, a callable that returns a screen, or the name of an installed screen.
However you specify it, the values in `MODES` set the base screen for each mode's screen stack.
You can switch between these screens at any time by calling [`App.switch_mode`][textual.app.App.switch_mode].
When you switch to a new mode, the topmost screen in the new stack becomes visible.
Any calls to [`App.push_screen`][textual.app.App.push_screen] or [`App.pop_screen`][textual.app.App.pop_screen] will affect only the active mode.
Let's look at an example with modes:
=== "modes01.py"
```python hl_lines="25-29 30-34 37"
--8<-- "docs/examples/guide/screens/modes01.py"
```
1. `switch_mode` is a builtin action to switch modes.
2. Associates `DashboardScreen` with the name "dashboard".
3. Switches to the dashboard mode.
=== "Output"
```{.textual path="docs/examples/guide/screens/modes01.py"}
```
=== "Output (after pressing S)"
```{.textual path="docs/examples/guide/screens/modes01.py", press="s"}
```
Here we have defined three screens.
One for a dashboard, one for settings, and one for help.
We've bound keys to each of these screens, so the user can switch between the screens.
Pressing ++d++, ++s++, or ++h++ switches between these modes.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -225,6 +225,16 @@ Display and update text in a scrolling panel.
```{.textual path="docs/examples/widgets/rich_log.py" press="H,i"}
```
## Rule
A rule widget to separate content, similar to a `<hr>` HTML tag.
[Rule reference](./widgets/rule.md){ .md-button .md-button--primary }
```{.textual path="docs/examples/widgets/horizontal_rules.py"}
```
## Select
Select from a number of possible options.

75
docs/widgets/rule.md Normal file
View File

@@ -0,0 +1,75 @@
# Rule
A rule widget to separate content, similar to a `<hr>` HTML tag.
- [ ] Focusable
- [ ] Container
## Examples
### Horizontal Rule
The default orientation of a rule is horizontal.
The example below shows horizontal rules with all the available line styles.
=== "Output"
```{.textual path="docs/examples/widgets/horizontal_rules.py"}
```
=== "horizontal_rules.py"
```python
--8<-- "docs/examples/widgets/horizontal_rules.py"
```
=== "horizontal_rules.tcss"
```sass
--8<-- "docs/examples/widgets/horizontal_rules.tcss"
```
### Vertical Rule
The example below shows vertical rules with all the available line styles.
=== "Output"
```{.textual path="docs/examples/widgets/vertical_rules.py"}
```
=== "vertical_rules.py"
```python
--8<-- "docs/examples/widgets/vertical_rules.py"
```
=== "vertical_rules.tcss"
```sass
--8<-- "docs/examples/widgets/vertical_rules.tcss"
```
## Reactive Attributes
| Name | Type | Default | Description |
| ------------- | ----------------- | -------------- | ---------------------------- |
| `orientation` | `RuleOrientation` | `"horizontal"` | The orientation of the rule. |
| `line_style` | `LineStyle` | `"solid"` | The line style of the rule. |
## Messages
This widget sends no messages.
---
::: textual.widgets.Rule
options:
heading_level: 2
::: textual.widgets.rule
options:
show_root_heading: true
show_root_toc_entry: true

View File

@@ -153,6 +153,7 @@ nav:
- "widgets/radiobutton.md"
- "widgets/radioset.md"
- "widgets/rich_log.md"
- "widgets/rule.md"
- "widgets/select.md"
- "widgets/selection_list.md"
- "widgets/sparkline.md"

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "textual"
version = "0.35.1"
version = "0.36.0"
homepage = "https://github.com/Textualize/textual"
repository = "https://github.com/Textualize/textual"
documentation = "https://textual.textualize.io/"

116
src/textual/_slug.py Normal file
View File

@@ -0,0 +1,116 @@
"""Provides a utility function and class for creating Markdown-friendly slugs.
The approach to creating slugs is designed to be as close to
GitHub-flavoured Markdown as possible. However, because there doesn't appear
to be any actual documentation for this 'standard', the code here involves
some guesswork and also some pragmatic shortcuts.
Expect this to grow over time.
The main rules used in here at the moment are:
1. Strip all leading and trailing whitespace.
2. Remove all non-lingual characters (emoji, etc).
3. Remove all punctuation and whitespace apart from dash and underscore.
"""
from __future__ import annotations
from collections import defaultdict
from re import compile
from string import punctuation
from typing import Pattern
from urllib.parse import quote
from typing_extensions import Final
WHITESPACE_REPLACEMENT: Final[str] = "-"
"""The character to replace undesirable characters with."""
REMOVABLE: Final[str] = punctuation.replace(WHITESPACE_REPLACEMENT, "").replace("_", "")
"""The collection of characters that should be removed altogether."""
NONLINGUAL: Final[str] = (
r"\U000024C2-\U0001F251"
r"\U00002702-\U000027B0"
r"\U0001F1E0-\U0001F1FF"
r"\U0001F300-\U0001F5FF"
r"\U0001F600-\U0001F64F"
r"\U0001F680-\U0001F6FF"
r"\U0001f926-\U0001f937"
r"\u200D"
r"\u2640-\u2642"
)
"""A string that can be used in a regular expression to remove most non-lingual characters."""
STRIP_RE: Final[Pattern] = compile(f"[{REMOVABLE}{NONLINGUAL}]+")
"""A regular expression for finding all the characters that should be removed."""
WHITESPACE_RE: Final[Pattern] = compile(r"\s")
"""A regular expression for finding all the whitespace and turning it into `REPLACEMENT`."""
def slug(text: str) -> str:
"""Create a Markdown-friendly slug from the given text.
Args:
text: The text to generate a slug from.
Returns:
A slug for the given text.
The rules used in generating the slug are based on observations of how
GitHub-flavoured Markdown works.
"""
result = text.strip().lower()
for rule, replacement in (
(STRIP_RE, ""),
(WHITESPACE_RE, WHITESPACE_REPLACEMENT),
):
result = rule.sub(replacement, result)
return quote(result)
class TrackedSlugs:
"""Provides a class for generating tracked slugs.
While [`slug`][textual._slug.slug] will generate a slug for a given
string, it does not guarantee that it is unique for a given context. If
you want to ensure that the same string generates unique slugs (perhaps
heading slugs within a Markdown document, as an example), use an
instance of this class to generate them.
Example:
```python
>>> slug("hello world")
'hello-world'
>>> slug("hello world")
'hello-world'
>>> unique = TrackedSlugs()
>>> unique.slug("hello world")
'hello-world'
>>> unique.slug("hello world")
'hello-world-1'
```
"""
def __init__(self) -> None:
"""Initialise the tracked slug object."""
self._used: defaultdict[str, int] = defaultdict(int)
"""Keeps track of how many times a particular slug has been used."""
def slug(self, text: str) -> str:
"""Create a Markdown-friendly unique slug from the given text.
Args:
text: The text to generate a slug from.
Returns:
A slug for the given text.
"""
slugged = slug(text)
used = self._used[slugged]
self._used[slugged] += 1
if used:
slugged = f"{slugged}-{used}"
return slugged

View File

@@ -693,6 +693,11 @@ class App(Generic[ReturnType], DOMNode):
"""
return self._screen_stacks[self._current_mode]
@property
def current_mode(self) -> str:
"""The name of the currently active mode."""
return self._current_mode
def exit(
self,
result: ReturnType | None = None,
@@ -1560,19 +1565,19 @@ class App(Generic[ReturnType], DOMNode):
return self.mount(*widgets, before=before, after=after)
def _init_mode(self, mode: str) -> None:
"""Do internal initialisation of a new screen stack mode."""
"""Do internal initialisation of a new screen stack mode.
Args:
mode: Name of the mode.
"""
stack = self._screen_stacks.get(mode, [])
if not stack:
_screen = self.MODES[mode]
if callable(_screen):
screen, _ = self._get_screen(_screen())
else:
screen, _ = self._get_screen(self.MODES[mode])
new_screen: Screen | str = _screen() if callable(_screen) else _screen
screen, _ = self._get_screen(new_screen)
stack.append(screen)
self._load_screen_css(screen)
self._screen_stacks[mode] = stack
def switch_mode(self, mode: str) -> None:

View File

@@ -32,6 +32,7 @@ if typing.TYPE_CHECKING:
from ._radio_button import RadioButton
from ._radio_set import RadioSet
from ._rich_log import RichLog
from ._rule import Rule
from ._select import Select
from ._selection_list import SelectionList
from ._sparkline import Sparkline
@@ -67,6 +68,7 @@ __all__ = [
"ProgressBar",
"RadioButton",
"RadioSet",
"Rule",
"Select",
"SelectionList",
"Sparkline",

View File

@@ -22,6 +22,7 @@ from ._progress_bar import ProgressBar as ProgressBar
from ._radio_button import RadioButton as RadioButton
from ._radio_set import RadioSet as RadioSet
from ._rich_log import RichLog as RichLog
from ._rule import Rule as Rule
from ._select import Select as Select
from ._selection_list import SelectionList as SelectionList
from ._sparkline import Sparkline as Sparkline

View File

@@ -0,0 +1,217 @@
from __future__ import annotations
from rich.text import Text
from typing_extensions import Literal
from ..app import RenderResult
from ..css._error_tools import friendly_list
from ..reactive import Reactive, reactive
from ..widget import Widget
RuleOrientation = Literal["horizontal", "vertical"]
"""The valid orientations of the rule widget."""
LineStyle = Literal[
"ascii",
"blank",
"dashed",
"double",
"heavy",
"hidden",
"none",
"solid",
"thick",
]
"""The valid line styles of the rule widget."""
_VALID_RULE_ORIENTATIONS = {"horizontal", "vertical"}
_VALID_LINE_STYLES = {
"ascii",
"blank",
"dashed",
"double",
"heavy",
"hidden",
"none",
"solid",
"thick",
}
_HORIZONTAL_LINE_CHARS: dict[LineStyle, str] = {
"ascii": "-",
"blank": " ",
"dashed": "",
"double": "",
"heavy": "",
"hidden": " ",
"none": " ",
"solid": "",
"thick": "",
}
_VERTICAL_LINE_CHARS: dict[LineStyle, str] = {
"ascii": "|",
"blank": " ",
"dashed": "",
"double": "",
"heavy": "",
"hidden": " ",
"none": " ",
"solid": "",
"thick": "",
}
class InvalidRuleOrientation(Exception):
"""Exception raised for an invalid rule orientation."""
class InvalidLineStyle(Exception):
"""Exception raised for an invalid rule line style."""
class Rule(Widget, can_focus=False):
"""A rule widget to separate content, similar to a `<hr>` HTML tag."""
DEFAULT_CSS = """
Rule {
color: $primary;
}
Rule.-horizontal {
min-height: 1;
max-height: 1;
margin: 1 0;
}
Rule.-vertical {
min-width: 1;
max-width: 1;
margin: 0 2;
}
"""
orientation: Reactive[RuleOrientation] = reactive[RuleOrientation]("horizontal")
"""The orientation of the rule."""
line_style: Reactive[LineStyle] = reactive[LineStyle]("solid")
"""The line style of the rule."""
def __init__(
self,
orientation: RuleOrientation = "horizontal",
line_style: LineStyle = "solid",
*,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
) -> None:
"""Initialize a rule widget.
Args:
orientation: The orientation of the rule.
line_style: The line style of the rule.
name: The name of the widget.
id: The ID of the widget in the DOM.
classes: The CSS classes of the widget.
disabled: Whether the widget is disabled or not.
"""
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
self.orientation = orientation
self.line_style = line_style
def render(self) -> RenderResult:
rule_char: str
if self.orientation == "vertical":
rule_char = _VERTICAL_LINE_CHARS[self.line_style]
return Text(rule_char * self.size.height)
elif self.orientation == "horizontal":
rule_char = _HORIZONTAL_LINE_CHARS[self.line_style]
return Text(rule_char * self.size.width)
else:
raise InvalidRuleOrientation(
f"Valid rule orientations are {friendly_list(_VALID_RULE_ORIENTATIONS)}"
)
def watch_orientation(
self, old_orientation: RuleOrientation, orientation: RuleOrientation
) -> None:
self.remove_class(f"-{old_orientation}")
self.add_class(f"-{orientation}")
def validate_orientation(self, orientation: RuleOrientation) -> RuleOrientation:
if orientation not in _VALID_RULE_ORIENTATIONS:
raise InvalidRuleOrientation(
f"Valid rule orientations are {friendly_list(_VALID_RULE_ORIENTATIONS)}"
)
return orientation
def validate_line_style(self, style: LineStyle) -> LineStyle:
if style not in _VALID_LINE_STYLES:
raise InvalidLineStyle(
f"Valid rule line styles are {friendly_list(_VALID_LINE_STYLES)}"
)
return style
@classmethod
def horizontal(
cls,
line_style: LineStyle = "solid",
name: str | None = None,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
) -> Rule:
"""Utility constructor for creating a horizontal rule.
Args:
line_style: The line style of the rule.
name: The name of the widget.
id: The ID of the widget in the DOM.
classes: The CSS classes of the widget.
disabled: Whether the widget is disabled or not.
Returns:
A rule widget with horizontal orientation.
"""
return Rule(
orientation="horizontal",
line_style=line_style,
name=name,
id=id,
classes=classes,
disabled=disabled,
)
@classmethod
def vertical(
cls,
line_style: LineStyle = "solid",
name: str | None = None,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
) -> Rule:
"""Utility constructor for creating a vertical rule.
Args:
line_style: The line style of the rule.
name: The name of the widget.
id: The ID of the widget in the DOM.
classes: The CSS classes of the widget.
disabled: Whether the widget is disabled or not.
Returns:
A rule widget with vertical orientation.
"""
return Rule(
orientation="vertical",
line_style=line_style,
name=name,
id=id,
classes=classes,
disabled=disabled,
)

View File

@@ -0,0 +1,13 @@
from ._rule import (
InvalidLineStyle,
InvalidRuleOrientation,
LineStyle,
RuleOrientation,
)
__all__ = [
"InvalidLineStyle",
"InvalidRuleOrientation",
"LineStyle",
"RuleOrientation",
]

File diff suppressed because one or more lines are too long

View File

@@ -285,6 +285,14 @@ def test_progress_bar_completed_styled(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "progress_bar_styled_.py", press=["u"])
def test_rule_horizontal_rules(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "horizontal_rules.py")
def test_rule_vertical_rules(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "vertical_rules.py")
def test_select(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "select_widget.py")

26
tests/test_rule.py Normal file
View File

@@ -0,0 +1,26 @@
import pytest
from textual.widgets import Rule
from textual.widgets.rule import InvalidLineStyle, InvalidRuleOrientation
def test_invalid_rule_orientation():
with pytest.raises(InvalidRuleOrientation):
Rule(orientation="invalid orientation!")
def test_invalid_rule_line_style():
with pytest.raises(InvalidLineStyle):
Rule(line_style="invalid line style!")
def test_invalid_reactive_rule_orientation_change():
rule = Rule()
with pytest.raises(InvalidRuleOrientation):
rule.orientation = "invalid orientation!"
def test_invalid_reactive_rule_line_style_change():
rule = Rule()
with pytest.raises(InvalidLineStyle):
rule.line_style = "invalid line style!"

62
tests/test_slug.py Normal file
View File

@@ -0,0 +1,62 @@
import pytest
from textual._slug import TrackedSlugs, slug
@pytest.mark.parametrize(
"text, expected",
[
("test", "test"),
("Test", "test"),
(" Test ", "test"),
("-test-", "-test-"),
("!test!", "test"),
("test!!test", "testtest"),
("test! !test", "test-test"),
("test test", "test-test"),
("test test", "test--test"),
("test test", "test----------test"),
("--test", "--test"),
("test--", "test--"),
("--test--test--", "--test--test--"),
("test!\"#$%&'()*+,-./:;<=>?@[]^_`{|}~test", "test-_test"),
("tëst", "t%C3%ABst"),
("test🙂test", "testtest"),
("test🤷test", "testtest"),
("test🤷🏻test", "testtest"),
],
)
def test_simple_slug(text: str, expected: str) -> None:
"""The simple slug function should produce the expected slug."""
assert slug(text) == expected
@pytest.fixture(scope="module")
def tracker() -> TrackedSlugs:
return TrackedSlugs()
@pytest.mark.parametrize(
"text, expected",
[
("test", "test"),
("test", "test-1"),
("test", "test-2"),
("-test-", "-test-"),
("-test-", "-test--1"),
("test!\"#$%&'()*+,-./:;<=>?@[]^_`{|}~test", "test-_test"),
("test!\"#$%&'()*+,-./:;<=>?@[]^_`{|}~test", "test-_test-1"),
("tëst", "t%C3%ABst"),
("tëst", "t%C3%ABst-1"),
("tëst", "t%C3%ABst-2"),
("test🙂test", "testtest"),
("test🤷test", "testtest-1"),
("test🤷🏻test", "testtest-2"),
("test", "test-3"),
("test", "test-4"),
(" test ", "test-5"),
],
)
def test_tracked_slugs(tracker: TrackedSlugs, text: str, expected: str) -> None:
"""The tracked slugging class should produce the expected slugs."""
assert tracker.slug(text) == expected