mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
feat: add rule widget (#3209)
* feat: add rule widget * add star to init Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> * remove unnecessary validations * update rule styles * add tests for invalid rules * add minimum heights and widths * tidy up examples * remove old example * move examples styling to tcss * modify examples to fit docs screenshots * add docs first draft * add snapshot tests * add rule to widget gallery * make non-widget rule classes available * tentatively update changelog --------- Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> Co-authored-by: Will McGugan <willmcgugan@gmail.com>
This commit is contained in:
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
- TCSS styles `layer` and `layers` can be strings https://github.com/Textualize/textual/pull/3169
|
||||
- `App.return_code` for the app return code https://github.com/Textualize/textual/pull/3202
|
||||
- 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
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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!"
|
||||
Reference in New Issue
Block a user