Skip to content

Commit 8603580

Browse files
authored
Merge pull request #5657 from Textualize/content-text
Content Text type
2 parents f59cf41 + cc9a024 commit 8603580

73 files changed

Lines changed: 3805 additions & 3603 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,24 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1212
- Static and Label now accept Content objects, satisfying type checkers https://github.com/Textualize/textual/pull/5618
1313
- Fixed click selection not being disabled when allow_select was set to false https://github.com/Textualize/textual/issues/5627
1414
- Fixed crash on clicking line API border https://github.com/Textualize/textual/pull/5641
15+
- Fixed additional spaces after text-wrapping https://github.com/Textualize/textual/pull/5657
1516
- Added missing `scroll_end` parameter to the `Log.write_line` method https://github.com/Textualize/textual/pull/5672
1617

1718
### Added
1819

1920
- Added Widget.preflight_checks to perform some debug checks after a widget is instantiated, to catch common errors. https://github.com/Textualize/textual/pull/5588
21+
- Added text-padding style https://github.com/Textualize/textual/pull/5657
22+
- Added `Content.first_line` property https://github.com/Textualize/textual/pull/5657
23+
- Added `Content.from_text` constructor https://github.com/Textualize/textual/pull/5657
24+
- Added `Content.empty` constructor https://github.com/Textualize/textual/pull/5657
25+
- Added `Content.pad` method https://github.com/Textualize/textual/pull/5657
26+
- Added `Style.has_transparent_foreground` property https://github.com/Textualize/textual/pull/5657
2027

2128
## Changed
2229

2330
- Assigned names to Textual-specific threads: `textual-input`, `textual-output`. These should become visible in monitoring tools (ps, top, htop) as of Python 3.14. https://github.com/Textualize/textual/pull/5654
31+
- Tabs now accept Content or content markup https://github.com/Textualize/textual/pull/5657
32+
- Buttons will now use Textual markup rather than console markup
2433

2534
## [2.1.2] - 2025-02-26
2635

docs/examples/widgets/radio_button.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from rich.text import Text
2+
13
from textual.app import App, ComposeResult
24
from textual.widgets import RadioButton, RadioSet
35

@@ -15,7 +17,9 @@ def compose(self) -> ComposeResult:
1517
yield RadioButton("Star Wars: A New Hope")
1618
yield RadioButton("The Last Starfighter")
1719
yield RadioButton(
18-
"Total Recall :backhand_index_pointing_right: :red_circle:"
20+
Text.from_markup(
21+
"Total Recall :backhand_index_pointing_right: :red_circle:"
22+
)
1923
)
2024
yield RadioButton("Wing Commander")
2125

docs/examples/widgets/radio_set.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from rich.text import Text
2+
13
from textual.app import App, ComposeResult
24
from textual.containers import Horizontal
35
from textual.widgets import RadioButton, RadioSet
@@ -18,7 +20,9 @@ def compose(self) -> ComposeResult:
1820
yield RadioButton("Star Wars: A New Hope")
1921
yield RadioButton("The Last Starfighter")
2022
yield RadioButton(
21-
"Total Recall :backhand_index_pointing_right: :red_circle:"
23+
Text.from_markup(
24+
"Total Recall :backhand_index_pointing_right: :red_circle:"
25+
)
2226
)
2327
yield RadioButton("Wing Commander")
2428
# A RadioSet built up from a collection of strings.

docs/examples/widgets/radio_set_changed.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from rich.text import Text
2+
13
from textual.app import App, ComposeResult
24
from textual.containers import Horizontal, VerticalScroll
35
from textual.widgets import Label, RadioButton, RadioSet
@@ -18,7 +20,9 @@ def compose(self) -> ComposeResult:
1820
yield RadioButton("Star Wars: A New Hope")
1921
yield RadioButton("The Last Starfighter")
2022
yield RadioButton(
21-
"Total Recall :backhand_index_pointing_right: :red_circle:"
23+
Text.from_markup(
24+
"Total Recall :backhand_index_pointing_right: :red_circle:"
25+
)
2226
)
2327
yield RadioButton("Wing Commander")
2428
with Horizontal():

