Implement border (sub)title. (#2064)

* Add Widget.border_title and border_subtitle.

Related issues: #1864

* Test setting border_(sub)title.

* Add border (sub)title references to StylesCache.

These internal references will make it easier for the instance of 'StylesCache' to know which border (sub)title to use, if/when needed.

* Add method to render border label.

* Add styles to align border (sub)title.

* Render border labels.

* Update styles template.

* Make new 'render_row' parameters optional.

* Add (sub)title border snapshot tests.

* Document border (sub)title and styles.

* Pass (sub)title directly as arguments.

Get rid of the watchers to make data flow easier to follow.
Related comment: https://github.com/Textualize/textual/pull/2064/files\#r1137746697

* Tweak example.

* Fix render_border_label.

This was wrong because border labels can be composed of multiple segments if they contain multiple styles. Additionally, we want to render a single blank space of padding around the title.

* Ensure we get no label when there's no space.

* Add tests for border label rendering.

* 'render_border_label' now returns iterable of segments.

* Add label to render_row.

* Fix calling signature in tests.

* Add padding to snapshot tests.

* Fix changelog.

* Update snapshot tests.

* Update snapshot tests.

* Border labels expand if there's no corners.

* Update CHANGELOG.md

* Fix docs.

* Remove irrelevant line.

* Fix snapshot tests.

* Don't share Console among tests.

* Simplify example in styles guide.

* Avoid expensive function call when possible.

* rewording

* positive branch first

* remove wasteful indirection

* fix changelog

---------

Co-authored-by: Will McGugan <willmcgugan@gmail.com>
This commit is contained in:
Rodrigo Girão Serrão
2023-03-22 11:07:38 +00:00
committed by GitHub
parent 29692736d0
commit 2a810f8c87
24 changed files with 1370 additions and 67 deletions

View File

@@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Added
- Added `parser_factory` argument to `Markdown` and `MarkdownViewer` constructors https://github.com/Textualize/textual/pull/2075
- Added `HorizontalScroll` https://github.com/Textualize/textual/issues/1957
- Added `Center` https://github.com/Textualize/textual/issues/1957
- Added `Middle` https://github.com/Textualize/textual/issues/1957
- Added `VerticalScroll` (mimicking the old behaviour of `Vertical`) https://github.com/Textualize/textual/issues/1957
- Added `Widget.border_title` and `Widget.border_subtitle` to set border (sub)title for a widget https://github.com/Textualize/textual/issues/1864
- Added CSS styles `border_title_align` and `border_subtitle_align`.
- Added `TabbedContent` widget https://github.com/Textualize/textual/pull/2059
- Added `get_child_by_type` method to widgets / app https://github.com/Textualize/textual/pull/2059
- Added `Widget.render_str` method https://github.com/Textualize/textual/pull/2059
- Added TEXTUAL_DRIVER environment variable
### Changed
@@ -27,18 +37,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Fixed borders not rendering correctly. https://github.com/Textualize/textual/pull/2074
- Fix for error when removing nodes. https://github.com/Textualize/textual/issues/2079
### Added
- Added `HorizontalScroll` https://github.com/Textualize/textual/issues/1957
- Added `Center` https://github.com/Textualize/textual/issues/1957
- Added `Middle` https://github.com/Textualize/textual/issues/1957
- Added `VerticalScroll` (mimicking the old behaviour of `Vertical`) https://github.com/Textualize/textual/issues/1957
- Added `TabbedContent` widget https://github.com/Textualize/textual/pull/2059
- Added `get_child_by_type` method to widgets / app https://github.com/Textualize/textual/pull/2059
- Added `Widget.render_str` method https://github.com/Textualize/textual/pull/2059
- Added TEXTUAL_DRIVER environment variable
## [0.15.1] - 2023-03-14
### Fixed

View File

@@ -1,6 +1,5 @@
from textual.app import App, ComposeResult
from textual.widgets import Static
from textual.widgets import Label
TEXT = """I must not fear.
Fear is the mind-killer.
@@ -13,7 +12,7 @@ Where the fear has gone there will be nothing. Only I will remain."""
class BorderApp(App):
def compose(self) -> ComposeResult:
self.widget = Static(TEXT)
self.widget = Label(TEXT)
yield self.widget
def on_mount(self) -> None:

View File

@@ -0,0 +1,29 @@
from textual.app import App, ComposeResult
from textual.widgets import Static
TEXT = """I must not fear.
Fear is the mind-killer.
Fear is the little-death that brings total obliteration.
I will face my fear.
I will permit it to pass over me and through me.
And when it has gone past, I will turn the inner eye to see its path.
Where the fear has gone there will be nothing. Only I will remain."""
class BorderTitleApp(App[None]):
def compose(self) -> ComposeResult:
self.widget = Static(TEXT)
yield self.widget
def on_mount(self) -> None:
self.widget.styles.background = "darkblue"
self.widget.styles.width = "50%"
self.widget.styles.border = ("heavy", "yellow")
self.widget.border_title = "Litany Against Fear"
self.widget.border_subtitle = "by Frank Herbert, in “Dune”"
self.widget.styles.border_title_align = "center"
if __name__ == "__main__":
app = BorderTitleApp()
app.run()

View File

@@ -0,0 +1,64 @@
Grid {
grid-size: 3 3;
align: center middle;
}
Container {
width: 100%;
height: 100%;
align: center middle;
}
#lbl1 { /* (1)! */
border: vkey $secondary;
}
#lbl2 { /* (2)! */
border: round $secondary;
border-title-align: right;
border-subtitle-align: right;
}
#lbl3 {
border: wide $secondary;
border-title-align: center;
border-subtitle-align: center;
}
#lbl4 {
border: ascii $success;
border-title-align: center; /* (3)! */
border-subtitle-align: left;
}
#lbl5 { /* (4)! */
/* No border = no (sub)title. */
border: none $success;
border-title-align: center;
border-subtitle-align: center;
}
#lbl6 { /* (5)! */
border-top: solid $success;
border-bottom: solid $success;
}
#lbl7 { /* (6)! */
border-top: solid $error;
border-bottom: solid $error;
padding: 1 2;
border-subtitle-align: left;
}
#lbl8 {
border-top: solid $error;
border-bottom: solid $error;
border-title-align: center;
border-subtitle-align: center;
}
#lbl9 {
border-top: solid $error;
border-bottom: solid $error;
border-title-align: right;
}

