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/)
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
### Added

View File

@@ -283,7 +283,7 @@ def get_box(
def render_border_label(
label: str,
label: Text,
is_title: bool,
name: EdgeType,
width: int,
@@ -323,12 +323,10 @@ def render_border_label(
# 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:
if not label.cell_len or width <= cells_reserved:
return
text_label = Text.from_markup(label)
if not text_label.cell_len:
return
text_label = label.copy()
text_label.truncate(width - cells_reserved, overflow="ellipsis")
segments = text_label.render(console)
@@ -401,13 +399,15 @@ def render_row(
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)
edge = Segment(box2.text * (space_available - 1), box2.style)
if label_alignment == "left":
yield Segment(box2.text, box2.style)
yield from label_segments_list
yield edge
else:
yield edge
yield from label_segments_list
yield Segment(box2.text, box2.style)
elif label_alignment == "center":
length_on_left = space_available // 2
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.segment import Segment
from rich.style import Style
from rich.text import Text
from ._border import get_box, render_border_label, render_row
from ._opacity import _apply_opacity
@@ -115,8 +116,8 @@ class StylesCache:
background,
widget.render_line,
widget.app.console,
widget.border_title,
widget.border_subtitle,
widget._border_title,
widget._border_subtitle,
content_size=widget.content_region.size,
padding=styles.padding,
crop=crop,
@@ -146,8 +147,8 @@ class StylesCache:
background: Color,
render_content_line: RenderLineCallback,
console: Console,
border_title: str,
border_subtitle: str,
border_title: Text | None,
border_subtitle: Text | None,
content_size: Size | None = None,
padding: Spacing | None = None,
crop: Region | None = None,
@@ -228,8 +229,8 @@ class StylesCache:
background: Color,
render_content_line: Callable[[int], Strip],
console: Console,
border_title: str,
border_subtitle: str,
border_title: Text | None,
border_subtitle: Text | None,
) -> Strip:
"""Render a styled line.
@@ -310,7 +311,7 @@ class StylesCache:
border_label,
is_top,
border_edge_type,
width,
width - 2,
inner,
outer,
border_color_as_style,

View File

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

View File

@@ -188,6 +188,34 @@ class PseudoClasses(NamedTuple):
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
class Widget(DOMNode):
"""
@@ -241,10 +269,6 @@ 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("")
@@ -270,6 +294,9 @@ class Widget(DOMNode):
self._horizontal_scrollbar: ScrollBar | 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), [])
# Regions which need to be updated (in Widget)
self._dirty_regions: set[Region] = set()
@@ -321,6 +348,11 @@ class Widget(DOMNode):
show_vertical_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
def siblings(self) -> list[Widget]:
"""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."""
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

@@ -2,6 +2,7 @@ import pytest
from rich.console import Console
from rich.segment import Segment
from rich.style import Style
from rich.text import Text
from textual._border import render_border_label, render_row
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."""
widget = Widget()
assert widget.border_title is None
widget.border_title = None
assert widget.border_title == None
widget.border_title = ""
assert widget.border_title == ""
@@ -50,7 +56,10 @@ def test_border_title_single_line():
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 "
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():
@@ -70,7 +79,10 @@ def test_border_subtitle_single_line():
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 "
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(
@@ -93,7 +105,7 @@ def test_render_border_label_empty_label_skipped(
assert [] == list(
render_border_label(
"",
Text(""),
True,
"round",
width,
@@ -130,7 +142,7 @@ def test_render_border_label_skipped_if_narrow(
assert [] == list(
render_border_label(
label,
Text.from_markup(label),
True,
"round",
width,
@@ -168,7 +180,7 @@ def test_render_border_label_wide_plain(label: str):
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 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."""
assert [] == list(
render_border_label(
label,
Text.from_markup(label),
True,
"round",
999,
@@ -210,7 +222,7 @@ def test_render_border_label():
# Implicit test on the number of segments returned:
blank1, what, is_up, with_you, blank2 = render_border_label(
label,
Text.from_markup(label),
True,
"round",
9999,
@@ -239,7 +251,7 @@ def test_render_border_label():
assert with_you == expected_with_you
blank1, what, blank2 = render_border_label(
label,
Text.from_markup(label),
True,
"round",
5 + 4, # 5 where "What…" fits + 2 for the blank spaces + 2 for the corners.