src/textual/content.py

Lines changed: 145 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@
3939
ContentType: TypeAlias = Union["Content", str]
4040
"""Type alias used where content and a str are interchangeable in a function."""
4141

42+
ContentText: TypeAlias = Union["Content", Text, str]
43+
"""A type that may be used to construct Text."""
44+
4245
ANSI_DEFAULT = Style(
4346
background=Color(0, 0, 0, 0, ansi=-1),
4447
foreground=Color(0, 0, 0, 0, ansi=-1),
@@ -134,6 +137,8 @@ def __init__(
134137
self._text: str = _strip_control_codes(text)
135138
self._spans: list[Span] = [] if spans is None else spans
136139
self._cell_length = cell_length
140+
self._optimal_width_cache: int | None = None
141+
self._height_cache: tuple[tuple[int, str, bool] | None, int] = (None, 0)
137142

138143
def __str__(self) -> str:
139144
return self._text
@@ -168,6 +173,46 @@ def markup(self) -> str:
168173
markup = "".join(output)
169174
return markup
170175

176+
@classmethod
177+
def empty(cls) -> Content:
178+
"""Get an empty (blank) content"""
179+
return EMPTY_CONTENT
180+
181+
@classmethod
182+
def from_text(
183+
cls, markup_content_or_text: ContentText, markup: bool = True
184+
) -> Content:
185+
"""Construct content from Text or str. If the argument is already Content, then
186+
return it unmodified.
187+
188+
This method exists to make (Rich) Text and Content interchangeable. While Content
189+
is preferred, we don't want to make it harder than necessary for apps to use Text.
190+
191+
Args:
192+
markup_content_or_text: Value to create Content from.
193+
markup: If `True`, then str values will be parsed as markup, otherwise they will
194+
be considered literals.
195+
196+
Raises:
197+
TypeError: If the supplied argument is not a valid type.
198+
199+
Returns:
200+
A new Content instance.
201+
"""
202+
if isinstance(markup_content_or_text, Content):
203+
return markup_content_or_text
204+
elif isinstance(markup_content_or_text, str):
205+
if markup:
206+
return cls.from_markup(markup_content_or_text)
207+
else:
208+
return cls(markup_content_or_text)
209+
elif isinstance(markup_content_or_text, Text):
210+
return cls.from_rich_text(markup_content_or_text)
211+
else:
212+
raise TypeError(
213+
"This method expects a str, a Text instance, or a Content instance"
214+
)
215+
171216
@classmethod
172217
def from_markup(cls, markup: str | Content, **variables: object) -> Content:
173218
"""Create content from Textual markup, optionally combined with template variables.
@@ -208,6 +253,8 @@ def from_rich_text(
208253
209254
Args:
210255
text: String or Rich Text.
256+
console: A Console object to use if parsing Rich Console markup, or `None` to
257+
use app default.
211258
212259
Returns:
213260
New Content.
@@ -220,7 +267,12 @@ def from_rich_text(
220267
if console is not None:
221268
get_style = console.get_style
222269
else:
223-
get_style = RichStyle.parse
270+
try:
271+
app = active_app.get()
272+
except LookupError:
273+
get_style = RichStyle.parse
274+
else:
275+
get_style = app.console.get_style
224276

225277
if text._spans:
226278
try:
@@ -280,7 +332,7 @@ def styled(
280332

281333
@classmethod
282334
def assemble(
283-
cls, *parts: str | Content | tuple[str, str], end: str = ""
335+
cls, *parts: str | Content | tuple[str, str | Style], end: str = ""
284336
) -> Content:
285337
"""Construct new content from string, content, or tuples of (TEXT, STYLE).
286338
@@ -367,11 +419,7 @@ def is_same(self, content: Content) -> bool:
367419
return False
368420
return self.spans == content.spans
369421

370-
def get_optimal_width(
371-
self,
372-
rules: RulesMap,
373-
container_width: int,
374-
) -> int:
422+
def get_optimal_width(self, rules: RulesMap, container_width: int) -> int:
375423
"""Get optimal width of the Visual to display its content.
376424
377425
The exact definition of "optimal width" is dependant on the Visual, but
@@ -380,14 +428,18 @@ def get_optimal_width(
380428
381429
Args:
382430
rules: A mapping of style rules, such as the Widgets `styles` object.
383-
container_width: The size of the container in cells.
384431
385432
Returns:
386433
A width in cells.
387434
388435
"""
389-
width = max(cell_len(line) for line in self.plain.split("\n"))
390-
return width
436+
if self._optimal_width_cache is None:
437+
self._optimal_width_cache = width = max(
438+
cell_len(line) for line in self.plain.split("\n")
439+
)
440+
else:
441+
width = self._optimal_width_cache
442+
return width + rules.get("line_pad", 0) * 2
391443

392444
def get_height(self, rules: RulesMap, width: int) -> int:
393445
"""Get the height of the Visual if rendered at the given width.
@@ -399,22 +451,32 @@ def get_height(self, rules: RulesMap, width: int) -> int:
399451
Returns:
400452
A height in lines.
401453
"""
402-
lines = self.without_spans._wrap_and_format(
403-
width,
404-
overflow=rules.get("text_overflow", "fold"),
405-
no_wrap=rules.get("text_wrap", "wrap") == "nowrap",
406-
)
407-
return len(lines)
454+
get_rule = rules.get
455+
line_pad = get_rule("line_pad", 0) * 2
456+
overflow = get_rule("text_overflow", "fold")
457+
no_wrap = get_rule("text_wrap", "wrap") == "nowrap"
458+
cache_key = (width + line_pad, overflow, no_wrap)
459+
if self._height_cache[0] == cache_key:
460+
height = self._height_cache[1]
461+
else:
462+
lines = self.without_spans._wrap_and_format(
463+
width - line_pad, overflow=overflow, no_wrap=no_wrap
464+
)
465+
height = len(lines)
466+
self._height_cache = (cache_key, height)
467+
return height
408468

409469
def _wrap_and_format(
410470
self,
411471
width: int,
412472
align: TextAlign = "left",
413473
overflow: TextOverflow = "fold",
414474
no_wrap: bool = False,
475+
line_pad: int = 0,
415476
tab_size: int = 8,
416477
selection: Selection | None = None,
417478
selection_style: Style | None = None,
479+
post_style: Style | None = None,
418480
) -> list[_FormattedLine]:
419481
"""Wraps the text and applies formatting.
420482
@@ -440,6 +502,10 @@ def get_span(y: int) -> tuple[int, int] | None:
440502
return None
441503

442504
for y, line in enumerate(self.split(allow_blank=True)):
505+
506+
if post_style is not None:
507+
line = line.stylize(post_style)
508+
443509
if selection_style is not None and (span := get_span(y)) is not None:
444510
start, end = span
445511
if end == -1:
@@ -461,15 +527,27 @@ def get_span(y: int) -> tuple[int, int] | None:
461527
new_lines = [content_line]
462528
else:
463529
content_line = _FormattedLine(line, width, y=y, align=align)
464-
offsets = divide_line(line.plain, width, fold=overflow == "fold")
530+
offsets = divide_line(
531+
line.plain, width - line_pad * 2, fold=overflow == "fold"
532+
)
465533
divided_lines = content_line.content.divide(offsets)
534+
ellipsis = overflow == "ellipsis"
466535
divided_lines = [
467-
line.truncate(width, ellipsis=overflow == "ellipsis")
468-
for line in divided_lines
536+
(
537+
line.truncate(width, ellipsis=ellipsis)
538+
if last
539+
else line.rstrip().truncate(width, ellipsis=ellipsis)
540+
)
541+
for last, line in loop_last(divided_lines)
469542
]
543+
470544
new_lines = [
471545
_FormattedLine(
472-
content.rstrip_end(width), width, offset, y, align=align
546+
content.rstrip_end(width).pad(line_pad, line_pad),
547+
width,
548+
offset,
549+
y,
550+
align=align,
473551
)
474552
for content, offset in zip(divided_lines, [0, *offsets])
475553
]
@@ -487,6 +565,7 @@ def render_strips(
487565
style: Style,
488566
selection: Selection | None = None,
489567
selection_style: Style | None = None,
568+
post_style: Style | None = None,
490569
) -> list[Strip]:
491570
"""Render the visual into an iterable of strips. Part of the Visual protocol.
492571
@@ -497,6 +576,7 @@ def render_strips(
497576
style: The base style to render on top of.
498577
selection: Selection information, if applicable, otherwise `None`.
499578
selection_style: Selection style if `selection` is not `None`.
579+
post_style: Style | None = None,
500580
501581
Returns:
502582
An list of Strips.
@@ -505,14 +585,17 @@ def render_strips(
505585
if not width:
506586
return []
507587

588+
get_rule = rules.get
508589
lines = self._wrap_and_format(
509590
width,
510-
align=rules.get("text_align", "left"),
511-
overflow=rules.get("text_overflow", "fold"),
512-
no_wrap=rules.get("text_wrap", "wrap") == "nowrap",
591+
align=get_rule("text_align", "left"),
592+
overflow=get_rule("text_overflow", "fold"),
593+
no_wrap=get_rule("text_wrap", "wrap") == "nowrap",
594+
line_pad=get_rule("line_pad", 0),
513595
tab_size=8,
514596
selection=selection,
515597
selection_style=selection_style,
598+
post_style=post_style,
516599
)
517600

518601
if height is not None:
@@ -566,6 +649,13 @@ def without_spans(self) -> Content:
566649
"""The content with no spans"""
567650
return Content(self.plain, [], self._cell_length)
568651

652+
@property
653+
def first_line(self) -> Content:
654+
"""The first line of the content."""
655+
if "\n" not in self.plain:
656+
return self
657+
return self[: self.plain.index("\n")]
658+
569659
def __getitem__(self, slice: int | slice) -> Content:
570660
def get_text_at(offset: int) -> "Content":
571661
_Span = Span
@@ -837,6 +927,34 @@ def pad_right(self, count: int, character: str = " ") -> Content:
837927
)
838928
return self
839929

930+
def pad(self, left: int, right: int, character: str = " ") -> Content:
931+
"""Pad both the left and right edges with a given number of characters.
932+
933+
Args:
934+
left (int): Number of characters to pad on the left.
935+
right (int): Number of characters to pad on the right.
936+
character (str, optional): Character to pad with. Defaults to " ".
937+
"""
938+
assert len(character) == 1, "Character must be a string of length 1"
939+
if left or right:
940+
text = f"{character * left}{self.plain}{character * right}"
941+
_Span = Span
942+
if left:
943+
spans = [
944+
_Span(start + left, end + left, style)
945+
for start, end, style in self._spans
946+
]
947+
else:
948+
spans = self._spans
949+
content = Content(
950+
text,
951+
spans,
952+
None if self._cell_length is None else self._cell_length + left + right,
953+
)
954+
return content
955+
956+
return self
957+
840958
def center(self, width: int, ellipsis: bool = False) -> Content:
841959
"""Align a line to the center.
842960
@@ -850,7 +968,7 @@ def center(self, width: int, ellipsis: bool = False) -> Content:
850968
content = self.rstrip().truncate(width, ellipsis=ellipsis)
851969
left = (width - content.cell_length) // 2
852970
right = width - left
853-
content = content.pad_left(left).pad_right(right)
971+
content = content.pad(left, right)
854972
return content
855973

856974
def right(self, width: int, ellipsis: bool = False) -> Content:
@@ -1404,3 +1522,6 @@ def _apply_link_style(
14041522
if style is not None
14051523
]
14061524
return segments
1525+
1526+
1527+
EMPTY_CONTENT: Final = Content("")

0 commit comments

Comments
 (0)