mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into M-x
This commit is contained in:
@@ -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
|
||||
|
||||
42
docs/examples/guide/screens/modes01.py
Normal file
42
docs/examples/guide/screens/modes01.py
Normal 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()
|
||||
27
docs/examples/widgets/horizontal_rules.py
Normal file
27
docs/examples/widgets/horizontal_rules.py
Normal 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()
|
||||
13
docs/examples/widgets/horizontal_rules.tcss
Normal file
13
docs/examples/widgets/horizontal_rules.tcss
Normal file
@@ -0,0 +1,13 @@
|
||||
Screen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
Vertical {
|
||||
height: auto;
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
Label {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
27
docs/examples/widgets/vertical_rules.py
Normal file
27
docs/examples/widgets/vertical_rules.py
Normal 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()
|
||||
14
docs/examples/widgets/vertical_rules.tcss
Normal file
14
docs/examples/widgets/vertical_rules.tcss
Normal file
@@ -0,0 +1,14 @@
|
||||
Screen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
Horizontal {
|
||||
width: auto;
|
||||
height: 80%;
|
||||
}
|
||||
|
||||
Label {
|
||||
width: 6;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
16
docs/images/screens/modes1.excalidraw.svg
Normal file
16
docs/images/screens/modes1.excalidraw.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 11 KiB |
16
docs/images/screens/modes2.excalidraw.svg
Normal file
16
docs/images/screens/modes2.excalidraw.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 11 KiB |
@@ -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
75
docs/widgets/rule.md
Normal 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
116
src/textual/_slug.py
Normal 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
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
217
src/textual/widgets/_rule.py
Normal file
217
src/textual/widgets/_rule.py
Normal 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,
|
||||
)
|
||||
13
src/textual/widgets/rule.py
Normal file
13
src/textual/widgets/rule.py
Normal 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
@@ -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
26
tests/test_rule.py
Normal 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
62
tests/test_slug.py
Normal 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
|
||||
Reference in New Issue
Block a user