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]
+ '''
+
+
+ '''
+# ---
+# name: test_css_property[border_subtitle_align.py]
+ '''
+
+
+ '''
+# ---
+# name: test_css_property[border_title_align.py]
+ '''
+
+
+ '''
+# ---
# name: test_css_property[box_sizing.py]
'''