mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user