diff --git a/CHANGELOG.md b/CHANGELOG.md index f45fb94d63..fd2533a91c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/examples/guide/styles/border01.py b/docs/examples/guide/styles/border01.py index 6cc5aada5f..05cf1d7ca5 100644 --- a/docs/examples/guide/styles/border01.py +++ b/docs/examples/guide/styles/border01.py @@ -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 @@ class BorderApp(App): def compose(self) -> ComposeResult: - self.widget = Static(TEXT) + self.widget = Label(TEXT) yield self.widget def on_mount(self) -> None: diff --git a/docs/examples/guide/styles/border_title.py b/docs/examples/guide/styles/border_title.py new file mode 100644 index 0000000000..8001e0dbfc --- /dev/null +++ b/docs/examples/guide/styles/border_title.py @@ -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() diff --git a/docs/examples/styles/border_sub_title_align_all.css b/docs/examples/styles/border_sub_title_align_all.css new file mode 100644 index 0000000000..973a50a0fb --- /dev/null +++ b/docs/examples/styles/border_sub_title_align_all.css @@ -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; +} diff --git a/docs/examples/styles/border_sub_title_align_all.py b/docs/examples/styles/border_sub_title_align_all.py new file mode 100644 index 0000000000..f3c6a1cc97 --- /dev/null +++ b/docs/examples/styles/border_sub_title_align_all.py @@ -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() diff --git a/docs/examples/styles/border_subtitle_align.css b/docs/examples/styles/border_subtitle_align.css new file mode 100644 index 0000000000..334e4eb1db --- /dev/null +++ b/docs/examples/styles/border_subtitle_align.css @@ -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; +} diff --git a/docs/examples/styles/border_subtitle_align.py b/docs/examples/styles/border_subtitle_align.py new file mode 100644 index 0000000000..9c48a78aca --- /dev/null +++ b/docs/examples/styles/border_subtitle_align.py @@ -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") diff --git a/docs/examples/styles/border_title_align.css b/docs/examples/styles/border_title_align.css new file mode 100644 index 0000000000..dd6b830893 --- /dev/null +++ b/docs/examples/styles/border_title_align.css @@ -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; +} diff --git a/docs/examples/styles/border_title_align.py b/docs/examples/styles/border_title_align.py new file mode 100644 index 0000000000..674a65ec33 --- /dev/null +++ b/docs/examples/styles/border_title_align.py @@ -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") diff --git a/docs/guide/styles.md b/docs/guide/styles.md index 4362286380..204b6c6cbb 100644 --- a/docs/guide/styles.md +++ b/docs/guide/styles.md @@ -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: diff --git a/docs/snippets/border_sub_title_align_all_example.md b/docs/snippets/border_sub_title_align_all_example.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/styles/_template.md b/docs/styles/_template.md index 325fb34328..e83bd55a0b 100644 --- a/docs/styles/_template.md +++ b/docs/styles/_template.md @@ -19,23 +19,7 @@ rule-name: <type-one>; ### Values - - ### 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 ``` --> diff --git a/docs/styles/border_subtitle_align.md b/docs/styles/border_subtitle_align.md new file mode 100644 index 0000000000..3aeb7c9749 --- /dev/null +++ b/docs/styles/border_subtitle_align.md @@ -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: <horizontal>; +--8<-- "docs/snippets/syntax_block_end.md" + +The style `border-subtitle-align` takes a [``](../../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" +``` diff --git a/docs/styles/border_title_align.md b/docs/styles/border_title_align.md new file mode 100644 index 0000000000..8a7608d63c --- /dev/null +++ b/docs/styles/border_title_align.md @@ -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: <horizontal>; +--8<-- "docs/snippets/syntax_block_end.md" + +The style `border-title-align` takes a [``](../../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" +``` diff --git a/docs/widgets/tabs.md b/docs/widgets/tabs.md index 5546e5dbd9..be2b1428a8 100644 --- a/docs/widgets/tabs.md +++ b/docs/widgets/tabs.md @@ -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 diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml index 704bd75e2a..c508c36197 100644 --- a/mkdocs-nav.yml +++ b/mkdocs-nav.yml @@ -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" diff --git a/src/textual/_border.py b/src/textual/_border.py index 1a0c10ea08..5c723882bb 100644 --- a/src/textual/_border.py +++ b/src/textual/_border.py @@ -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 @@ 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 @@ } # 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 @@ ), } +# 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] = { diff --git a/src/textual/_styles_cache.py b/src/textual/_styles_cache.py index 3cb4aebeb9..97216abf04 100644 --- a/src/textual/_styles_cache.py +++ b/src/textual/_styles_cache.py @@ -4,10 +4,11 @@ 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 @@ def render_widget(self, widget: Widget, crop: Region) -> list[Strip]: 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 @@ def render( 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 @@ def render( 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 @@ def render( base_background, background, render_content_line, + console, + border_title, + border_subtitle, ) self._cache[y] = strip else: @@ -213,6 +227,9 @@ def render_line( 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 @@ def render_line( 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 @@ def post(segments: Iterable[Segment]) -> Iterable[Segment]: 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 @@ def post(segments: Iterable[Segment]) -> Iterable[Segment]: width, outline_left != "", outline_right != "", + (), ) elif outline_left or outline_right: diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index b98cd8c040..ef98fb77e8 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -812,6 +812,9 @@ def process_align_vertical(self, name: str, tokens: list[Token]) -> None: 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) diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index a9687f9232..087cc3211e 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -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 @@ def append_declaration(name: str, value: str) -> None: 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: diff --git a/src/textual/widget.py b/src/textual/widget.py index 6ac329d6d1..f64144c7d5 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -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 @@ def watch_disabled(self) -> None: """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: diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 326be7e82d..6aa03f848d 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -2936,6 +2936,487 @@ ''' # --- +# name: test_css_property[border_sub_title_align_all.py] + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BorderSubTitleAlignAll + + + + + + + + + + + + Border titleLeft,…▁▁▁▁Left▁▁▁▁ + This is the story ofa Pythondeveloper that + Border subtitleCente…▔▔▔▔@@@▔▔▔▔▔ + + + + + + +--------------+Title────────────────── + |had to fill up|nine labelsand ended up redoing it + +Left--------+───────────────Subtitle + + + + + Title, but really looooo… + Title, but rea…Title, but really … + because the first tryhad some labelsthat were too long. + Subtitle, but …Subtitle, but real… + Subtitle, but really loo… + + + + + + + ''' +# --- +# name: test_css_property[border_subtitle_align.py] + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BorderSubtitleAlignApp + + + + + + + + + + + ──────────────────────────────────────────────────────────────────────────── + + My subtitle is on the left. + + < Left──────────────────────────────────────────────────────────────────── + + ╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍ + + My subtitle is centered + + ╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍Centered!╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + + My subtitle is on the right + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁Right > + + + + + + + + + + + ''' +# --- +# name: test_css_property[border_title_align.py] + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + BorderTitleAlignApp + + + + + + + + + + + < Left──────────────────────────────────────────────────────────────────── + + My title is on the left. + + ──────────────────────────────────────────────────────────────────────────── + + ╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍Centered!╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍ + + My title is centered + + ╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔Right > + + My title is on the right + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + ''' +# --- # name: test_css_property[box_sizing.py] ''' diff --git a/tests/test_border.py b/tests/test_border.py index 37133125b3..606e40a96f 100644 --- a/tests/test_border.py +++ b/tests/test_border.py @@ -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 diff --git a/tests/test_styles_cache.py b/tests/test_styles_cache.py index 01045e322b..e1cb37c65b 100644 --- a/tests/test_styles_cache.py +++ b/tests/test_styles_cache.py @@ -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 get_content_line(y: int) -> Strip: 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 get_content_line(y: int) -> Strip: Color.parse("blue"), Color.parse("green"), get_content_line, + Console(), + "", + "", content_size=Size(3, 3), ) assert rendered_lines == [] @@ -268,6 +293,9 @@ def get_content_line(y: int) -> Strip: Color.parse("blue"), Color.parse("green"), get_content_line, + Console(), + "", + "", content_size=Size(3, 3), ) assert rendered_lines == [0, 1]