Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,24 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Static and Label now accept Content objects, satisfying type checkers https://github.com/Textualize/textual/pull/5618
- Fixed click selection not being disabled when allow_select was set to false https://github.com/Textualize/textual/issues/5627
- Fixed crash on clicking line API border https://github.com/Textualize/textual/pull/5641
- Fixed additional spaces after text-wrapping https://github.com/Textualize/textual/pull/5657
- Added missing `scroll_end` parameter to the `Log.write_line` method https://github.com/Textualize/textual/pull/5672

### Added

- 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
- Added text-padding style https://github.com/Textualize/textual/pull/5657
- Added `Content.first_line` property https://github.com/Textualize/textual/pull/5657
- Added `Content.from_text` constructor https://github.com/Textualize/textual/pull/5657
- Added `Content.empty` constructor https://github.com/Textualize/textual/pull/5657
- Added `Content.pad` method https://github.com/Textualize/textual/pull/5657
- Added `Style.has_transparent_foreground` property https://github.com/Textualize/textual/pull/5657

## Changed

- 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
- Tabs now accept Content or content markup https://github.com/Textualize/textual/pull/5657
- Buttons will now use Textual markup rather than console markup

## [2.1.2] - 2025-02-26

Expand Down
6 changes: 5 additions & 1 deletion docs/examples/widgets/radio_button.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from rich.text import Text

from textual.app import App, ComposeResult
from textual.widgets import RadioButton, RadioSet

Expand All @@ -15,7 +17,9 @@ def compose(self) -> ComposeResult:
yield RadioButton("Star Wars: A New Hope")
yield RadioButton("The Last Starfighter")
yield RadioButton(
"Total Recall :backhand_index_pointing_right: :red_circle:"
Text.from_markup(
"Total Recall :backhand_index_pointing_right: :red_circle:"
)
)
yield RadioButton("Wing Commander")

Expand Down
6 changes: 5 additions & 1 deletion docs/examples/widgets/radio_set.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from rich.text import Text

from textual.app import App, ComposeResult
from textual.containers import Horizontal
from textual.widgets import RadioButton, RadioSet
Expand All @@ -18,7 +20,9 @@ def compose(self) -> ComposeResult:
yield RadioButton("Star Wars: A New Hope")
yield RadioButton("The Last Starfighter")
yield RadioButton(
"Total Recall :backhand_index_pointing_right: :red_circle:"
Text.from_markup(
"Total Recall :backhand_index_pointing_right: :red_circle:"
)
)
yield RadioButton("Wing Commander")
# A RadioSet built up from a collection of strings.
Expand Down
6 changes: 5 additions & 1 deletion docs/examples/widgets/radio_set_changed.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from rich.text import Text

from textual.app import App, ComposeResult
from textual.containers import Horizontal, VerticalScroll
from textual.widgets import Label, RadioButton, RadioSet
Expand All @@ -18,7 +20,9 @@ def compose(self) -> ComposeResult:
yield RadioButton("Star Wars: A New Hope")
yield RadioButton("The Last Starfighter")
yield RadioButton(
"Total Recall :backhand_index_pointing_right: :red_circle:"
Text.from_markup(
"Total Recall :backhand_index_pointing_right: :red_circle:"
)
)
yield RadioButton("Wing Commander")
with Horizontal():
Expand Down
169 changes: 145 additions & 24 deletions src/textual/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@
ContentType: TypeAlias = Union["Content", str]
"""Type alias used where content and a str are interchangeable in a function."""

ContentText: TypeAlias = Union["Content", Text, str]
"""A type that may be used to construct Text."""

