title descriptors (#2213)

* title descriptors

* add extra line around titles

* changelog

* snapshots

* comment

* Fix border refresh

* simplify typing

* test for None case
This commit is contained in:
Will McGugan
2023-04-05 10:10:43 +01:00
committed by GitHub
parent c2f7004fbb
commit 41af489648
7 changed files with 318 additions and 278 deletions

View File

@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/) The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/). and this project adheres to [Semantic Versioning](http://semver.org/).
## Unreleased
### Changed
- Allowed border_title and border_subtitle to accept Text objects
- Added additional line around titles
## [0.18.0] - 2023-04-04 ## [0.18.0] - 2023-04-04
### Added ### Added

View File

@@ -283,7 +283,7 @@ def get_box(
def render_border_label( def render_border_label(
label: str, label: Text,
is_title: bool, is_title: bool,
name: EdgeType, name: EdgeType,
width: int, width: int,
@@ -323,12 +323,10 @@ def render_border_label(
# How many cells do we need to reserve for surrounding blanks and corners? # How many cells do we need to reserve for surrounding blanks and corners?
corners_needed = has_left_corner + has_right_corner corners_needed = has_left_corner + has_right_corner
cells_reserved = 2 * corners_needed cells_reserved = 2 * corners_needed
if not label or width <= cells_reserved: if not label.cell_len or width <= cells_reserved:
return return
text_label = Text.from_markup(label) text_label = label.copy()
if not text_label.cell_len:
return
text_label.truncate(width - cells_reserved, overflow="ellipsis") text_label.truncate(width - cells_reserved, overflow="ellipsis")
segments = text_label.render(console) segments = text_label.render(console)
@@ -401,13 +399,15 @@ def render_row(
elif not label_length: elif not label_length:
yield Segment(box2.text * space_available, box2.style) yield Segment(box2.text * space_available, box2.style)
elif label_alignment == "left" or label_alignment == "right": elif label_alignment == "left" or label_alignment == "right":
edge = Segment(box2.text * space_available, box2.style) edge = Segment(box2.text * (space_available - 1), box2.style)
if label_alignment == "left": if label_alignment == "left":
yield Segment(box2.text, box2.style)
yield from label_segments_list yield from label_segments_list
yield edge yield edge
else: else:
yield edge yield edge
yield from label_segments_list yield from label_segments_list
yield Segment(box2.text, box2.style)
elif label_alignment == "center": elif label_alignment == "center":
length_on_left = space_available // 2 length_on_left = space_available // 2
length_on_right = space_available - length_on_left length_on_right = space_available - length_on_left

View File

@@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Callable, Iterable
from rich.console import Console from rich.console import Console
from rich.segment import Segment from rich.segment import Segment
from rich.style import Style from rich.style import Style
from rich.text import Text
from ._border import get_box, render_border_label, render_row from ._border import get_box, render_border_label, render_row
from ._opacity import _apply_opacity from ._opacity import _apply_opacity
@@ -115,8 +116,8 @@ class StylesCache:
background, background,
widget.render_line, widget.render_line,
widget.app.console, widget.app.console,
widget.border_title, widget._border_title,
widget.border_subtitle, widget._border_subtitle,
content_size=widget.content_region.size, content_size=widget.content_region.size,
padding=styles.padding, padding=styles.padding,
crop=crop, crop=crop,
@@ -146,8 +147,8 @@ class StylesCache:
background: Color, background: Color,
render_content_line: RenderLineCallback, render_content_line: RenderLineCallback,
console: Console, console: Console,
border_title: str, border_title: Text | None,
border_subtitle: str, border_subtitle: Text | None,
content_size: Size | None = None, content_size: Size | None = None,
padding: Spacing | None = None, padding: Spacing | None = None,
crop: Region | None = None, crop: Region | None = None,
@@ -228,8 +229,8 @@ class StylesCache:
background: Color, background: Color,
render_content_line: Callable[[int], Strip], render_content_line: Callable[[int], Strip],
console: Console, console: Console,
border_title: str, border_title: Text | None,
border_subtitle: str, border_subtitle: Text | None,
) -> Strip: ) -> Strip:
"""Render a styled line. """Render a styled line.
@@ -310,7 +311,7 @@ class StylesCache:
border_label, border_label,
is_top, is_top,
border_edge_type, border_edge_type,
width, width - 2,
inner, inner,
outer, outer,
border_color_as_style, border_color_as_style,

View File

@@ -1011,6 +1011,8 @@ class App(Generic[ReturnType], DOMNode):
) )
try: try:
app._loop = asyncio.get_running_loop()
app._thread_id = threading.get_ident()
await app._process_messages( await app._process_messages(
ready_callback=None if auto_pilot is None else app_ready, ready_callback=None if auto_pilot is None else app_ready,
headless=headless, headless=headless,

View File

@@ -188,6 +188,34 @@ class PseudoClasses(NamedTuple):
hover: bool hover: bool
class _BorderTitle:
"""Descriptor to set border titles."""
def __set_name__(self, owner: Widget, name: str) -> None:
# The private name where we store the real data.
self._internal_name = f"_{name}"
def __set__(self, obj: Widget, title: str | Text | None) -> None:
"""Setting a title accepts a str, Text, or None."""
if title is None:
setattr(obj, self._internal_name, None)
else:
# We store the title as Text
new_title = obj.render_str(title)
new_title.expand_tabs(4)
new_title = new_title.split()[0]
setattr(obj, self._internal_name, new_title)
obj.refresh()
def __get__(self, obj: Widget, objtype: type[Widget] | None = None) -> str | None:
"""Getting a title will return None or a str as console markup."""
title: Text | None = getattr(obj, self._internal_name, None)
if title is None:
return None
# If we have a title, convert from Text to console markup
return title.markup
@rich.repr.auto @rich.repr.auto
class Widget(DOMNode): class Widget(DOMNode):
""" """
@@ -241,10 +269,6 @@ class Widget(DOMNode):
"""Widget will highlight links automatically.""" """Widget will highlight links automatically."""
disabled = Reactive(False) disabled = Reactive(False)
"""The disabled state of the widget. `True` if disabled, `False` if not.""" """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) hover_style: Reactive[Style] = Reactive(Style, repaint=False)
highlight_link_id: Reactive[str] = Reactive("") highlight_link_id: Reactive[str] = Reactive("")
@@ -270,6 +294,9 @@ class Widget(DOMNode):
self._horizontal_scrollbar: ScrollBar | None = None self._horizontal_scrollbar: ScrollBar | None = None
self._scrollbar_corner: ScrollBarCorner | None = None self._scrollbar_corner: ScrollBarCorner | None = None
self._border_title: Text | None = None
self._border_subtitle: Text | None = None
self._render_cache = RenderCache(Size(0, 0), []) self._render_cache = RenderCache(Size(0, 0), [])
# Regions which need to be updated (in Widget) # Regions which need to be updated (in Widget)
self._dirty_regions: set[Region] = set() self._dirty_regions: set[Region] = set()
@@ -321,6 +348,11 @@ class Widget(DOMNode):
show_vertical_scrollbar = Reactive(False, layout=True) show_vertical_scrollbar = Reactive(False, layout=True)
show_horizontal_scrollbar = Reactive(False, layout=True) show_horizontal_scrollbar = Reactive(False, layout=True)
border_title = _BorderTitle()
"""A title to show in the top border (if there is one)."""
border_subtitle = _BorderTitle()
"""A title to show in the bottom border (if there is one)."""
@property @property
def siblings(self) -> list[Widget]: def siblings(self) -> list[Widget]:
"""Get the widget's siblings (self is removed from the return list). """Get the widget's siblings (self is removed from the return list).
@@ -2429,20 +2461,6 @@ class Widget(DOMNode):
"""Update the styles of the widget and its children when disabled is toggled.""" """Update the styles of the widget and its children when disabled is toggled."""
self._update_styles() 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( def _size_updated(
self, size: Size, virtual_size: Size, container_size: Size, layout: bool = True self, size: Size, virtual_size: Size, container_size: Size, layout: bool = True
) -> bool: ) -> bool:

File diff suppressed because one or more lines are too long

View File

@@ -2,6 +2,7 @@ import pytest
from rich.console import Console from rich.console import Console
from rich.segment import Segment from rich.segment import Segment
from rich.style import Style from rich.style import Style
from rich.text import Text
from textual._border import render_border_label, render_row from textual._border import render_border_label, render_row
from textual.widget import Widget from textual.widget import Widget
@@ -37,6 +38,11 @@ def test_border_title_single_line():
"""The border_title gets set to a single line even when multiple lines are provided.""" """The border_title gets set to a single line even when multiple lines are provided."""
widget = Widget() widget = Widget()
assert widget.border_title is None
widget.border_title = None
assert widget.border_title == None
widget.border_title = "" widget.border_title = ""
assert widget.border_title == "" assert widget.border_title == ""
@@ -50,7 +56,10 @@ def test_border_title_single_line():
assert widget.border_title == "Sorry you " assert widget.border_title == "Sorry you "
widget.border_title = "[red]This also \n works with markup \n involved.[/]" widget.border_title = "[red]This also \n works with markup \n involved.[/]"
assert widget.border_title == "[red]This also " assert widget.border_title == "[red]This also [/red]"
widget.border_title = Text.from_markup("[bold]Hello World")
assert widget.border_title == "[bold]Hello World[/bold]"
def test_border_subtitle_single_line(): def test_border_subtitle_single_line():
@@ -70,7 +79,10 @@ def test_border_subtitle_single_line():
assert widget.border_subtitle == "Sorry you " assert widget.border_subtitle == "Sorry you "
widget.border_subtitle = "[red]This also \n works with markup \n involved.[/]" widget.border_subtitle = "[red]This also \n works with markup \n involved.[/]"
assert widget.border_subtitle == "[red]This also " assert widget.border_subtitle == "[red]This also [/red]"
widget.border_subtitle = Text.from_markup("[bold]Hello World")
assert widget.border_subtitle == "[bold]Hello World[/bold]"
@pytest.mark.parametrize( @pytest.mark.parametrize(
@@ -93,7 +105,7 @@ def test_render_border_label_empty_label_skipped(
assert [] == list( assert [] == list(
render_border_label( render_border_label(
"", Text(""),
True, True,
"round", "round",
width, width,
@@ -130,7 +142,7 @@ def test_render_border_label_skipped_if_narrow(
assert [] == list( assert [] == list(
render_border_label( render_border_label(
label, Text.from_markup(label),
True, True,
"round", "round",
width, width,
@@ -168,7 +180,7 @@ def test_render_border_label_wide_plain(label: str):
True, True,
True, True,
) )
left, original_text, right = render_border_label(label, *args) left, original_text, right = render_border_label(Text.from_markup(label), *args)
assert left == _BLANK_SEGMENT assert left == _BLANK_SEGMENT
assert right == _BLANK_SEGMENT assert right == _BLANK_SEGMENT
@@ -188,7 +200,7 @@ def test_render_border_empty_text_with_markup(label: str):
"""Test label rendering if there is no text but some markup.""" """Test label rendering if there is no text but some markup."""
assert [] == list( assert [] == list(
render_border_label( render_border_label(
label, Text.from_markup(label),
True, True,
"round", "round",
999, 999,
@@ -210,7 +222,7 @@ def test_render_border_label():
# Implicit test on the number of segments returned: # Implicit test on the number of segments returned:
blank1, what, is_up, with_you, blank2 = render_border_label( blank1, what, is_up, with_you, blank2 = render_border_label(
label, Text.from_markup(label),
True, True,
"round", "round",
9999, 9999,
@@ -239,7 +251,7 @@ def test_render_border_label():
assert with_you == expected_with_you assert with_you == expected_with_you
blank1, what, blank2 = render_border_label( blank1, what, blank2 = render_border_label(
label, Text.from_markup(label),
True, True,
"round", "round",
5 + 4, # 5 where "What…" fits + 2 for the blank spaces + 2 for the corners. 5 + 4, # 5 where "What…" fits + 2 for the blank spaces + 2 for the corners.