View File

@@ -0,0 +1,74 @@
from textual.app import App
from textual.containers import Container, Grid
from textual.widgets import Label
def make_label_container( # (11)!
text: str, id: str, border_title: str, border_subtitle: str
) -> Container:
lbl = Label(text, id=id)
lbl.border_title = border_title
lbl.border_subtitle = border_subtitle
return Container(lbl)
class BorderSubTitleAlignAll(App[None]):
def compose(self):
with Grid():
yield make_label_container( # (1)!
"This is the story of",
"lbl1",
"[b]Border [i]title[/i][/]",
"[u][r]Border[/r] subtitle[/]",
)
yield make_label_container( # (2)!
"a Python",
"lbl2",
"[b red]Left, but it's loooooooooooong",
"[reverse]Center, but it's loooooooooooong",
)
yield make_label_container( # (3)!
"developer that",
"lbl3",
"[b i on purple]Left[/]",
"[r u white on black]@@@[/]",
)
yield make_label_container(
"had to fill up",
"lbl4",
"", # (4)!
"[link=https://textual.textualize.io]Left[/]", # (5)!
)
yield make_label_container( # (6)!
"nine labels", "lbl5", "Title", "Subtitle"
)
yield make_label_container( # (7)!
"and ended up redoing it",
"lbl6",
"Title",
"Subtitle",
)
yield make_label_container( # (8)!
"because the first try",
"lbl7",
"Title, but really loooooooooong!",
"Subtitle, but really loooooooooong!",
)
yield make_label_container( # (9)!
"had some labels",
"lbl8",
"Title, but really loooooooooong!",
"Subtitle, but really loooooooooong!",
)
yield make_label_container( # (10)!
"that were too long.",
"lbl9",
"Title, but really loooooooooong!",
"Subtitle, but really loooooooooong!",
)
app = BorderSubTitleAlignAll(css_path="border_sub_title_align_all.css")
if __name__ == "__main__":
app.run()

View File

@@ -0,0 +1,23 @@
#label1 {
border: solid $secondary;
border-subtitle-align: left;
}
#label2 {
border: dashed $secondary;
border-subtitle-align: center;
}
#label3 {
border: tall $secondary;
border-subtitle-align: right;
}
Screen > Label {
width: 100%;
height: 5;
content-align: center middle;
color: white;
margin: 1;
box-sizing: border-box;
}

View File

@@ -0,0 +1,20 @@
from textual.app import App
from textual.widgets import Label
class BorderSubtitleAlignApp(App):
def compose(self):
lbl = Label("My subtitle is on the left.", id="label1")
lbl.border_subtitle = "< Left"
yield lbl
lbl = Label("My subtitle is centered", id="label2")
lbl.border_subtitle = "Centered!"
yield lbl
lbl = Label("My subtitle is on the right", id="label3")
lbl.border_subtitle = "Right >"
yield lbl
app = BorderSubtitleAlignApp(css_path="border_subtitle_align.css")