ANSI_DEFAULT = Style(
background=Color(0, 0, 0, 0, ansi=-1),
foreground=Color(0, 0, 0, 0, ansi=-1),
Expand Down Expand Up @@ -134,6 +137,8 @@ def __init__(
self._text: str = _strip_control_codes(text)
self._spans: list[Span] = [] if spans is None else spans
self._cell_length = cell_length
self._optimal_width_cache: int | None = None
self._height_cache: tuple[tuple[int, str, bool] | None, int] = (None, 0)

def __str__(self) -> str:
return self._text
Expand Down Expand Up @@ -168,6 +173,46 @@ def markup(self) -> str:
markup = "".join(output)
return markup

@classmethod
def empty(cls) -> Content:
"""Get an empty (blank) content"""
return EMPTY_CONTENT

@classmethod
def from_text(
cls, markup_content_or_text: ContentText, markup: bool = True
) -> Content:
"""Construct content from Text or str. If the argument is already Content, then
return it unmodified.

This method exists to make (Rich) Text and Content interchangeable. While Content
is preferred, we don't want to make it harder than necessary for apps to use Text.

Args:
markup_content_or_text: Value to create Content from.
markup: If `True`, then str values will be parsed as markup, otherwise they will
be considered literals.

Raises:
TypeError: If the supplied argument is not a valid type.

Returns:
A new Content instance.
"""
if isinstance(markup_content_or_text, Content):
return markup_content_or_text
elif isinstance(markup_content_or_text, str):
if markup:
return cls.from_markup(markup_content_or_text)
else:
return cls(markup_content_or_text)
elif isinstance(markup_content_or_text, Text):
return cls.from_rich_text(markup_content_or_text)
else:
raise TypeError(
"This method expects a str, a Text instance, or a Content instance"
)

@classmethod
def from_markup(cls, markup: str | Content, **variables: object) -> Content:
"""Create content from Textual markup, optionally combined with template variables.
Expand Down Expand Up @@ -208,6 +253,8 @@ def from_rich_text(

Args:
text: String or Rich Text.
console: A Console object to use if parsing Rich Console markup, or `None` to
use app default.

Returns:
New Content.
Expand All @@ -220,7 +267,12 @@ def from_rich_text(
if console is not None:
get_style = console.get_style
else:
get_style = RichStyle.parse
try:
app = active_app.get()
except LookupError:
get_style = RichStyle.parse
else:
get_style = app.console.get_style

if text._spans:
try:
Expand Down Expand Up @@ -280,7 +332,7 @@ def styled(

@classmethod
def assemble(
cls, *parts: str | Content | tuple[str, str], end: str = ""
cls, *parts: str | Content | tuple[str, str | Style], end: str = ""
) -> Content:
"""Construct new content from string, content, or tuples of (TEXT, STYLE).

Expand Down Expand Up @@ -367,11 +419,7 @@ def is_same(self, content: Content) -> bool:
return False
return self.spans == content.spans

def get_optimal_width(
self,
rules: RulesMap,
container_width: int,
) -> int:
def get_optimal_width(self, rules: RulesMap, container_width: int) -> int:
"""Get optimal width of the Visual to display its content.

The exact definition of "optimal width" is dependant on the Visual, but
Expand All @@ -380,14 +428,18 @@ def get_optimal_width(

Args:
rules: A mapping of style rules, such as the Widgets `styles` object.
container_width: The size of the container in cells.

Returns:
A width in cells.

"""
width = max(cell_len(line) for line in self.plain.split("\n"))
return width
if self._optimal_width_cache is None:
self._optimal_width_cache = width = max(
cell_len(line) for line in self.plain.split("\n")
)
else:
width = self._optimal_width_cache
return width + rules.get("line_pad", 0) * 2

def get_height(self, rules: RulesMap, width: int) -> int:
"""Get the height of the Visual if rendered at the given width.
Expand All @@ -399,22 +451,32 @@ def get_height(self, rules: RulesMap, width: int) -> int:
Returns:
A height in lines.
"""
lines = self.without_spans._wrap_and_format(
width,
overflow=rules.get("text_overflow", "fold"),
no_wrap=rules.get("text_wrap", "wrap") == "nowrap",
)
return len(lines)
get_rule = rules.get
line_pad = get_rule("line_pad", 0) * 2
overflow = get_rule("text_overflow", "fold")
no_wrap = get_rule("text_wrap", "wrap") == "nowrap"
cache_key = (width + line_pad, overflow, no_wrap)
if self._height_cache[0] == cache_key:
height = self._height_cache[1]
else:
lines = self.without_spans._wrap_and_format(
width - line_pad, overflow=overflow, no_wrap=no_wrap
)
height = len(lines)
self._height_cache = (cache_key, height)
return height

def _wrap_and_format(
self,
width: int,
align: TextAlign = "left",
overflow: TextOverflow = "fold",
no_wrap: bool = False,
line_pad: int = 0,
tab_size: int = 8,
selection: Selection | None = None,
selection_style: Style | None = None,
post_style: Style | None = None,
) -> list[_FormattedLine]:
"""Wraps the text and applies formatting.

Expand All @@ -440,6 +502,10 @@ def get_span(y: int) -> tuple[int, int] | None:
return None

for y, line in enumerate(self.split(allow_blank=True)):

if post_style is not None:
line = line.stylize(post_style)

if selection_style is not None and (span := get_span(y)) is not None:
start, end = span
if end == -1:
Expand All @@ -461,15 +527,27 @@ def get_span(y: int) -> tuple[int, int] | None:
new_lines = [content_line]
else:
content_line = _FormattedLine(line, width, y=y, align=align)
offsets = divide_line(line.plain, width, fold=overflow == "fold")
offsets = divide_line(
line.plain, width - line_pad * 2, fold=overflow == "fold"
)
divided_lines = content_line.content.divide(offsets)
ellipsis = overflow == "ellipsis"
divided_lines = [
line.truncate(width, ellipsis=overflow == "ellipsis")
for line in divided_lines
(
line.truncate(width, ellipsis=ellipsis)
if last
else line.rstrip().truncate(width, ellipsis=ellipsis)
)
for last, line in loop_last(divided_lines)
]

new_lines = [
_FormattedLine(
content.rstrip_end(width), width, offset, y, align=align
content.rstrip_end(width).pad(line_pad, line_pad),
width,
offset,
y,
align=align,
)
for content, offset in zip(divided_lines, [0, *offsets])
]
Expand All @@ -487,6 +565,7 @@ def render_strips(
style: Style,
selection: Selection | None = None,
selection_style: Style | None = None,
post_style: Style | None = None,
) -> list[Strip]:
"""Render the visual into an iterable of strips. Part of the Visual protocol.

Expand All @@ -497,6 +576,7 @@ def render_strips(
style: The base style to render on top of.
selection: Selection information, if applicable, otherwise `None`.
selection_style: Selection style if `selection` is not `None`.
post_style: Style | None = None,

Returns:
An list of Strips.
Expand All @@ -505,14 +585,17 @@ def render_strips(
if not width:
return []

get_rule = rules.get
lines = self._wrap_and_format(
width,
align=rules.get("text_align", "left"),
overflow=rules.get("text_overflow", "fold"),
no_wrap=rules.get("text_wrap", "wrap") == "nowrap",
align=get_rule("text_align", "left"),
overflow=get_rule("text_overflow", "fold"),
no_wrap=get_rule("text_wrap", "wrap") == "nowrap",
line_pad=get_rule("line_pad", 0),
tab_size=8,
selection=selection,
selection_style=selection_style,
post_style=post_style,
)

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

@property
def first_line(self) -> Content:
"""The first line of the content."""
if "\n" not in self.plain:
return self
return self[: self.plain.index("\n")]

def __getitem__(self, slice: int | slice) -> Content:
def get_text_at(offset: int) -> "Content":
_Span = Span
Expand Down Expand Up @@ -837,6 +927,34 @@ def pad_right(self, count: int, character: str = " ") -> Content:
)
return self

def pad(self, left: int, right: int, character: str = " ") -> Content:
"""Pad both the left and right edges with a given number of characters.

Args:
left (int): Number of characters to pad on the left.
right (int): Number of characters to pad on the right.
character (str, optional): Character to pad with. Defaults to " ".
"""
assert len(character) == 1, "Character must be a string of length 1"
if left or right:
text = f"{character * left}{self.plain}{character * right}"
_Span = Span
if left:
spans = [
_Span(start + left, end + left, style)
for start, end, style in self._spans
]
else:
spans = self._spans
content = Content(
text,
spans,
None if self._cell_length is None else self._cell_length + left + right,
)
return content

return self

def center(self, width: int, ellipsis: bool = False) -> Content:
"""Align a line to the center.

Expand All @@ -850,7 +968,7 @@ def center(self, width: int, ellipsis: bool = False) -> Content:
content = self.rstrip().truncate(width, ellipsis=ellipsis)
left = (width - content.cell_length) // 2
right = width - left
content = content.pad_left(left).pad_right(right)
content = content.pad(left, right)
return content

def right(self, width: int, ellipsis: bool = False) -> Content:
Expand Down Expand Up @@ -1404,3 +1522,6 @@ def _apply_link_style(
if style is not None
]
return segments


EMPTY_CONTENT: Final = Content("")
Loading
Loading