From 2f0139047a2b128f38b988fac0e02c6b5e56c75e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 24 Jul 2025 08:39:43 +0100 Subject: [PATCH 1/6] allow component classes in content --- src/textual/content.py | 21 ++- src/textual/renderables/blank.py | 3 + src/textual/visual.py | 8 +- src/textual/widget.py | 28 +++- src/textual/widgets/_markdown.py | 229 +++++++++++++++---------------- tests/test_content.py | 4 +- 6 files changed, 163 insertions(+), 130 deletions(-) diff --git a/src/textual/content.py b/src/textual/content.py index 3910b034dd..4a20b5bda3 100644 --- a/src/textual/content.py +++ b/src/textual/content.py @@ -502,6 +502,7 @@ def _wrap_and_format( selection: Selection | None = None, selection_style: Style | None = None, post_style: Style | None = None, + get_style: Callable[[str | Style], Style] = Style.parse, ) -> list[_FormattedLine]: """Wraps the text and applies formatting. @@ -543,15 +544,17 @@ def get_span(y: int) -> tuple[int, int] | None: if overflow == "fold": cuts = list(range(0, line.cell_length, width))[1:] new_lines = [ - _FormattedLine(line, width, y=y, align=align) + _FormattedLine(get_style, line, width, y=y, align=align) for line in line.divide(cuts) ] else: line = line.truncate(width, ellipsis=overflow == "ellipsis") - content_line = _FormattedLine(line, width, y=y, align=align) + content_line = _FormattedLine( + get_style, line, width, y=y, align=align + ) new_lines = [content_line] else: - content_line = _FormattedLine(line, width, y=y, align=align) + content_line = _FormattedLine(get_style, line, width, y=y, align=align) offsets = divide_line( line.plain, width - line_pad * 2, fold=overflow == "fold" ) @@ -568,6 +571,7 @@ def get_span(y: int) -> tuple[int, int] | None: new_lines = [ _FormattedLine( + get_style, content.rstrip_end(width).pad(line_pad, line_pad), width, offset, @@ -584,6 +588,7 @@ def get_span(y: int) -> tuple[int, int] | None: def render_strips( self, + get_style: Callable[[str | Style], Style], rules: RulesMap, width: int, height: int | None, @@ -621,6 +626,7 @@ def render_strips( selection=selection, selection_style=selection_style, post_style=post_style, + get_style=get_style, ) if height is not None: @@ -1478,6 +1484,7 @@ class _FormattedLine: def __init__( self, + get_style: Callable[[str | Style], Style], content: Content, width: int, x: int = 0, @@ -1486,6 +1493,7 @@ def __init__( line_end: bool = False, link_style: Style | None = None, ) -> None: + self.get_style = get_style self.content = content self.width = width self.x = x @@ -1506,6 +1514,7 @@ def to_strip(self, style: Style) -> tuple[list[Segment], int]: content = self.content x = self.x y = self.y + get_style = self.get_style if align in ("start", "left") or (align == "justify" and self.line_end): pass @@ -1534,7 +1543,9 @@ def to_strip(self, style: Style) -> tuple[list[Segment], int]: add_segment = segments.append x = self.x for index, word in enumerate(words): - for text, text_style in word.render(style, end=""): + for text, text_style in word.render( + style, end="", parse_style=get_style + ): add_segment( _Segment( text, (style + text_style).rich_style_with_offset(x, y) @@ -1552,7 +1563,7 @@ def to_strip(self, style: Style) -> tuple[list[Segment], int]: else [] ) add_segment = segments.append - for text, text_style in content.render(style, end=""): + for text, text_style in content.render(style, end="", parse_style=get_style): add_segment( _Segment(text, (style + text_style).rich_style_with_offset(x, y)) ) diff --git a/src/textual/renderables/blank.py b/src/textual/renderables/blank.py index a5ea61074e..96d194ed43 100644 --- a/src/textual/renderables/blank.py +++ b/src/textual/renderables/blank.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Callable + from rich.style import Style as RichStyle from textual.color import Color @@ -27,6 +29,7 @@ def get_height(self, rules: RulesMap, width: int) -> int: def render_strips( self, + get_style: Callable[[str | Style], Style], rules: RulesMap, width: int, height: int | None, diff --git a/src/textual/visual.py b/src/textual/visual.py index 228e7ddc04..348f509422 100644 --- a/src/textual/visual.py +++ b/src/textual/visual.py @@ -2,7 +2,7 @@ from abc import ABC, abstractmethod from itertools import islice -from typing import TYPE_CHECKING, Protocol +from typing import TYPE_CHECKING, Callable, Protocol import rich.repr from rich.console import Console, ConsoleOptions, RenderableType @@ -113,6 +113,7 @@ class Visual(ABC): @abstractmethod def render_strips( self, + get_style: Callable[[str | Style], Style], rules: RulesMap, width: int, height: int | None, @@ -131,6 +132,7 @@ def render_strips( selection: Selection information, if applicable, otherwise `None`. selection_style: Selection style if `selection` is not `None`. post_style: Optional style to apply post render. + get_style: Callable to get a style. Returns: An list of Strips. @@ -216,6 +218,7 @@ def to_strips( selection_style = None strips = visual.render_strips( + widget._get_style, widget.styles, width, height, @@ -304,6 +307,7 @@ def get_height(self, rules: RulesMap, width: int) -> int: def render_strips( self, + get_style: Callable[[str | Style], Style], rules: RulesMap, width: int, height: int | None, @@ -366,6 +370,7 @@ def get_height(self, rules: RulesMap, width: int) -> int: def render_strips( self, + get_style: Callable[[str | Style], Style], rules: RulesMap, width: int, height: int | None, @@ -381,6 +386,7 @@ def render_strips( return [] strips = self._visual.render_strips( + get_style, rules, render_width, None if height is None else height - padding.height, diff --git a/src/textual/widget.py b/src/textual/widget.py index 40e3af4ffb..782401e63a 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1140,9 +1140,15 @@ def iter_styles() -> Iterable[StylesBase]: text_background = background + styles.background.tint( styles.background_tint ) - background += ( - styles.background.tint(styles.background_tint) - ).multiply_alpha(opacity) + if partial: + background_tint = styles.background.tint(styles.background_tint) + background = background.blend( + background_tint, 1 - background_tint.a + ).multiply_alpha(opacity) + else: + background += ( + styles.background.tint(styles.background_tint) + ).multiply_alpha(opacity) else: text_background = background if has_rule("color"): @@ -1164,7 +1170,7 @@ def iter_styles() -> Iterable[StylesBase]: return visual_style - def _get_style(self, style: str) -> VisualStyle | None: + def _get_style(self, style: str) -> VisualStyle: """A get_style method for use in Content. Args: @@ -1174,8 +1180,18 @@ def _get_style(self, style: str) -> VisualStyle | None: A visual style if one is fund, otherwise `None`. """ if style.startswith("."): - return self.get_visual_style(style[1:]) - return None + for node in self.ancestors_with_self: + if not isinstance(node, Widget): + break + try: + visual_style = node.get_visual_style(style[1:], partial=True) + break + except KeyError: + continue + else: + raise KeyError(f"No matching component class found for '{style}'") + return visual_style + return VisualStyle.parse(style) @overload def render_str(self, text_content: str) -> Content: ... diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index e8822ffc4b..5e34007a3f 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -4,7 +4,6 @@ import re from contextlib import suppress from functools import partial -from itertools import starmap from pathlib import Path, PurePath from typing import Callable, Iterable, Optional from urllib.parse import unquote @@ -18,7 +17,7 @@ from textual.app import ComposeResult from textual.await_complete import AwaitComplete from textual.containers import Horizontal, Vertical, VerticalScroll -from textual.content import Content +from textual.content import Content, Span from textual.css.query import NoMatches from textual.events import Mount from textual.highlight import highlight @@ -179,6 +178,19 @@ def forward(self) -> bool: class MarkdownBlock(Static): """The base class for a Markdown Element.""" + COMPONENT_CLASSES = {"em", "strong", "s", "code_inline"} + """ + These component classes target standard inline markdown styles. + Changing these will potentially break the standard markdown formatting. + + | Class | Description | + | :- | :- | + | `code_inline` | Target text that is styled as inline code. | + | `em` | Target text that is emphasized inline. | + | `s` | Target text that is styled inline with strikethrough. | + | `strong` | Target text that is styled inline with strong. | + """ + DEFAULT_CSS = """ MarkdownBlock { width: 1fr; @@ -220,6 +232,10 @@ def source(self) -> str | None: start, end = self.source_range return "".join(self._markdown.source.splitlines(keepends=True)[start:end]) + def _copy_context(self, block: MarkdownBlock) -> None: + """Copy the context from another block.""" + self._token = block._token + def compose(self) -> ComposeResult: yield from self._blocks self._blocks.clear() @@ -236,10 +252,12 @@ async def action_link(self, href: str) -> None: """Called on link click.""" self.post_message(Markdown.LinkClicked(self._markdown, href)) - def rebuild(self) -> None: - """Rebuild the content of the block if we have a source token.""" - if self._inline_token is not None: - self.build_from_token(self._inline_token) + # def rebuild(self) -> None: + # """Rebuild the content of the block if we have a source token.""" + # if self._inline_token is not None: + # self.build_from_token(self._inline_token) + # # for block in self.query_children(MarkdownBlock): + # # block.rebuild() def build_from_token(self, token: Token) -> None: """Build inline block content from its source token. @@ -261,81 +279,67 @@ def _token_to_content(self, token: Token) -> Content: Content instance. """ - null_style = Style.null() - style_stack: list[Style] = [Style()] - pending_content: list[tuple[str, Style]] = [] + tokens: list[str] = [] + spans: list[Span] = [] + style_stack: list[tuple[Style | str, int]] = [] + position: int = 0 - def add_content(text: str, style: Style) -> None: - """Add text and style to output content. + def add_content(text: str) -> None: + nonlocal position + tokens.append(text) + position += len(text) - Args: - text: String of content. - style: Style of string. - """ - if pending_content: - top_text, top_style = pending_content[-1] - if top_style == style: - # Combine contiguous styles - pending_content[-1] = (top_text + text, style) - else: - pending_content.append((text, style)) - else: - pending_content.append((text, style)) + def add_style(style: Style | str) -> None: + style_stack.append((style, position)) if token.children is None: return Content("") - get_visual_style = self._markdown.get_visual_style + + position = 0 + + def close_tag() -> None: + style, start = style_stack.pop() + spans.append(Span(start, position, style)) for child in token.children: child_type = child.type if child_type == "text": - add_content(re.sub(r"\s+", " ", child.content), style_stack[-1]) + add_content(re.sub(r"\s+", " ", child.content)) if child_type == "hardbreak": - add_content("\n", null_style) + add_content("\n") if child_type == "softbreak": - add_content(" ", style_stack[-1]) + add_content(" ") elif child_type == "code_inline": - add_content( - child.content, - style_stack[-1] + get_visual_style("code_inline"), - ) + add_style(".code_inline") + add_content(child.content) + close_tag() elif child_type == "em_open": - style_stack.append( - style_stack[-1] + get_visual_style("em", partial=True) - ) + add_style(".em") elif child_type == "strong_open": - style_stack.append( - style_stack[-1] + get_visual_style("strong", partial=True) - ) + add_style(".strong") elif child_type == "s_open": - style_stack.append( - style_stack[-1] + get_visual_style("s", partial=True) - ) + add_style(".s") elif child_type == "link_open": href = child.attrs.get("href", "") action = f"link({href!r})" - style_stack.append( - style_stack[-1] + Style.from_meta({"@click": action}) - ) + add_style(Style.from_meta({"@click": action})) elif child_type == "image": href = child.attrs.get("src", "") alt = child.attrs.get("alt", "") action = f"link({href!r})" - style_stack.append( - style_stack[-1] + Style.from_meta({"@click": action}) - ) - add_content("🖼 ", style_stack[-1]) + add_style(Style.from_meta({"@click": action})) + add_content("🖼 ") if alt: - add_content(f"({alt})", style_stack[-1]) + add_content(f"({alt})") if child.children is not None: for grandchild in child.children: - add_content(grandchild.content, style_stack[-1]) - style_stack.pop() + add_content(grandchild.content) + close_tag() elif child_type.endswith("_close"): - style_stack.pop() + close_tag() - content = Content("").join(starmap(Content.styled, pending_content)) + content = Content("".join(tokens), spans=spans) return content @@ -455,8 +459,7 @@ class MarkdownParagraph(MarkdownBlock): async def _update_from_block(self, block: MarkdownBlock): if isinstance(block, MarkdownParagraph): self.set_content(block._content) - self._token = block._token - self._inline_token = block._inline_token + self._copy_context(block) else: await super()._update_from_block(block) @@ -563,7 +566,7 @@ def compose(self) -> ComposeResult: bullet.symbol = f"{number}{suffix}".rjust(symbol_size + 1) yield Horizontal(bullet, Vertical(*block._blocks)) - self._blocks.clear() + # self._blocks.clear() class MarkdownTableCellContents(Static): @@ -723,29 +726,29 @@ def flatten(block: MarkdownBlock) -> Iterable[MarkdownBlock]: rows.pop() return headers, rows - def rebuild(self) -> None: - self._rebuild() + # def rebuild(self) -> None: + # self._rebuild() - def _rebuild(self) -> None: - try: - table_content = self.query_one(MarkdownTableContent) - except NoMatches: - return + # def _rebuild(self) -> None: + # try: + # table_content = self.query_one(MarkdownTableContent) + # except NoMatches: + # return - def flatten(block: MarkdownBlock) -> Iterable[MarkdownBlock]: - for block in block._blocks: - if block._blocks: - yield from flatten(block) - yield block + # def flatten(block: MarkdownBlock) -> Iterable[MarkdownBlock]: + # for block in block._blocks: + # if block._blocks: + # yield from flatten(block) + # yield block - for block in flatten(self): - if block._inline_token is not None: - block.rebuild() + # for block in flatten(self): + # if block._inline_token is not None: + # block.rebuild() - headers, rows = self._get_headers_and_rows() - self._headers = headers - self._rows = rows - table_content._update_content(headers, rows) + # headers, rows = self._get_headers_and_rows() + # self._headers = headers + # self._rows = rows + # table_content._update_content(headers, rows) async def _update_from_block(self, block: MarkdownBlock) -> None: """Special case to update a Markdown table. @@ -877,15 +880,18 @@ def allow_horizontal_scroll(self) -> bool: def highlight(cls, code: str, language: str) -> Content: return highlight(code, language=language) + def _copy_context(self, block: MarkdownBlock) -> None: + if isinstance(block, MarkdownFence): + self.lexer = block.lexer + self._token = block._token + async def _update_from_block(self, block: MarkdownBlock): if isinstance(block, MarkdownFence): self.set_content(block._highlighted_code) + self._copy_context(block) else: await super()._update_from_block(block) - def on_mount(self): - self.set_content(self._highlighted_code) - def set_content(self, content: Content) -> None: self._content = content with suppress(NoMatches): @@ -906,23 +912,25 @@ class Markdown(Widget): layout: vertical; color: $foreground; overflow-y: hidden; - - &:dark > .code_inline { - background: $warning 10%; - color: $text-warning 95%; - } - &:light > .code_inline { - background: $error 5%; - color: $text-error 95%; - } - & > .em { - text-style: italic; - } - & > .strong { - text-style: bold; - } - & > .s { - text-style: strike; + + MarkdownBlock { + &:dark > .code_inline { + background: $warning 10%; + color: $text-warning 95%; + } + &:light > .code_inline { + background: $error 5%; + color: $text-error 95%; + } + & > .em { + text-style: italic; + } + & > .strong { + text-style: bold; + } + & > .s { + text-style: strike; + } } } @@ -930,19 +938,6 @@ class Markdown(Widget): """ - COMPONENT_CLASSES = {"em", "strong", "s", "code_inline"} - """ - These component classes target standard inline markdown styles. - Changing these will potentially break the standard markdown formatting. - - | Class | Description | - | :- | :- | - | `code_inline` | Target text that is styled as inline code. | - | `em` | Target text that is emphasized inline. | - | `s` | Target text that is styled inline with strikethrough. | - | `strong` | Target text that is styled inline with strong. | - """ - BULLETS = ["• ", "â–ª ", "‣ ", "â­‘ ", "â—¦ "] """Unicode bullets used for unordered lists.""" @@ -1075,18 +1070,18 @@ def get_block_class(self, block_name: str) -> type[MarkdownBlock]: """ return self.BLOCKS[block_name] - def notify_style_update(self) -> None: - super().notify_style_update() + # def notify_style_update(self) -> None: + # super().notify_style_update() - if self.app.theme == self._theme: - return - self._theme = self.app.theme + # if self.app.theme == self._theme: + # return + # self._theme = self.app.theme - def rebuild_all() -> None: - for child in self.query_children(MarkdownBlock): - child.rebuild() + # def rebuild_all() -> None: + # for child in self.query_children(MarkdownBlock): + # child.rebuild() - self.call_after_refresh(rebuild_all) + # self.call_after_refresh(rebuild_all) async def _on_mount(self, _: Mount) -> None: initial_markdown = self._initial_markdown diff --git a/tests/test_content.py b/tests/test_content.py index fa54671654..ad2db79ad8 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -5,6 +5,7 @@ from textual.content import Content, Span from textual.style import Style +from textual.widget import Widget def test_blank(): @@ -282,4 +283,5 @@ def test_split_and_tabs(): ] content = Content("--- hello.py\t2024-01-15 10:30:00.000000000 -0800", spans=spans) - content.render_strips({}, 80, None, Style()) + widget = Widget() + content.render_strips(widget._get_style, {}, 80, None, Style()) From d0ca435cda6ebd0b4bbeed711fb4c858aa891a1d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 24 Jul 2025 08:46:46 +0100 Subject: [PATCH 2/6] lenient parsing --- src/textual/widget.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index 782401e63a..b030e6348c 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1179,6 +1179,7 @@ def _get_style(self, style: str) -> VisualStyle: Returns: A visual style if one is fund, otherwise `None`. """ + if style.startswith("."): for node in self.ancestors_with_self: if not isinstance(node, Widget): @@ -1191,7 +1192,11 @@ def _get_style(self, style: str) -> VisualStyle: else: raise KeyError(f"No matching component class found for '{style}'") return visual_style - return VisualStyle.parse(style) + try: + visual_style = VisualStyle.parse(style) + except Exception: + visual_style = VisualStyle.null() + return visual_style @overload def render_str(self, text_content: str) -> Content: ... From f66e8943053a90274fa23467df1ee92a11bbcac5 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 24 Jul 2025 09:26:35 +0100 Subject: [PATCH 3/6] Changed visual protocol --- src/textual/content.py | 29 ++++------ src/textual/renderables/blank.py | 26 ++++----- src/textual/visual.py | 93 +++++++++++++++++--------------- src/textual/widget.py | 5 +- src/textual/widgets/_markdown.py | 20 ------- 5 files changed, 76 insertions(+), 97 deletions(-) diff --git a/src/textual/content.py b/src/textual/content.py index 4a20b5bda3..30749bdbda 100644 --- a/src/textual/content.py +++ b/src/textual/content.py @@ -33,7 +33,7 @@ from textual.selection import Selection from textual.strip import Strip from textual.style import Style -from textual.visual import RulesMap, Visual +from textual.visual import RenderOptions, RulesMap, Visual __all__ = ["ContentType", "Content", "Span"] @@ -587,26 +587,15 @@ def get_span(y: int) -> tuple[int, int] | None: return output_lines def render_strips( - self, - get_style: Callable[[str | Style], Style], - rules: RulesMap, - width: int, - height: int | None, - style: Style, - selection: Selection | None = None, - selection_style: Style | None = None, - post_style: Style | None = None, + self, width: int, height: int | None, style: Style, options: RenderOptions ) -> list[Strip]: - """Render the visual into an iterable of strips. Part of the Visual protocol. + """Render the Visual into an iterable of strips. Part of the Visual protocol. Args: - rules: A mapping of style rules, such as the Widgets `styles` object. width: Width of desired render. height: Height of desired render or `None` for any height. 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, + options: Additional render options. Returns: An list of Strips. @@ -615,7 +604,7 @@ def render_strips( if not width: return [] - get_rule = rules.get + get_rule = options.rules.get lines = self._wrap_and_format( width, align=get_rule("text_align", "left"), @@ -623,10 +612,10 @@ def render_strips( 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, - get_style=get_style, + selection=options.selection, + selection_style=options.selection_style, + post_style=options.post_style, + get_style=options.get_style, ) if height is not None: diff --git a/src/textual/renderables/blank.py b/src/textual/renderables/blank.py index 96d194ed43..12a10bdf54 100644 --- a/src/textual/renderables/blank.py +++ b/src/textual/renderables/blank.py @@ -1,15 +1,12 @@ from __future__ import annotations -from typing import Callable - from rich.style import Style as RichStyle from textual.color import Color from textual.content import Style from textual.css.styles import RulesMap -from textual.selection import Selection from textual.strip import Strip -from textual.visual import Visual +from textual.visual import RenderOptions, Visual class Blank(Visual): @@ -28,15 +25,18 @@ def get_height(self, rules: RulesMap, width: int) -> int: return 1 def render_strips( - self, - get_style: Callable[[str | Style], Style], - rules: RulesMap, - width: int, - height: int | None, - style: Style, - selection: Selection | None = None, - selection_style: Style | None = None, - post_style: Style | None = None, + self, width: int, height: int | None, style: Style, options: RenderOptions ) -> list[Strip]: + """Render the Visual into an iterable of strips. Part of the Visual protocol. + + Args: + width: Width of desired render. + height: Height of desired render or `None` for any height. + style: The base style to render on top of. + options: Additional render options. + + Returns: + An list of Strips. + """ line_count = 1 if height is None else height return [Strip.blank(width, self._rich_style)] * line_count diff --git a/src/textual/visual.py b/src/textual/visual.py index 348f509422..6431904a1b 100644 --- a/src/textual/visual.py +++ b/src/textual/visual.py @@ -1,6 +1,7 @@ from __future__ import annotations from abc import ABC, abstractmethod +from dataclasses import dataclass from itertools import islice from typing import TYPE_CHECKING, Callable, Protocol @@ -31,6 +32,21 @@ def is_visual(obj: object) -> bool: return isinstance(obj, Visual) or hasattr(obj, "textualize") +@dataclass(frozen=True) +class RenderOptions: + + get_style: Callable[[str | Style], Style] + """Callable to get a style.""" + rules: RulesMap + """Mapping of style rules.""" + selection: Selection | None = None + """Text selection information.""" + selection_style: Style | None = None + """Style of text selection.""" + post_style: Style | None = None + """Optional style to apply post render.""" + + # Note: not runtime checkable currently, as I've found that to be slow class SupportsVisual(Protocol): """An object that supports the textualize protocol.""" @@ -112,27 +128,15 @@ class Visual(ABC): @abstractmethod def render_strips( - self, - get_style: Callable[[str | Style], Style], - rules: RulesMap, - width: int, - height: int | None, - style: Style, - selection: Selection | None = None, - selection_style: Style | None = None, - post_style: Style | None = None, + self, width: int, height: int | None, style: Style, options: RenderOptions ) -> list[Strip]: """Render the Visual into an iterable of strips. Args: - rules: A mapping of style rules, such as the Widgets `styles` object. width: Width of desired render. height: Height of desired render or `None` for any height. 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: Optional style to apply post render. - get_style: Callable to get a style. + options: Additional render options. Returns: An list of Strips. @@ -218,13 +222,15 @@ def to_strips( selection_style = None strips = visual.render_strips( - widget._get_style, - widget.styles, width, height, style, - selection, - selection_style, + RenderOptions( + widget._get_style, + widget.styles, + selection, + selection_style, + ), ) strips = [strip._apply_link_style(widget.link_style) for strip in strips] @@ -306,25 +312,28 @@ def get_height(self, rules: RulesMap, width: int) -> int: return height def render_strips( - self, - get_style: Callable[[str | Style], Style], - rules: RulesMap, - width: int, - height: int | None, - style: Style, - selection: Selection | None = None, - selection_style: Style | None = None, - post_style: Style | None = None, + self, width: int, height: int | None, style: Style, options: RenderOptions ) -> list[Strip]: + """Render the Visual into an iterable of strips. Part of the Visual protocol. + + Args: + width: Width of desired render. + height: Height of desired render or `None` for any height. + style: The base style to render on top of. + options: Additional render options. + + Returns: + An list of Strips. + """ console = active_app.get().console - options = console.options.update( + console_options = console.options.update( highlight=False, width=width, height=height, ) rich_style = style.rich_style renderable = self._widget.post_render(self._renderable, rich_style) - segments = console.render(renderable, options.update_width(width)) + segments = console.render(renderable, console_options.update_width(width)) strips = [ Strip(line) for line in islice( @@ -369,16 +378,19 @@ def get_height(self, rules: RulesMap, width: int) -> int: ) def render_strips( - self, - get_style: Callable[[str | Style], Style], - rules: RulesMap, - width: int, - height: int | None, - style: Style, - selection: Selection | None = None, - selection_style: Style | None = None, - post_style: Style | None = None, + self, width: int, height: int | None, style: Style, options: RenderOptions ) -> list[Strip]: + """Render the Visual into an iterable of strips. Part of the Visual protocol. + + Args: + width: Width of desired render. + height: Height of desired render or `None` for any height. + style: The base style to render on top of. + options: Additional render options. + + Returns: + An list of Strips. + """ padding = self._spacing top, right, bottom, left = self._spacing render_width = width - (left + right) @@ -386,13 +398,10 @@ def render_strips( return [] strips = self._visual.render_strips( - get_style, - rules, render_width, None if height is None else height - padding.height, style, - selection, - selection_style, + options, ) if padding: diff --git a/src/textual/widget.py b/src/textual/widget.py index b030e6348c..fef4d011d7 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1170,7 +1170,7 @@ def iter_styles() -> Iterable[StylesBase]: return visual_style - def _get_style(self, style: str) -> VisualStyle: + def _get_style(self, style: VisualStyle | str) -> VisualStyle: """A get_style method for use in Content. Args: @@ -1179,7 +1179,8 @@ def _get_style(self, style: str) -> VisualStyle: Returns: A visual style if one is fund, otherwise `None`. """ - + if isinstance(style, VisualStyle): + return style if style.startswith("."): for node in self.ancestors_with_self: if not isinstance(node, Widget): diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 5e34007a3f..561d60b160 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -252,13 +252,6 @@ async def action_link(self, href: str) -> None: """Called on link click.""" self.post_message(Markdown.LinkClicked(self._markdown, href)) - # def rebuild(self) -> None: - # """Rebuild the content of the block if we have a source token.""" - # if self._inline_token is not None: - # self.build_from_token(self._inline_token) - # # for block in self.query_children(MarkdownBlock): - # # block.rebuild() - def build_from_token(self, token: Token) -> None: """Build inline block content from its source token. @@ -1070,19 +1063,6 @@ def get_block_class(self, block_name: str) -> type[MarkdownBlock]: """ return self.BLOCKS[block_name] - # def notify_style_update(self) -> None: - # super().notify_style_update() - - # if self.app.theme == self._theme: - # return - # self._theme = self.app.theme - - # def rebuild_all() -> None: - # for child in self.query_children(MarkdownBlock): - # child.rebuild() - - # self.call_after_refresh(rebuild_all) - async def _on_mount(self, _: Mount) -> None: initial_markdown = self._initial_markdown self._initial_markdown = None From d39e7362fe6c851e3a99bcc1fb1e7dc12d53dcb6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 24 Jul 2025 09:30:14 +0100 Subject: [PATCH 4/6] remove comments --- src/textual/visual.py | 1 + src/textual/widgets/_markdown.py | 24 ------------------------ 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/src/textual/visual.py b/src/textual/visual.py index 6431904a1b..96fe11951c 100644 --- a/src/textual/visual.py +++ b/src/textual/visual.py @@ -34,6 +34,7 @@ def is_visual(obj: object) -> bool: @dataclass(frozen=True) class RenderOptions: + """Additional options passed to `Visual.render_strips`.""" get_style: Callable[[str | Style], Style] """Callable to get a style.""" diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 561d60b160..33232d6fcf 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -719,30 +719,6 @@ def flatten(block: MarkdownBlock) -> Iterable[MarkdownBlock]: rows.pop() return headers, rows - # def rebuild(self) -> None: - # self._rebuild() - - # def _rebuild(self) -> None: - # try: - # table_content = self.query_one(MarkdownTableContent) - # except NoMatches: - # return - - # def flatten(block: MarkdownBlock) -> Iterable[MarkdownBlock]: - # for block in block._blocks: - # if block._blocks: - # yield from flatten(block) - # yield block - - # for block in flatten(self): - # if block._inline_token is not None: - # block.rebuild() - - # headers, rows = self._get_headers_and_rows() - # self._headers = headers - # self._rows = rows - # table_content._update_content(headers, rows) - async def _update_from_block(self, block: MarkdownBlock) -> None: """Special case to update a Markdown table. From c3a50a54ee1a9cb7625eeca3198ca8c866f61a6a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 24 Jul 2025 09:38:37 +0100 Subject: [PATCH 5/6] snapshot tests --- src/textual/widgets/_footer.py | 7 ++++++- .../test_snapshots/test_example_markdown.svg | 2 +- ...t_markdown_component_classes_reloading.svg | 2 +- .../test_markdown_dark_theme_override.svg | 8 ++++---- .../test_snapshots/test_markdown_example.svg | 2 +- .../test_markdown_light_theme_override.svg | 8 ++++---- .../test_markdown_space_squashing.svg | 20 +++++++++---------- .../test_markdown_theme_switching.svg | 8 ++++---- .../test_markdown_viewer_example.svg | 2 +- tests/test_content.py | 3 ++- 10 files changed, 34 insertions(+), 28 deletions(-) diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index 45b1c4ca15..f55568bea3 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -268,7 +268,12 @@ def _on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None: def on_mount(self) -> None: self.call_next(self.bindings_changed, self.screen) - self.screen.bindings_updated_signal.subscribe(self, self.bindings_changed) + + def bindings_changed(screen: Screen) -> None: + """Update bindings after a short delay to avoid flicker.""" + self.set_timer(1 / 20, lambda: self.bindings_changed(screen)) + + self.screen.bindings_updated_signal.subscribe(self, bindings_changed) def on_unmount(self) -> None: self.screen.bindings_updated_signal.unsubscribe(self) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_example_markdown.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_example_markdown.svg index 5f80f1960f..c73d5cc255 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_example_markdown.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_example_markdown.svg @@ -128,7 +128,7 @@ - + â–¼ â…  Textual Markdown Browser diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_component_classes_reloading.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_component_classes_reloading.svg index 591cf3f624..9604e40fd7 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_component_classes_reloading.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_component_classes_reloading.svg @@ -130,7 +130,7 @@ - + diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_dark_theme_override.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_dark_theme_override.svg index 929c642316..715becb479 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_dark_theme_override.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_dark_theme_override.svg @@ -37,8 +37,8 @@ .terminal-r3 { fill: #ffc473 } .terminal-r4 { fill: #ffffff } .terminal-r5 { fill: #ffc473;text-decoration: underline; } -.terminal-r6 { fill: #7dc092 } -.terminal-r7 { fill: #d2d2d2 } +.terminal-r6 { fill: #d2d2d2 } +.terminal-r7 { fill: #7dc092 } @@ -124,7 +124,7 @@ - + @@ -132,7 +132,7 @@ defmain(): -print("Hello world!") +print("Hello world!") diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_example.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_example.svg index 992787eb51..cbd50db799 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_example.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_example.svg @@ -129,7 +129,7 @@ - + diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_light_theme_override.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_light_theme_override.svg index 30e0730985..64336425b3 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_light_theme_override.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_light_theme_override.svg @@ -37,8 +37,8 @@ .terminal-r3 { fill: #a86d1c } .terminal-r4 { fill: #000000 } .terminal-r5 { fill: #a86d1c;text-decoration: underline; } -.terminal-r6 { fill: #458859 } -.terminal-r7 { fill: #d2d2d2 } +.terminal-r6 { fill: #d2d2d2 } +.terminal-r7 { fill: #458859 } @@ -124,7 +124,7 @@ - + @@ -132,7 +132,7 @@ defmain(): -print("Hello world!") +print("Hello world!") diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_space_squashing.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_space_squashing.svg index 85b58d2522..0aaecb234f 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_space_squashing.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_space_squashing.svg @@ -40,12 +40,12 @@ .terminal-r6 { fill: #e0e0e0;font-weight: bold } .terminal-r7 { fill: #e0e0e0;text-decoration: line-through; } .terminal-r8 { fill: #9f9f9f } -.terminal-r9 { fill: #ffc473 } -.terminal-r10 { fill: #ffffff } -.terminal-r11 { fill: #ffc473;font-weight: bold } -.terminal-r12 { fill: #71ac84;font-style: italic; } -.terminal-r13 { fill: #57a5e2 } -.terminal-r14 { fill: #d2d2d2 } +.terminal-r9 { fill: #d2d2d2 } +.terminal-r10 { fill: #ffc473 } +.terminal-r11 { fill: #ffffff } +.terminal-r12 { fill: #ffc473;font-weight: bold } +.terminal-r13 { fill: #71ac84;font-style: italic; } +.terminal-r14 { fill: #57a5e2 } @@ -131,7 +131,7 @@ - + │X X│X X│X X X X X X │││ @@ -145,9 +145,9 @@ │ │ │# Two spaces:  see? -│classFoo: -│'''This is    a doc    string.''' -│some_code(1,  2,   3,      4) +│classFoo: +│'''This is    a doc    string.''' +│some_code(1,  2,   3,      4) │ │ │ diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_theme_switching.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_theme_switching.svg index 30e0730985..64336425b3 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_theme_switching.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_theme_switching.svg @@ -37,8 +37,8 @@ .terminal-r3 { fill: #a86d1c } .terminal-r4 { fill: #000000 } .terminal-r5 { fill: #a86d1c;text-decoration: underline; } -.terminal-r6 { fill: #458859 } -.terminal-r7 { fill: #d2d2d2 } +.terminal-r6 { fill: #d2d2d2 } +.terminal-r7 { fill: #458859 } @@ -124,7 +124,7 @@ - + @@ -132,7 +132,7 @@ defmain(): -print("Hello world!") +print("Hello world!") diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_viewer_example.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_viewer_example.svg index 35449480ba..fd9a2fd3c6 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_viewer_example.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_viewer_example.svg @@ -130,7 +130,7 @@ - + â–¼ â…  Markdown Viewer diff --git a/tests/test_content.py b/tests/test_content.py index ad2db79ad8..04beef93fc 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -5,6 +5,7 @@ from textual.content import Content, Span from textual.style import Style +from textual.visual import RenderOptions from textual.widget import Widget @@ -284,4 +285,4 @@ def test_split_and_tabs(): content = Content("--- hello.py\t2024-01-15 10:30:00.000000000 -0800", spans=spans) widget = Widget() - content.render_strips(widget._get_style, {}, 80, None, Style()) + content.render_strips(0, None, Style(), RenderOptions(widget._get_style, {})) From a2c79b2e163d55c666b91ba8036badb2a19bbc2e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 24 Jul 2025 10:51:14 +0100 Subject: [PATCH 6/6] docstrings --- src/textual/widgets/_markdown.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 33232d6fcf..bd869069ca 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -272,21 +272,32 @@ def _token_to_content(self, token: Token) -> Content: Content instance. """ + if token.children is None: + return Content("") + tokens: list[str] = [] spans: list[Span] = [] style_stack: list[tuple[Style | str, int]] = [] position: int = 0 def add_content(text: str) -> None: + """Add text to the tokens list, and advance the position. + + Args: + text: Text to add. + + """ nonlocal position tokens.append(text) position += len(text) def add_style(style: Style | str) -> None: - style_stack.append((style, position)) + """Add a style to the stack. - if token.children is None: - return Content("") + Args: + style: A style as Style instance or string. + """ + style_stack.append((style, position)) position = 0