View File

@@ -0,0 +1,23 @@
#label1 {
border: solid $secondary;
border-title-align: left;
}
#label2 {
border: dashed $secondary;
border-title-align: center;
}
#label3 {
border: tall $secondary;
border-title-align: right;
}
Screen > Label {
width: 100%;
height: 5;
content-align: center middle;
color: white;
margin: 1;
box-sizing: border-box;
}

View File

@@ -0,0 +1,20 @@
from textual.app import App
from textual.widgets import Label
class BorderTitleAlignApp(App):
def compose(self):
lbl = Label("My title is on the left.", id="label1")
lbl.border_title = "< Left"
yield lbl
lbl = Label("My title is centered", id="label2")
lbl.border_title = "Centered!"
yield lbl
lbl = Label("My title is on the right", id="label3")
lbl.border_title = "Right >"
yield lbl
app = BorderTitleAlignApp(css_path="border_title_align.css")

View File

@@ -257,6 +257,28 @@ There are many other border types. Run the following from the command prompt to
textual borders
```
#### Title alignment
Widgets have two attributes, `border_title` and `border_subtitle` which (if set) will be displayed within the border.
The `border_title` attribute is displayed in the top border, and `border_subtitle` is displayed in the bottom border.
There are two styles to set the alignment of these border labels, which may be set to "left", "right", or "center".
- [`border-title-align`](../styles/border_title_align.md) sets the alignment of the title, which defaults to "left".
- [`border-subtitle-align`](../styles/border_subtitle_align.md) sets the alignment of the subtitle, which defaults to "right".
The following example sets both titles and changes the alignment of the title (top) to "center".
```py hl_lines="22-24"
--8<-- "docs/examples/guide/styles/border_title.py"
```
Note the addition of the titles and their alignments:
```{.textual path="docs/examples/guide/styles/border_title.py"}
```
### Outline
[Outline](../styles/outline.md) is similar to border and is set in the same way. The difference is that outline will not change the size of the widget, and may overlap the content area. The following example sets an outline on a widget:

View File

@@ -19,23 +19,7 @@ rule-name: <a href="../../css_types/type_one">&lt;type-one&gt;</a>;
### Values
<!--
If this rule only needs one type, include it directly:
--8<-- "docs/snippets/type_syntax/only_type.md"
-->
<!--
If this rule needs two or more types:
### &lt;first-type&gt;
--8<-- "docs/snippets/type_syntax/first_type.md"
### &lt;second-type&gt;
--8<-- "docs/snippets/type_syntax/second_type.md"
...
For enum-like styles that don't warrant a dedicated type.
-->
### Defaults
@@ -120,12 +104,12 @@ Copy the same examples as the ones shown in the CSS above.
If the programmatic way of setting the rule differs significantly from the CSS way, make note of that here.
```py
rule_name = value1
rule_name = value2
rule_name = (different_syntax_value, shown_here)
widget.styles.rule_name = value1
widget.styles.rule_name = value2
widget.styles.rule_name = (different_syntax_value, shown_here)
rule_name_variant = value3
rule_name_variant = value4
widget.styles.rule_name_variant = value3
widget.styles.rule_name_variant = value4
```
-->

View File

@@ -0,0 +1,62 @@
# Border-subtitle-align
The `border-subtitle-align` sets the horizontal alignment for the border subtitle.
## Syntax
--8<-- "docs/snippets/syntax_block_start.md"
border-subtitle-align: <a href="../../css_types/horizontal">&lt;horizontal&gt;</a>;
--8<-- "docs/snippets/syntax_block_end.md"
The style `border-subtitle-align` takes a [`<horizontal>`](../../css_types/horizontal) that determines where the border subtitle is aligned along the top edge of the border.
This means that the border corners are always visible.
### Default
The default alignment is `right`.
## Examples
### Basic usage
This example shows three labels, each with a different border subtitle alignment:
=== "Output"
```{.textual path="docs/examples/styles/border_subtitle_align.py"}
```
=== "border_subtitle_align.py"
```py
--8<-- "docs/examples/styles/border_subtitle_align.py"
```
=== "border_subtitle_align.css"
```sass
--8<-- "docs/examples/styles/border_subtitle_align.css"
```
### All title and subtitle combinations
--8<-- "docs/snippets/border_sub_title_align_all_example.md"
## CSS
```sass
border-subtitle-align: left;
border-subtitle-align: center;
border-subtitle-align: right;
```
## Python
```py
widget.styles.border_subtitle_align = "left"
widget.styles.border_subtitle_align = "center"
widget.styles.border_subtitle_align = "right"
```

View File

@@ -0,0 +1,62 @@
# Border-title-align
The `border-title-align` sets the horizontal alignment for the border title.
## Syntax
--8<-- "docs/snippets/syntax_block_start.md"
border-title-align: <a href="../../css_types/horizontal">&lt;horizontal&gt;</a>;
--8<-- "docs/snippets/syntax_block_end.md"
The style `border-title-align` takes a [`<horizontal>`](../../css_types/horizontal) that determines where the border title is aligned along the top edge of the border.
This means that the border corners are always visible.
### Default
The default alignment is `left`.
## Examples
### Basic usage
This example shows three labels, each with a different border title alignment:
=== "Output"
```{.textual path="docs/examples/styles/border_title_align.py"}
```
=== "border_title_align.py"
```py
--8<-- "docs/examples/styles/border_title_align.py"
```
=== "border_title_align.css"
```sass
--8<-- "docs/examples/styles/border_title_align.css"
```
### All title and subtitle combinations
--8<-- "docs/snippets/border_sub_title_align_all_example.md"
## CSS
```sass
border-title-align: left;
border-title-align: center;
border-title-align: right;
```
## Python
```py
widget.styles.border_title_align = "left"
widget.styles.border_title_align = "center"
widget.styles.border_title_align = "right"
```

View File

@@ -60,7 +60,7 @@ The following example adds a `Tabs` widget above a text label. Press ++a++ to ad
## Messages
### ::: textual.widgets.Tabs.TabActivated
### ::: textual.widgets.Tabs.TabsCleared
### ::: textual.widgets.Tabs.Cleared
## Bindings

View File

@@ -67,6 +67,8 @@ nav:
- "styles/align.md"
- "styles/background.md"
- "styles/border.md"
- "styles/border_subtitle_align.md"
- "styles/border_title_align.md"
- "styles/box_sizing.md"
- "styles/color.md"
- "styles/content_align.md"

View File

@@ -1,13 +1,15 @@
from __future__ import annotations
from functools import lru_cache
from typing import TYPE_CHECKING, Tuple, Union, cast
from typing import TYPE_CHECKING, Iterable, Tuple, cast
from rich.console import Console
from rich.segment import Segment
from rich.style import Style
from rich.text import Text
from .color import Color
from .css.types import EdgeStyle, EdgeType
from .css.types import AlignHorizontal, EdgeStyle, EdgeType
if TYPE_CHECKING:
from typing_extensions import TypeAlias
@@ -15,6 +17,8 @@ if TYPE_CHECKING:
INNER = 1
OUTER = 2
_EMPTY_SEGMENT = Segment("", Style())
BORDER_CHARS: dict[
EdgeType, tuple[tuple[str, str, str], tuple[str, str, str], tuple[str, str, str]]
] = {
@@ -103,7 +107,8 @@ BORDER_CHARS: dict[
}
# Some of the borders are on the widget background and some are on the background of the parent
# This table selects which for each character, 0 indicates the widget, 1 selects the parent
# This table selects which for each character, 0 indicates the widget, 1 selects the parent.
# 2 and 3 reverse a cross-combination of the background and foreground colors of 0 and 1.
BORDER_LOCATIONS: dict[
EdgeType, tuple[tuple[int, int, int], tuple[int, int, int], tuple[int, int, int]]
] = {
@@ -189,6 +194,14 @@ BORDER_LOCATIONS: dict[
),
}
# In a similar fashion, we extract the border _label_ locations for easier access when
# rendering a border label.
# The values are a pair with (title location, subtitle location).
BORDER_LABEL_LOCATIONS: dict[EdgeType, tuple[int, int]] = {
edge_type: (locations[0][1], locations[2][1])
for edge_type, locations in BORDER_LOCATIONS.items()
}
INVISIBLE_EDGE_TYPES = cast("frozenset[EdgeType]", frozenset(("", "none", "hidden")))
BorderValue: TypeAlias = Tuple[EdgeType, Color]
@@ -261,29 +274,141 @@ def get_box(
)
def render_border_label(
label: str,
is_title: bool,
name: EdgeType,
width: int,
inner_style: Style,
outer_style: Style,
style: Style,
console: Console,
has_left_corner: bool,
has_right_corner: bool,
) -> Iterable[Segment]:
"""Render a border label (the title or subtitle) with optional markup.
The styling that may be embedded in the label will be reapplied after taking into
account the inner, outer, and border-specific, styles.
Args:
label: The label to display (that may contain markup).
is_title: Whether we are rendering the title (`True`) or the subtitle (`False`).
name: Name of the box type.
width: The width, in cells, of the space available for the whole edge.
This is the total space that may also be needed for the border corners and
the whitespace padding around the (sub)title. Thus, the effective space
available for the border label is:
- `width` if no corner is needed;
- `width - 2` if one corner is needed; and
- `width - 4` if both corners are needed.
inner_style: The inner style (widget background).
outer_style: The outer style (parent background).
style: Widget style.
console: The console that will render the markup in the label.
has_left_corner: Whether the border edge will have to render a left corner.
has_right_corner: Whether the border edge will have to render a right corner.
Returns:
A list of segments that represent the full label and surrounding padding.
"""
# How many cells do we need to reserve for surrounding blanks and corners?
corners_needed = has_left_corner + has_right_corner
cells_reserved = 2 * corners_needed
if not label or width <= cells_reserved:
return
text_label = Text.from_markup(label)
text_label.truncate(width - cells_reserved, overflow="ellipsis")
segments = text_label.render(console)
label_style_location = BORDER_LABEL_LOCATIONS[name][0 if is_title else 1]
inner = inner_style + style
outer = outer_style + style
base_style: Style
if label_style_location == 0:
base_style = inner
elif label_style_location == 1:
base_style = outer
elif label_style_location == 2:
base_style = Style.from_color(outer.bgcolor, inner.color)
elif label_style_location == 3:
base_style = Style.from_color(inner.bgcolor, outer.color)
else:
assert False
styled_segments = [
Segment(segment.text, base_style + segment.style) for segment in segments
]
blank = Segment(" ", base_style)
if has_left_corner:
yield blank
yield from styled_segments
if has_right_corner:
yield blank
def render_row(
box_row: tuple[Segment, Segment, Segment], width: int, left: bool, right: bool
) -> list[Segment]:
"""Render a top, or bottom border row.
box_row: tuple[Segment, Segment, Segment],
width: int,
left: bool,
right: bool,
label_segments: Iterable[Segment],
label_alignment: AlignHorizontal = "left",
) -> Iterable[Segment]:
"""Compose a box row with its padded label.
This is the function that actually does the work that `render_row` is intended
to do, but we have many lists of segments flowing around, so it becomes easier
to yield the segments bit by bit, and the aggregate everything into a list later.
Args:
box_row: Corners and side segments.
width: Total width of resulting line.
left: Render left corner.
right: Render right corner.
label_segments: The segments that make up the label.
label_alignment: Where to horizontally align the label.
Returns:
A list of segments.
An iterable of segments.
"""
box1, box2, box3 = box_row
if left and right:
return [box1, Segment(box2.text * (width - 2), box2.style), box3]
corners_needed = left + right
label_segments_list = list(label_segments)
label_length = sum((segment.cell_length for segment in label_segments_list), 0)
space_available = max(0, width - corners_needed - label_length)
if left:
return [box1, Segment(box2.text * (width - 1), box2.style)]
if right:
return [Segment(box2.text * (width - 1), box2.style), box3]
yield box1
if not space_available:
yield from label_segments_list
elif not label_length:
yield Segment(box2.text * space_available, box2.style)
elif label_alignment == "left" or label_alignment == "right":
edge = Segment(box2.text * space_available, box2.style)
if label_alignment == "left":
yield from label_segments_list
yield edge
else:
yield edge
yield from label_segments_list
elif label_alignment == "center":
length_on_left = space_available // 2
length_on_right = space_available - length_on_left
yield Segment(box2.text * length_on_left, box2.style)
yield from label_segments_list
yield Segment(box2.text * length_on_right, box2.style)
else:
return [Segment(box2.text * width, box2.style)]
assert False
if right:
yield box3
_edge_type_normalization_table: dict[EdgeType, EdgeType] = {

View File

@@ -4,10 +4,11 @@ from functools import lru_cache
from sys import intern
from typing import TYPE_CHECKING, Callable, Iterable
from rich.console import Console
from rich.segment import Segment
from rich.style import Style
from ._border import get_box, render_row
from ._border import get_box, render_border_label, render_row
from ._opacity import _apply_opacity
from ._segment_tools import line_pad, line_trim
from .color import Color
@@ -113,6 +114,9 @@ class StylesCache:
base_background,
background,
widget.render_line,
widget.app.console,
widget.border_title,
widget.border_subtitle,
content_size=widget.content_region.size,
padding=styles.padding,
crop=crop,
@@ -141,6 +145,9 @@ class StylesCache:
base_background: Color,
background: Color,
render_content_line: RenderLineCallback,
console: Console,
border_title: str,
border_subtitle: str,
content_size: Size | None = None,
padding: Spacing | None = None,
crop: Region | None = None,
@@ -154,9 +161,13 @@ class StylesCache:
base_background: Background color beneath widget.
background: Background color of widget.
render_content_line: Callback to render content line.
content_size: Size of content or None to assume full size. Defaults to None.
padding: Override padding from Styles, or None to use styles.padding. Defaults to None.
crop: Region to crop to. Defaults to None.
console: The console in use by the app.
border_title: The title for the widget border.
border_subtitle: The subtitle for the widget border.
content_size: Size of content or None to assume full size.
padding: Override padding from Styles, or None to use styles.padding.
crop: Region to crop to.
filter: Additional post-processing for the segments.
Returns:
Rendered lines.
@@ -188,6 +199,9 @@ class StylesCache:
base_background,
background,
render_content_line,
console,
border_title,
border_subtitle,
)
self._cache[y] = strip
else:
@@ -213,6 +227,9 @@ class StylesCache:
base_background: Color,
background: Color,
render_content_line: Callable[[int], Strip],
console: Console,
border_title: str,
border_subtitle: str,
) -> Strip:
"""Render a styled line.
@@ -225,6 +242,9 @@ class StylesCache:
base_background: Background color of widget beneath this line.
background: Background color of widget.
render_content_line: Callback to render a line of content.
console: The console in use by the app.
border_title: The title for the widget border.
border_subtitle: The subtitle for the widget border.
Returns:
A line of segments.
@@ -275,20 +295,47 @@ class StylesCache:
line: Iterable[Segment]
# Draw top or bottom borders (A)
if (border_top and y == 0) or (border_bottom and y == height - 1):
is_top = y == 0
border_color = base_background + (
border_top_color if y == 0 else border_bottom_color
border_top_color if is_top else border_bottom_color
)
border_color_as_style = from_color(color=border_color.rich_color)
border_edge_type = border_top if is_top else border_bottom
has_left = border_left != ""
has_right = border_right != ""
border_label = border_title if is_top else border_subtitle
# Try to save time with expensive call to `render_border_label`:
if border_label:
label_segments = render_border_label(
border_label,
is_top,
border_edge_type,
width,
inner,
outer,
border_color_as_style,
console,
has_left,
has_right,
)
else:
label_segments = []
box_segments = get_box(
border_top if y == 0 else border_bottom,
border_edge_type,
inner,
outer,
from_color(color=border_color.rich_color),
border_color_as_style,
)
label_alignment = (
styles.border_title_align if is_top else styles.border_subtitle_align
)
line = render_row(
box_segments[0 if y == 0 else 2],
box_segments[0 if is_top else 2],
width,
border_left != "",
border_right != "",
has_left,
has_right,
label_segments,
label_alignment,
)
# Draw padding (B)
@@ -353,6 +400,7 @@ class StylesCache:
width,
outline_left != "",
outline_right != "",
(),
)
elif outline_left or outline_right:

View File

@@ -812,6 +812,9 @@ class StylesBuilder:
process_content_align_horizontal = process_align_horizontal
process_content_align_vertical = process_align_vertical
process_border_title_align = process_align_horizontal
process_border_subtitle_align = process_align_horizontal
def process_scrollbar_gutter(self, name: str, tokens: list[Token]) -> None:
try:
value = self._process_enum(name, tokens, VALID_SCROLLBAR_GUTTER)

View File

@@ -96,6 +96,9 @@ class RulesMap(TypedDict, total=False):
border_bottom: tuple[str, Color]
border_left: tuple[str, Color]
border_title_align: AlignHorizontal
border_subtitle_align: AlignHorizontal
outline_top: tuple[str, Color]
outline_right: tuple[str, Color]
outline_bottom: tuple[str, Color]
@@ -235,6 +238,9 @@ class StylesBase(ABC):
border_bottom = BoxProperty(Color(0, 255, 0))
border_left = BoxProperty(Color(0, 255, 0))
border_title_align = StringEnumProperty(VALID_ALIGN_HORIZONTAL, "left")
border_subtitle_align = StringEnumProperty(VALID_ALIGN_HORIZONTAL, "right")
outline = BorderProperty(layout=False)
outline_top = BoxProperty(Color(0, 255, 0))
outline_right = BoxProperty(Color(0, 255, 0))
@@ -918,6 +924,11 @@ class Styles(StylesBase):
if "text_align" in rules:
append_declaration("text-align", self.text_align)
if "border_title_align" in rules:
append_declaration("border-title-align", self.border_title_align)
if "border_subtitle_align" in rules:
append_declaration("border-subtitle-align", self.border_subtitle_align)
if "opacity" in rules:
append_declaration("opacity", str(self.opacity))
if "text_opacity" in rules:

View File

@@ -232,6 +232,10 @@ class Widget(DOMNode):
"""Widget will highlight links automatically."""
disabled = Reactive(False)
"""The disabled state of the widget. `True` if disabled, `False` if not."""
border_title = Reactive("")
"""The one-line border title, which may contain markup to be parsed."""
border_subtitle = Reactive("")
"""The one-line border subtitle, which may contain markup to be parsed."""
hover_style: Reactive[Style] = Reactive(Style, repaint=False)
highlight_link_id: Reactive[str] = Reactive("")
@@ -2390,6 +2394,20 @@ class Widget(DOMNode):
"""Update the styles of the widget and its children when disabled is toggled."""
self._update_styles()
def validate_border_title(self, title: str) -> str:
"""Ensure we only use a single line for the border title."""
if not title:
return title
first, *_ = title.splitlines()
return first
def validate_border_subtitle(self, subtitle: str) -> str:
"""Ensure we only use a single line for the border subtitle."""
if not subtitle:
return subtitle
first, *_ = subtitle.splitlines()
return first
def _size_updated(
self, size: Size, virtual_size: Size, container_size: Size, layout: bool = True
) -> bool:

File diff suppressed because one or more lines are too long

View File

@@ -1,24 +1,231 @@
import pytest
from rich.console import Console
from rich.segment import Segment
from rich.style import Style
from textual._border import render_row
from textual._border import render_border_label, render_row
from textual.widget import Widget
_EMPTY_STYLE = Style()
_BLANK_SEGMENT = Segment(" ", _EMPTY_STYLE)
_WIDE_CONSOLE = Console(width=9999)
def test_border_render_row():
style = Style.parse("red")
row = (Segment("", style), Segment("", style), Segment("", style))
assert render_row(row, 5, False, False) == [Segment(row[1].text * 5, row[1].style)]
assert render_row(row, 5, True, False) == [
assert list(render_row(row, 5, False, False, ())) == [
Segment(row[1].text * 5, row[1].style)
]
assert list(render_row(row, 5, True, False, ())) == [
row[0],
Segment(row[1].text * 4, row[1].style),
]
assert render_row(row, 5, False, True) == [
assert list(render_row(row, 5, False, True, ())) == [
Segment(row[1].text * 4, row[1].style),
row[2],
]
assert render_row(row, 5, True, True) == [
assert list(render_row(row, 5, True, True, ())) == [
row[0],
Segment(row[1].text * 3, row[1].style),
row[2],
]
def test_border_title_single_line():
"""The border_title gets set to a single line even when multiple lines are provided."""
widget = Widget()
widget.border_title = ""
assert widget.border_title == ""
widget.border_title = "How is life\ngoing for you?"
assert widget.border_title == "How is life"
widget.border_title = "How is life\n\rgoing for you?"
assert widget.border_title == "How is life"
widget.border_title = "Sorry you \r\n have to \n read this."
assert widget.border_title == "Sorry you "
widget.border_title = "[red]This also \n works with markup \n involved.[/]"
assert widget.border_title == "[red]This also "
def test_border_subtitle_single_line():
"""The border_subtitle gets set to a single line even when multiple lines are provided."""
widget = Widget()
widget.border_subtitle = ""
assert widget.border_subtitle == ""
widget.border_subtitle = "How is life\ngoing for you?"
assert widget.border_subtitle == "How is life"
widget.border_subtitle = "How is life\n\rgoing for you?"
assert widget.border_subtitle == "How is life"
widget.border_subtitle = "Sorry you \r\n have to \n read this."
assert widget.border_subtitle == "Sorry you "
widget.border_subtitle = "[red]This also \n works with markup \n involved.[/]"
assert widget.border_subtitle == "[red]This also "
@pytest.mark.parametrize(
["width", "has_left_corner", "has_right_corner"],
[
(10, True, True),
(10, True, False),
(10, False, False),
(10, False, True),
(1, True, True),
(1, True, False),
(1, False, False),
(1, False, True),
],
)
def test_render_border_label_empty_label_skipped(
width: int, has_left_corner: bool, has_right_corner: bool
):
"""Test that we get an empty list of segments if there is no label to display."""
assert [] == list(
render_border_label(
"",
True,
"round",
width,
_EMPTY_STYLE,
_EMPTY_STYLE,
_EMPTY_STYLE,
_WIDE_CONSOLE,
has_left_corner,
has_right_corner,
)
)
@pytest.mark.parametrize(
["label", "width", "has_left_corner", "has_right_corner"],
[
("hey", 2, True, True),
("hey", 2, True, False),
("hey", 2, False, True),
("hey", 3, True, True),
("hey", 4, True, True),
],
)
def test_render_border_label_skipped_if_narrow(
label: str, width: int, has_left_corner: bool, has_right_corner: bool
):
"""Test that we skip rendering a label when we do not have space for it.
In order for us to have enough space for the label, we need to have space for the
corners that we need (none, just one, or both) and we need to be able to have two
blank spaces around the label (one on each side).
If we don't have space for all of these, we skip the label altogether.
"""
assert [] == list(
render_border_label(
label,
True,
"round",
width,
_EMPTY_STYLE,
_EMPTY_STYLE,
_EMPTY_STYLE,
_WIDE_CONSOLE,
has_left_corner,
has_right_corner,
)
)
@pytest.mark.parametrize(
"label",
[
"Why did the scarecrow",
"win a Nobel prize?",
"because it was outstanding",
"in its field.",
],
)
def test_render_border_label_wide_plain(label: str):
"""Test label rendering in a wide area with no styling."""
BIG_NUM = 9999
args = (
True,
"round",
BIG_NUM,
_EMPTY_STYLE,
_EMPTY_STYLE,
_EMPTY_STYLE,
_WIDE_CONSOLE,
True,
True,
)
left, original_text, right = render_border_label(label, *args)
assert left == _BLANK_SEGMENT
assert right == _BLANK_SEGMENT
assert original_text == Segment(label, _EMPTY_STYLE)
def test_render_border_label():
"""Test label rendering with styling, with and without overflow."""
label = "[b][on red]What [i]is up[/on red] with you?[/]"
border_style = Style.parse("green on blue")
# Implicit test on the number of segments returned:
blank1, what, is_up, with_you, blank2 = render_border_label(
label,
True,
"round",
9999,
_EMPTY_STYLE,
_EMPTY_STYLE,
border_style,
_WIDE_CONSOLE,
True,
True,
)
expected_blank = Segment(" ", border_style)
assert blank1 == expected_blank
assert blank2 == expected_blank
what_style = Style.parse("b on red")
expected_what = Segment("What ", border_style + what_style)
assert what == expected_what
is_up_style = Style.parse("b on red i")
expected_is_up = Segment("is up", border_style + is_up_style)
assert is_up == expected_is_up
with_you_style = Style.parse("b i")
expected_with_you = Segment(" with you?", border_style + with_you_style)
assert with_you == expected_with_you
blank1, what, blank2 = render_border_label(
label,
True,
"round",
5 + 4, # 5 where "What…" fits + 2 for the blank spaces + 2 for the corners.
_EMPTY_STYLE,
_EMPTY_STYLE,
border_style,
_WIDE_CONSOLE,
True, # This corner costs 2 cells.
True, # This corner costs 2 cells.
)
assert blank1 == expected_blank
assert blank2 == expected_blank
expected_what = Segment("What…", border_style + what_style)
assert what == expected_what

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
from rich.console import Console
from rich.segment import Segment
from rich.style import Style
@@ -40,6 +41,9 @@ def test_no_styles():
Color.parse("blue"),
Color.parse("green"),
content.__getitem__,
Console(),
"",
"",
content_size=Size(3, 3),
)
style = Style.from_color(bgcolor=Color.parse("green").rich_color)
@@ -67,6 +71,9 @@ def test_border():
Color.parse("blue"),
Color.parse("green"),
content.__getitem__,
Console(),
"",
"",
content_size=Size(3, 3),
)
@@ -98,6 +105,9 @@ def test_padding():
Color.parse("blue"),
Color.parse("green"),
content.__getitem__,
Console(),
"",
"",
content_size=Size(3, 3),
)
@@ -130,6 +140,9 @@ def test_padding_border():
Color.parse("blue"),
Color.parse("green"),
content.__getitem__,
Console(),
"",
"",
content_size=Size(3, 3),
)
@@ -163,6 +176,9 @@ def test_outline():
Color.parse("blue"),
Color.parse("green"),
content.__getitem__,
Console(),
"",
"",
content_size=Size(3, 3),
)
@@ -191,6 +207,9 @@ def test_crop():
Color.parse("blue"),
Color.parse("green"),
content.__getitem__,
Console(),
"",
"",
content_size=Size(3, 3),
crop=Region(2, 2, 3, 3),
)
@@ -227,7 +246,10 @@ def test_dirty_cache() -> None:
Color.parse("blue"),
Color.parse("green"),
get_content_line,
Size(3, 3),
Console(),
"",
"",
content_size=Size(3, 3),
)
assert rendered_lines == [0, 1, 2]
del rendered_lines[:]
@@ -252,6 +274,9 @@ def test_dirty_cache() -> None:
Color.parse("blue"),
Color.parse("green"),
get_content_line,
Console(),
"",
"",
content_size=Size(3, 3),
)
assert rendered_lines == []
@@ -268,6 +293,9 @@ def test_dirty_cache() -> None:
Color.parse("blue"),
Color.parse("green"),
get_content_line,
Console(),
"",
"",
content_size=Size(3, 3),
)
assert rendered_lines == [0, 1]