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:
TomJGooding
2023-09-04 17:57:10 +01:00
committed by GitHub
parent cbed79c7eb
commit 06b6426750
15 changed files with 747 additions and 0 deletions

View File

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

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

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

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