From 4ab9204335364c6d82a6232d97fdb94ff9dec1e2 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 12 Jul 2025 18:08:17 +0100 Subject: [PATCH 01/38] change text to content --- src/textual/widgets/_markdown.py | 83 ++++++++++++++++++++------------ tests/test_markdown.py | 4 +- tests/test_markdownviewer.py | 2 +- 3 files changed, 55 insertions(+), 34 deletions(-) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index a491e17458..f6ffe422ff 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -10,20 +10,24 @@ from markdown_it import MarkdownIt from markdown_it.token import Token from rich import box -from rich.style import Style + +# from rich.style import Style from rich.syntax import Syntax from rich.table import Table -from rich.text import Text + +# from rich.text import Text from typing_extensions import TypeAlias from textual._slug import TrackedSlugs 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.css.query import NoMatches from textual.events import Mount from textual.message import Message from textual.reactive import reactive, var +from textual.style import Style from textual.widget import Widget from textual.widgets import Static, Tree @@ -117,7 +121,7 @@ class MarkdownBlock(Static): def __init__(self, markdown: Markdown, *args, **kwargs) -> None: self._markdown: Markdown = markdown """A reference to the Markdown document that contains this block.""" - self._text = Text() + self._content = Content() self._token: Token | None = None self._blocks: list[MarkdownBlock] = [] super().__init__(*args, **kwargs) @@ -130,9 +134,9 @@ def compose(self) -> ComposeResult: yield from self._blocks self._blocks.clear() - def set_content(self, text: Text) -> None: - self._text = text - self.update(text) + def set_content(self, content: Content) -> None: + self._content = content + self.update(content) async def action_link(self, href: str) -> None: """Called on link click.""" @@ -164,44 +168,56 @@ def build_from_token(self, token: Token) -> None: self._token = token style_stack: list[Style] = [Style()] - content = Text() + # content = Content() + + pending_content: list[Content] = [] + new_line = Content("\n") + if token.children: for child in token.children: if child.type == "text": - content.append( + pending_content.append( # Ensure repeating spaces and/or tabs get squashed # down to a single space. - re.sub(r"\s+", " ", child.content), - style_stack[-1], + Content.styled( + re.sub(r"\s+", " ", child.content), style_stack[-1] + ) ) if child.type == "hardbreak": - content.append("\n") + pending_content.append(new_line) if child.type == "softbreak": - content.append(" ", style_stack[-1]) + pending_content.append(Content.styled(" ", style_stack[-1])) elif child.type == "code_inline": - content.append( - child.content, - style_stack[-1] - + self._markdown.get_component_rich_style( - "code_inline", partial=True - ), + pending_content.append( + Content.styled( + child.content, + style_stack[-1] + + self._markdown.get_visual_style( + "code_inline", partial=True + ), + ) ) + # content.append( + # child.content, + # style_stack[-1] + # + self._markdown.get_component_rich_style( + # "code_inline", partial=True + # ), + # ) elif child.type == "em_open": style_stack.append( style_stack[-1] - + self._markdown.get_component_rich_style("em", partial=True) + + self._markdown.get_visual_style("em", partial=True) ) elif child.type == "strong_open": style_stack.append( style_stack[-1] - + self._markdown.get_component_rich_style( - "strong", partial=True - ) + + self._markdown.get_visual_style("strong", partial=True) ) elif child.type == "s_open": style_stack.append( style_stack[-1] - + self._markdown.get_component_rich_style("s", partial=True) + + self._markdown.get_visual_style("s", partial=True) ) elif child.type == "link_open": href = child.attrs.get("href", "") @@ -218,18 +234,23 @@ def build_from_token(self, token: Token) -> None: style_stack[-1] + Style.from_meta({"@click": action}) ) - content.append("🖼 ", style_stack[-1]) + pending_content.append(Content.styled("🖼 ", style_stack[-1])) if alt: - content.append(f"({alt})", style_stack[-1]) + pending_content.append( + Content.styled(f"({alt})", style_stack[-1]) + ) if child.children is not None: for grandchild in child.children: - content.append(grandchild.content, style_stack[-1]) + pending_content.append( + Content.styled(grandchild.content, style_stack[-1]) + ) style_stack.pop() elif child.type.endswith("_close"): style_stack.pop() + content = Content("").join(pending_content) self.set_content(content) @@ -523,11 +544,11 @@ def flatten(block: MarkdownBlock) -> Iterable[MarkdownBlock]: rows: list[list[Text]] = [] for block in flatten(self): if isinstance(block, MarkdownTH): - headers.append(block._text) + headers.append(block._content) elif isinstance(block, MarkdownTR): rows.append([]) elif isinstance(block, MarkdownTD): - rows[-1].append(block._text) + rows[-1].append(block._content) yield MarkdownTableContent(headers, rows) self._blocks.clear() @@ -573,8 +594,8 @@ class MarkdownBullet(Widget): def get_selection(self, _selection) -> tuple[str, str] | None: return self.symbol, " " - def render(self) -> Text: - return Text(self.symbol) + def render(self) -> Content: + return Content(self.symbol) class MarkdownListItem(MarkdownBlock): @@ -986,7 +1007,7 @@ def _parse_markdown( elif token_type.endswith("_close"): block = stack.pop() if token.type == "heading_close": - heading = block._text.plain + heading = block._content.plain level = int(token.tag[1:]) table_of_contents.append((level, heading, block.id)) if stack: diff --git a/tests/test_markdown.py b/tests/test_markdown.py index 4d1214c0d8..07dc9eaa7f 100644 --- a/tests/test_markdown.py +++ b/tests/test_markdown.py @@ -107,7 +107,7 @@ async def test_softbreak_split_links_rendered_correctly() -> None: markdown = pilot.app.query_one(Markdown) paragraph = markdown.children[0] assert isinstance(paragraph, MD.MarkdownParagraph) - assert paragraph._text.plain == "My site has this URL" + assert paragraph._content.plain == "My site has this URL" expected_spans = [ Span(8, 11, Style(meta={"@click": "link('https://example.com')"})), Span(11, 12, Style(meta={"@click": "link('https://example.com')"})), @@ -116,7 +116,7 @@ async def test_softbreak_split_links_rendered_correctly() -> None: Span(17, 20, Style(meta={"@click": "link('https://example.com')"})), ] - assert paragraph._text.spans == expected_spans + assert paragraph._content.spans == expected_spans async def test_load_non_existing_file() -> None: diff --git a/tests/test_markdownviewer.py b/tests/test_markdownviewer.py index a1646f5a2e..c6b367a6a3 100644 --- a/tests/test_markdownviewer.py +++ b/tests/test_markdownviewer.py @@ -81,7 +81,7 @@ async def test_headings_that_look_like_they_contain_markup(text: str) -> None: document = f"# {text}" async with MarkdownStringViewerApp(document).run_test() as pilot: - assert pilot.app.query_one(MD.MarkdownH1)._text == Text(text) + assert pilot.app.query_one(MD.MarkdownH1)._content == Text(text) toc_tree = pilot.app.query_one(MD.MarkdownTableOfContents).query_one(Tree) # The toc label looks like "I {text}" but the I is styled so we drop it. toc_label = toc_tree.root.children[0].label From 8716980ca8b293b07ed13d551f718fa88a8d1987 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 12 Jul 2025 18:11:30 +0100 Subject: [PATCH 02/38] tables --- src/textual/widgets/_markdown.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index f6ffe422ff..f7821d40d4 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -14,8 +14,7 @@ # from rich.style import Style from rich.syntax import Syntax from rich.table import Table - -# from rich.text import Text +from rich.text import Text from typing_extensions import TypeAlias from textual._slug import TrackedSlugs @@ -544,11 +543,11 @@ def flatten(block: MarkdownBlock) -> Iterable[MarkdownBlock]: rows: list[list[Text]] = [] for block in flatten(self): if isinstance(block, MarkdownTH): - headers.append(block._content) + headers.append(Text(block._content.plain)) elif isinstance(block, MarkdownTR): rows.append([]) elif isinstance(block, MarkdownTD): - rows[-1].append(block._content) + rows[-1].append(Text(block._content.plain)) yield MarkdownTableContent(headers, rows) self._blocks.clear() From 9a804df285d14f90576d51e958b4110d419e786a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 12 Jul 2025 19:59:26 +0100 Subject: [PATCH 03/38] table grid WIP --- src/textual/layouts/grid.py | 2 +- src/textual/widgets/_markdown.py | 112 +++++++++++++++++++++---------- 2 files changed, 77 insertions(+), 37 deletions(-) diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index 5f6b934a74..20f33f1ff4 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -20,7 +20,7 @@ class GridLayout(Layout): def __init__(self) -> None: self.min_column_width: int | None = None self.stretch_height: bool = False - self.regular = False + self.regular: bool = False def arrange( self, parent: Widget, children: list[Widget], size: Size diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index f7821d40d4..e3658a3dc0 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -9,11 +9,9 @@ from markdown_it import MarkdownIt from markdown_it.token import Token -from rich import box # from rich.style import Style from rich.syntax import Syntax -from rich.table import Table from rich.text import Text from typing_extensions import TypeAlias @@ -24,6 +22,7 @@ from textual.content import Content from textual.css.query import NoMatches from textual.events import Mount +from textual.layouts.grid import GridLayout from textual.message import Message from textual.reactive import reactive, var from textual.style import Style @@ -191,9 +190,7 @@ def build_from_token(self, token: Token) -> None: Content.styled( child.content, style_stack[-1] - + self._markdown.get_visual_style( - "code_inline", partial=True - ), + + self._markdown.get_visual_style("code_inline"), ) ) # content.append( @@ -205,18 +202,15 @@ def build_from_token(self, token: Token) -> None: # ) elif child.type == "em_open": style_stack.append( - style_stack[-1] - + self._markdown.get_visual_style("em", partial=True) + style_stack[-1] + self._markdown.get_visual_style("em") ) elif child.type == "strong_open": style_stack.append( - style_stack[-1] - + self._markdown.get_visual_style("strong", partial=True) + style_stack[-1] + self._markdown.get_visual_style("strong") ) elif child.type == "s_open": style_stack.append( - style_stack[-1] - + self._markdown.get_visual_style("s", partial=True) + style_stack[-1] + self._markdown.get_visual_style("s") ) elif child.type == "link_open": href = child.attrs.get("href", "") @@ -477,9 +471,36 @@ class MarkdownTableContent(Widget): DEFAULT_CSS = """ MarkdownTableContent { - width: 100%; + width: 1fr; + height: auto; + layout: grid; + grid-columns: auto; + grid-rows: auto; + grid-gutter: 1 1; + height: auto; + # & > .cell { + # padding: 1 2; + # } + & > .cell { + margin: 0 0; + height: auto; + padding: 0 2; + + + } + & > .header { + height: auto; + margin: 0 0; + padding: 0 2; + color: $primary; + + + + } + keyline: thin $foreground 20%; + } MarkdownTableContent > .markdown-table--header { text-style: bold; @@ -488,30 +509,46 @@ class MarkdownTableContent(Widget): COMPONENT_CLASSES = {"markdown-table--header", "markdown-table--lines"} - def __init__(self, headers: list[Text], rows: list[list[Text]]): + def __init__(self, headers: list[Content], rows: list[list[Content]]): self.headers = headers """List of header text.""" self.rows = rows """The row contents.""" super().__init__() - self.shrink = True + # self.shrink = True - def render(self) -> Table: - table = Table( - expand=True, - box=box.SIMPLE_HEAD, - style=self.rich_style, - header_style=self.get_component_rich_style("markdown-table--header"), - border_style=self.get_component_rich_style("markdown-table--lines"), - collapse_padding=True, - padding=0, - ) + def compose(self) -> ComposeResult: for header in self.headers: - table.add_column(header) + yield Static(header, classes="header", shrink=False, expand=False) for row in self.rows: - if row: - table.add_row(*row) - return table + for cell in row: + yield Static(cell, classes="cell", expand=False) + + def on_mount(self) -> None: + + assert isinstance(self.layout, GridLayout) + self.layout.stretch_height = True + # self.layout.regular = True + self.styles.grid_columns = ("auto",) * (len(self.headers) - 1) + ("1fr",) + self.styles.grid_size_columns = len(self.headers) + # self.styles.grid_columns = ("1fr",) + + # def render(self) -> Table: + # table = Table( + # expand=True, + # box=box.SIMPLE_HEAD, + # style=self.rich_style, + # header_style=self.get_component_rich_style("markdown-table--header"), + # border_style=self.get_component_rich_style("markdown-table--lines"), + # collapse_padding=True, + # padding=0, + # ) + # for header in self.headers: + # table.add_column(header) + # for row in self.rows: + # if row: + # table.add_row(*row) + # return table async def action_link(self, href: str) -> None: """Pass a link action on to the MarkdownTable parent.""" @@ -524,11 +561,13 @@ class MarkdownTable(MarkdownBlock): DEFAULT_CSS = """ MarkdownTable { - width: 100%; - background: black 10%; + width: 1fr; + # background: black 10%; &:light { background: white 30%; } + + # margin: 1 2; } """ @@ -539,15 +578,15 @@ def flatten(block: MarkdownBlock) -> Iterable[MarkdownBlock]: yield from flatten(block) yield block - headers: list[Text] = [] - rows: list[list[Text]] = [] + headers: list[Content] = [] + rows: list[list[Content]] = [] for block in flatten(self): if isinstance(block, MarkdownTH): - headers.append(Text(block._content.plain)) + headers.append(block._content) elif isinstance(block, MarkdownTR): rows.append([]) elif isinstance(block, MarkdownTD): - rows[-1].append(Text(block._content.plain)) + rows[-1].append(block._content) yield MarkdownTableContent(headers, rows) self._blocks.clear() @@ -725,7 +764,7 @@ class Markdown(Widget): } } .em { - text-style: italic; + text-style: italic; } .strong { text-style: bold; @@ -734,7 +773,8 @@ class Markdown(Widget): text-style: strike; } .code_inline { - text-style: bold dim; + background: $warning-muted 30%; + color: $warning; } """ From 495133877babfa8ab2f2e37f1d81a4c1f3fe2624 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 12 Jul 2025 21:39:06 +0100 Subject: [PATCH 04/38] tooltips --- src/textual/widget.py | 2 +- src/textual/widgets/_markdown.py | 30 +++++++++++++++++------------- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index 4c92931e56..2c153e23a5 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -756,7 +756,7 @@ def tooltip(self, tooltip: VisualType | None): except NoScreen: pass - def with_tooltip(self, tooltip: RenderableType | None) -> Self: + def with_tooltip(self, tooltip: Visual | RenderableType | None) -> Self: """Chainable method to set a tooltip. Example: diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index e3658a3dc0..cdbb07d225 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -202,15 +202,18 @@ def build_from_token(self, token: Token) -> None: # ) elif child.type == "em_open": style_stack.append( - style_stack[-1] + self._markdown.get_visual_style("em") + style_stack[-1] + + self._markdown.get_visual_style("em", partial=True) ) elif child.type == "strong_open": style_stack.append( - style_stack[-1] + self._markdown.get_visual_style("strong") + style_stack[-1] + + self._markdown.get_visual_style("strong", partial=True) ) elif child.type == "s_open": style_stack.append( - style_stack[-1] + self._markdown.get_visual_style("s") + style_stack[-1] + + self._markdown.get_visual_style("s", partial=True) ) elif child.type == "link_open": href = child.attrs.get("href", "") @@ -472,6 +475,7 @@ class MarkdownTableContent(Widget): DEFAULT_CSS = """ MarkdownTableContent { width: 1fr; + height: auto; layout: grid; grid-columns: auto; @@ -479,6 +483,7 @@ class MarkdownTableContent(Widget): grid-gutter: 1 1; height: auto; + # & > .cell { # padding: 1 2; # } @@ -486,7 +491,7 @@ class MarkdownTableContent(Widget): margin: 0 0; height: auto; padding: 0 2; - + text-overflow: ellipsis; } & > .header { @@ -494,12 +499,9 @@ class MarkdownTableContent(Widget): margin: 0 0; padding: 0 2; color: $primary; - - - - + text-overflow: ellipsis; } - keyline: thin $foreground 20%; + keyline: thin $foreground 20%; } MarkdownTableContent > .markdown-table--header { @@ -515,21 +517,21 @@ def __init__(self, headers: list[Content], rows: list[list[Content]]): self.rows = rows """The row contents.""" super().__init__() - # self.shrink = True + self.shrink = True def compose(self) -> ComposeResult: for header in self.headers: - yield Static(header, classes="header", shrink=False, expand=False) + yield Static(header, classes="header").with_tooltip(header) for row in self.rows: for cell in row: - yield Static(cell, classes="cell", expand=False) + yield Static(cell, classes="cell").with_tooltip(cell) def on_mount(self) -> None: assert isinstance(self.layout, GridLayout) self.layout.stretch_height = True # self.layout.regular = True - self.styles.grid_columns = ("auto",) * (len(self.headers) - 1) + ("1fr",) + self.styles.grid_columns = ("1fr",) * (len(self.headers) - 1) + ("1fr",) self.styles.grid_size_columns = len(self.headers) # self.styles.grid_columns = ("1fr",) @@ -752,12 +754,14 @@ def compose(self) -> ComposeResult: class Markdown(Widget): DEFAULT_CSS = """ Markdown { + width:auto; height: auto; padding: 0 2 1 2; layout: vertical; color: $foreground; background: $surface; overflow-y: auto; + &:focus { background-tint: $foreground 5%; From 6f89a33b853e0d1315af1807b5d1bbb89f62c035 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 13 Jul 2025 09:52:52 +0100 Subject: [PATCH 05/38] table updates --- src/textual/widget.py | 2 + src/textual/widgets/_markdown.py | 77 ++++++++++++++++++++++++++------ 2 files changed, 66 insertions(+), 13 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index 2c153e23a5..febb033ea4 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -3652,6 +3652,8 @@ def __rich_repr__(self) -> rich.repr.Result: yield "id", self.id, None if self.name: yield "name", self.name + if self.classes: + yield "classes", " ".join(self.classes) except AttributeError: pass diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index cdbb07d225..15969d27e8 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -9,8 +9,6 @@ from markdown_it import MarkdownIt from markdown_it.token import Token - -# from rich.style import Style from rich.syntax import Syntax from rich.text import Text from typing_extensions import TypeAlias @@ -119,7 +117,7 @@ class MarkdownBlock(Static): def __init__(self, markdown: Markdown, *args, **kwargs) -> None: self._markdown: Markdown = markdown """A reference to the Markdown document that contains this block.""" - self._content = Content() + self._content: Content = Content() self._token: Token | None = None self._blocks: list[MarkdownBlock] = [] super().__init__(*args, **kwargs) @@ -136,6 +134,10 @@ def set_content(self, content: Content) -> None: self._content = content self.update(content) + async def update_from_block(self, block: MarkdownBlock) -> None: + await self.remove() + await self._markdown.mount(block) + async def action_link(self, href: str) -> None: """Called on link click.""" self.post_message(Markdown.LinkClicked(self._markdown, href)) @@ -512,26 +514,40 @@ class MarkdownTableContent(Widget): COMPONENT_CLASSES = {"markdown-table--header", "markdown-table--lines"} def __init__(self, headers: list[Content], rows: list[list[Content]]): - self.headers = headers + self.headers = headers.copy() """List of header text.""" - self.rows = rows + self.rows = rows.copy() """The row contents.""" super().__init__() self.shrink = True + self.last_row = 0 def compose(self) -> ComposeResult: for header in self.headers: yield Static(header, classes="header").with_tooltip(header) - for row in self.rows: + for row_index, row in enumerate(self.rows, 1): for cell in row: - yield Static(cell, classes="cell").with_tooltip(cell) + yield Static(cell, classes=f"row{row_index} cell").with_tooltip(cell) + self.last_row = row_index + + async def _update_rows(self, updated_rows: list[list[Content]]) -> None: + + await self.query_children(f".cell.row{self.last_row}").remove() + new_cells: list[Static] = [] + for row_index, row in enumerate(updated_rows, self.last_row): + for cell in row: + new_cells.append( + Static(cell, classes=f"row{row_index} cell").with_tooltip(cell) + ) + self.last_row = row_index + await self.mount_all(new_cells) def on_mount(self) -> None: assert isinstance(self.layout, GridLayout) self.layout.stretch_height = True # self.layout.regular = True - self.styles.grid_columns = ("1fr",) * (len(self.headers) - 1) + ("1fr",) + self.styles.grid_columns = ("auto",) * (len(self.headers) - 1) + ("1fr",) self.styles.grid_size_columns = len(self.headers) # self.styles.grid_columns = ("1fr",) @@ -573,7 +589,20 @@ class MarkdownTable(MarkdownBlock): } """ + def __init__(self, markdown: Markdown, *args, **kwargs) -> None: + super().__init__(markdown, *args, **kwargs) + self._headers: list[Content] = [] + self._rows: list[list[Content]] = [] + def compose(self) -> ComposeResult: + headers, rows = self._get_headers_and_rows() + self._headers = headers + self._rows = rows + + yield MarkdownTableContent(headers, rows) + # self._blocks.clear() + + def _get_headers_and_rows(self) -> tuple[list[Content], list[list[Content]]]: def flatten(block: MarkdownBlock) -> Iterable[MarkdownBlock]: for block in block._blocks: if block._blocks: @@ -589,9 +618,25 @@ def flatten(block: MarkdownBlock) -> Iterable[MarkdownBlock]: rows.append([]) elif isinstance(block, MarkdownTD): rows[-1].append(block._content) + if rows and not rows[-1]: + rows.pop() + return headers, rows - yield MarkdownTableContent(headers, rows) - self._blocks.clear() + async def update_from_block(self, block: MarkdownBlock) -> None: + assert isinstance(block, MarkdownTable) + try: + table_content = self.query_one(MarkdownTableContent) + except NoMatches: + pass + else: + if table_content.rows: + current_rows = self._rows + new_headers, new_rows = block._get_headers_and_rows() + updated_rows = new_rows[len(current_rows) - 1 :] + self._rows = new_rows + await table_content._update_rows(updated_rows) + return + await super().update_from_block(block) class MarkdownTBody(MarkdownBlock): @@ -1159,7 +1204,9 @@ def append(self, markdown: str) -> AwaitComplete: table_of_contents: TableOfContentsType = [] self._markdown = updated_markdown = self.source + markdown - existing_blocks = list(self.children) + existing_blocks = [ + child for child in self.children if isinstance(child, MarkdownBlock) + ] async def await_append() -> None: """Append new markdown widgets.""" @@ -1173,8 +1220,12 @@ async def await_append() -> None: async with self.lock: with self.app.batch_update(): - for block in existing_blocks[last_index:]: - await block.remove() + # for block in existing_blocks[last_index:]: + # await block.remove() + if existing_blocks and new_blocks: + last_block = existing_blocks[-1] + await last_block.update_from_block(new_blocks[last_index]) + last_index += 1 append_blocks = new_blocks[last_index:] if append_blocks: await self.mount_all(append_blocks) From 5e883ebb2de8c3d78873f640cece77ca01a6d66f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 13 Jul 2025 12:40:49 +0100 Subject: [PATCH 06/38] tidy --- src/textual/widgets/_markdown.py | 61 +++++++++++--------------------- 1 file changed, 20 insertions(+), 41 deletions(-) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 15969d27e8..16771d27f8 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -110,6 +110,7 @@ class MarkdownBlock(Static): DEFAULT_CSS = """ MarkdownBlock { + width: 1fr; height: auto; } """ @@ -134,7 +135,7 @@ def set_content(self, content: Content) -> None: self._content = content self.update(content) - async def update_from_block(self, block: MarkdownBlock) -> None: + async def _update_from_block(self, block: MarkdownBlock) -> None: await self.remove() await self._markdown.mount(block) @@ -168,7 +169,6 @@ def build_from_token(self, token: Token) -> None: self._token = token style_stack: list[Style] = [Style()] - # content = Content() pending_content: list[Content] = [] new_line = Content("\n") @@ -195,13 +195,6 @@ def build_from_token(self, token: Token) -> None: + self._markdown.get_visual_style("code_inline"), ) ) - # content.append( - # child.content, - # style_stack[-1] - # + self._markdown.get_component_rich_style( - # "code_inline", partial=True - # ), - # ) elif child.type == "em_open": style_stack.append( style_stack[-1] @@ -486,9 +479,7 @@ class MarkdownTableContent(Widget): height: auto; - # & > .cell { - # padding: 1 2; - # } + & > .cell { margin: 0 0; height: auto; @@ -549,24 +540,6 @@ def on_mount(self) -> None: # self.layout.regular = True self.styles.grid_columns = ("auto",) * (len(self.headers) - 1) + ("1fr",) self.styles.grid_size_columns = len(self.headers) - # self.styles.grid_columns = ("1fr",) - - # def render(self) -> Table: - # table = Table( - # expand=True, - # box=box.SIMPLE_HEAD, - # style=self.rich_style, - # header_style=self.get_component_rich_style("markdown-table--header"), - # border_style=self.get_component_rich_style("markdown-table--lines"), - # collapse_padding=True, - # padding=0, - # ) - # for header in self.headers: - # table.add_column(header) - # for row in self.rows: - # if row: - # table.add_row(*row) - # return table async def action_link(self, href: str) -> None: """Pass a link action on to the MarkdownTable parent.""" @@ -579,13 +552,10 @@ class MarkdownTable(MarkdownBlock): DEFAULT_CSS = """ MarkdownTable { - width: 1fr; - # background: black 10%; + width: 1fr; &:light { background: white 30%; - } - - # margin: 1 2; + } } """ @@ -598,11 +568,15 @@ def compose(self) -> ComposeResult: headers, rows = self._get_headers_and_rows() self._headers = headers self._rows = rows - yield MarkdownTableContent(headers, rows) - # self._blocks.clear() def _get_headers_and_rows(self) -> tuple[list[Content], list[list[Content]]]: + """Get list of headers, and list of rows. + + Returns: + A tuple containing a list of headers, and a list of rows. + """ + def flatten(block: MarkdownBlock) -> Iterable[MarkdownBlock]: for block in block._blocks: if block._blocks: @@ -622,7 +596,12 @@ def flatten(block: MarkdownBlock) -> Iterable[MarkdownBlock]: rows.pop() return headers, rows - async def update_from_block(self, block: MarkdownBlock) -> None: + async def _update_from_block(self, block: MarkdownBlock) -> None: + """Special case to update a Markdown table. + + Args: + block: Existing markdown block. + """ assert isinstance(block, MarkdownTable) try: table_content = self.query_one(MarkdownTableContent) @@ -631,12 +610,12 @@ async def update_from_block(self, block: MarkdownBlock) -> None: else: if table_content.rows: current_rows = self._rows - new_headers, new_rows = block._get_headers_and_rows() + _new_headers, new_rows = block._get_headers_and_rows() updated_rows = new_rows[len(current_rows) - 1 :] self._rows = new_rows await table_content._update_rows(updated_rows) return - await super().update_from_block(block) + await super()._update_from_block(block) class MarkdownTBody(MarkdownBlock): @@ -1224,7 +1203,7 @@ async def await_append() -> None: # await block.remove() if existing_blocks and new_blocks: last_block = existing_blocks[-1] - await last_block.update_from_block(new_blocks[last_index]) + await last_block._update_from_block(new_blocks[last_index]) last_index += 1 append_blocks = new_blocks[last_index:] if append_blocks: From 4a656f2de13af9721b55c05a9f95ffcb6aea8a98 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 14 Jul 2025 09:05:51 +0100 Subject: [PATCH 07/38] table crushing --- src/textual/_resolve.py | 49 +++++++++++++++++++++++++++----- src/textual/content.py | 14 +++++++++ src/textual/layouts/grid.py | 26 +++++++++++++++++ src/textual/visual.py | 14 +++++++++ src/textual/widgets/_markdown.py | 36 ++++++++++++++++++++++- 5 files changed, 131 insertions(+), 8 deletions(-) diff --git a/src/textual/_resolve.py b/src/textual/_resolve.py index 5f7e85ca81..ac84d6df8c 100644 --- a/src/textual/_resolve.py +++ b/src/textual/_resolve.py @@ -21,7 +21,10 @@ def resolve( gutter: int, size: Size, viewport: Size, - min_size: int | None = None, + *, + expand: bool = False, + shrink: bool = False, + minimums: list[int] | None = None, ) -> list[tuple[int, int]]: """Resolve a list of dimensions. @@ -49,8 +52,8 @@ def resolve( sum([scalar.value for scalar, fraction in resolved if fraction is None]) ) + total_gutter = gutter * (len(dimensions) - 1) if total_fraction: - total_gutter = gutter * (len(dimensions) - 1) consumed = sum([fraction for _, fraction in resolved if fraction is not None]) remaining = max(Fraction(0), Fraction(total - total_gutter) - consumed) fraction_unit = Fraction(remaining, total_fraction) @@ -63,12 +66,44 @@ def resolve( "list[Fraction]", [fraction for _, fraction in resolved] ) - if min_size is not None: - resolved_fractions = [ - max(Fraction(min_size), fraction) for fraction in resolved_fractions - ] - fraction_gutter = Fraction(gutter) + + if expand or shrink: + + total_space = total - total_gutter + if expand: + total_space = total - total_gutter + used_space = sum(resolved_fractions) + remaining_space = total_space - used_space + if remaining_space > 0: + resolved_fractions = [ + width + Fraction(width, used_space) * remaining_space + for width in resolved_fractions + ] + assert sum(resolved_fractions) == total_space + if shrink: + used_space = sum(resolved_fractions, start=Fraction(0)) + excess_space = used_space - total_space + + if minimums is not None and excess_space > 0: + for index, width in enumerate(resolved_fractions): + remove_space = Fraction(width, used_space) * excess_space + updated_width = max( + Fraction(minimums[index]), + width - remove_space, + ) + resolved_fractions[index] = updated_width + used_space = sum(resolved_fractions[index + 1 :]) + + used_space = sum(resolved_fractions, start=Fraction(0)) + excess_space = used_space - total_space + + if excess_space > 0: + resolved_fractions = [ + width - Fraction(width, used_space) * excess_space + for width in resolved_fractions + ] + offsets = [0] + [ fraction.__floor__() for fraction in accumulate( diff --git a/src/textual/content.py b/src/textual/content.py index 20d9480562..d4ae18b750 100644 --- a/src/textual/content.py +++ b/src/textual/content.py @@ -138,6 +138,7 @@ def __init__( self._spans: list[Span] = [] if spans is None else spans self._cell_length = cell_length self._optimal_width_cache: int | None = None + self._minimal_width_cache: int | None = None self._height_cache: tuple[tuple[int, str, bool] | None, int] = (None, 0) def __str__(self) -> str: @@ -441,6 +442,19 @@ def get_optimal_width(self, rules: RulesMap, container_width: int) -> int: width = self._optimal_width_cache return width + rules.get("line_pad", 0) * 2 + def get_minimal_width(self, rules: RulesMap) -> int: + """Minimal width is the largest single word.""" + + if self._minimal_width_cache is None: + self._minimal_width_cache = width = max( + cell_len(word) + for line in self.plain.splitlines() + for word in line.split() + ) + else: + width = self._minimal_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. diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index 20f33f1ff4..8acce81ea7 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -7,6 +7,7 @@ from textual.css.scalar import Scalar from textual.geometry import NULL_OFFSET, Region, Size, Spacing from textual.layout import ArrangeResult, Layout, WidgetPlacement +from textual.visual import visualize if TYPE_CHECKING: from textual.widget import Widget @@ -20,7 +21,14 @@ class GridLayout(Layout): def __init__(self) -> None: self.min_column_width: int | None = None self.stretch_height: bool = False + """Stretch the height of cells to be equal in each row.""" self.regular: bool = False + self.expand: bool = False + """Expand the grid to fit the container if it is smaller.""" + self.shrink: bool = False + """Shrink the grid to fit the container if it is larger.""" + self.auto_minimum: bool = False + """If self.shrink is `True`, auto-detect and limit the width.""" def arrange( self, parent: Widget, children: list[Widget], size: Size @@ -222,12 +230,30 @@ def apply_height_limits(widget: Widget, height: int) -> int: ) column_scalars[column] = Scalar.from_number(width) + column_minimums: list[int] | None = None + if self.auto_minimum and self.shrink: + column_minimums = [1] * table_size_columns + for column_index in range(table_size_columns): + for row_index in range(len(row_scalars)): + if ( + cell_info := cell_map.get((column_index, row_index)) + ) is not None: + widget = cell_info[0] + column_minimums[column_index] = max( + visualize(widget, widget.render()).get_minimal_width({}) + + widget.styles.gutter.width, + column_minimums[column_index], + ) + columns = resolve( column_scalars, size.width, gutter_vertical, size, viewport, + expand=self.expand, + shrink=self.shrink, + minimums=column_minimums, ) # Handle any auto rows diff --git a/src/textual/visual.py b/src/textual/visual.py index e4d6087c59..228e7ddc04 100644 --- a/src/textual/visual.py +++ b/src/textual/visual.py @@ -154,6 +154,20 @@ def get_optimal_width(self, rules: RulesMap, container_width: int) -> int: """ + def get_minimal_width(self, rules: RulesMap) -> int: + """Get a minimal width (the smallest width before data loss occurs). + + Args: + rules: A mapping of style rules, such as the Widgets `styles` object. + container_width: The width of the container, used by Rich Renderables. + May be ignored for Textual Visuals. + + Returns: + A width in cells. + + """ + return 1 + @abstractmethod def get_height(self, rules: RulesMap, width: int) -> int: """Get the height of the Visual if rendered at the given width. diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 16771d27f8..89c4fbf6c8 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -20,6 +20,7 @@ from textual.content import Content from textual.css.query import NoMatches from textual.events import Mount +from textual.layout import Layout from textual.layouts.grid import GridLayout from textual.message import Message from textual.reactive import reactive, var @@ -495,6 +496,11 @@ class MarkdownTableContent(Widget): text-overflow: ellipsis; } keyline: thin $foreground 20%; + # &>.full-width { + # margin-top: 2; + # column-span: 10; + # height: auto; + # } } MarkdownTableContent > .markdown-table--header { @@ -513,6 +519,12 @@ def __init__(self, headers: list[Content], rows: list[list[Content]]): self.shrink = True self.last_row = 0 + def pre_layout(self, layout: Layout) -> None: + assert isinstance(layout, GridLayout) + layout.expand = True + layout.shrink = True + layout.auto_minimum = True + def compose(self) -> ComposeResult: for header in self.headers: yield Static(header, classes="header").with_tooltip(header) @@ -521,6 +533,24 @@ def compose(self) -> ComposeResult: yield Static(cell, classes=f"row{row_index} cell").with_tooltip(cell) self.last_row = row_index + # table = Table( + # expand=True, + # box=box.SQUARE, + # style=self.rich_style, + # # header_style=self.get_component_rich_style("markdown-table--header"), + # # border_style=self.get_component_rich_style("markdown-table--lines"), + # show_lines=True, + # # collapse_padding=True, + # # padding=(1, 2), + # ) + # for header in self.headers: + # table.add_column(header.plain) + # for row in self.rows: + # if row: + # table.add_row(*[cell.plain for cell in row]) + + # yield Static(table, expand=True, shrink=True, classes="full-width") + async def _update_rows(self, updated_rows: list[list[Content]]) -> None: await self.query_children(f".cell.row{self.last_row}").remove() @@ -538,7 +568,7 @@ def on_mount(self) -> None: assert isinstance(self.layout, GridLayout) self.layout.stretch_height = True # self.layout.regular = True - self.styles.grid_columns = ("auto",) * (len(self.headers) - 1) + ("1fr",) + # self.styles.grid_columns = ("auto",) * (len(self.headers) - 1) + ("1fr",) self.styles.grid_size_columns = len(self.headers) async def action_link(self, href: str) -> None: @@ -920,6 +950,10 @@ def source(self) -> str: """The markdown source.""" return self._markdown or "" + def notify_style_update(self) -> None: + self.update(self.source) + super().notify_style_update() + async def _on_mount(self, _: Mount) -> None: if self._markdown is not None: await self.update(self._markdown) From 615163c23110da52224bffc2b182676718dfad2e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 14 Jul 2025 09:39:49 +0100 Subject: [PATCH 08/38] fix edge cases --- src/textual/_resolve.py | 2 +- src/textual/canvas.py | 4 +++- src/textual/content.py | 4 +++- src/textual/layouts/grid.py | 4 +++- src/textual/widgets/_markdown.py | 10 ++++++---- 5 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/textual/_resolve.py b/src/textual/_resolve.py index ac84d6df8c..e2b0e38f32 100644 --- a/src/textual/_resolve.py +++ b/src/textual/_resolve.py @@ -93,7 +93,7 @@ def resolve( width - remove_space, ) resolved_fractions[index] = updated_width - used_space = sum(resolved_fractions[index + 1 :]) + used_space -= updated_width used_space = sum(resolved_fractions, start=Fraction(0)) excess_space = used_space - total_space diff --git a/src/textual/canvas.py b/src/textual/canvas.py index d783e1d1b2..7fc22ee8e4 100644 --- a/src/textual/canvas.py +++ b/src/textual/canvas.py @@ -271,7 +271,9 @@ def render( text[offset:next_offset], base_style + Style.from_color( - colors[max(color_indices)].rich_color + colors[ + max(color_indices) if color_indices else 0 + ].rich_color ), ) ) diff --git a/src/textual/content.py b/src/textual/content.py index d4ae18b750..2cc37f2571 100644 --- a/src/textual/content.py +++ b/src/textual/content.py @@ -444,12 +444,14 @@ def get_optimal_width(self, rules: RulesMap, container_width: int) -> int: def get_minimal_width(self, rules: RulesMap) -> int: """Minimal width is the largest single word.""" - + if not self.plain.strip(): + return 0 if self._minimal_width_cache is None: self._minimal_width_cache = width = max( cell_len(word) for line in self.plain.splitlines() for word in line.split() + if word.strip() ) else: width = self._minimal_width_cache diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index 8acce81ea7..f2315f4f87 100644 --- a/src/textual/layouts/grid.py +++ b/src/textual/layouts/grid.py @@ -240,7 +240,9 @@ def apply_height_limits(widget: Widget, height: int) -> int: ) is not None: widget = cell_info[0] column_minimums[column_index] = max( - visualize(widget, widget.render()).get_minimal_width({}) + visualize(widget, widget.render()).get_minimal_width( + widget.styles + ) + widget.styles.gutter.width, column_minimums[column_index], ) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 89c4fbf6c8..9c4ab8a2d9 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -1233,12 +1233,14 @@ async def await_append() -> None: async with self.lock: with self.app.batch_update(): - # for block in existing_blocks[last_index:]: - # await block.remove() if existing_blocks and new_blocks: last_block = existing_blocks[-1] - await last_block._update_from_block(new_blocks[last_index]) - last_index += 1 + try: + await last_block._update_from_block(new_blocks[last_index]) + except IndexError: + pass + else: + last_index += 1 append_blocks = new_blocks[last_index:] if append_blocks: await self.mount_all(append_blocks) From 251753cdcd8b5a81fb8be53e31371516f67d8b5f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 14 Jul 2025 09:53:12 +0100 Subject: [PATCH 09/38] style inline code differently in light themes --- src/textual/widgets/_markdown.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 9c4ab8a2d9..153e438693 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -816,10 +816,17 @@ class Markdown(Widget): background: $surface; overflow-y: auto; - &:focus { background-tint: $foreground 5%; } + &:dark .code_inline { + background: $warning-muted 30%; + color: $warning; + } + &:light .code_inline { + background: $error-muted 30%; + color: $error; + } } .em { text-style: italic; @@ -830,10 +837,7 @@ class Markdown(Widget): .s { text-style: strike; } - .code_inline { - background: $warning-muted 30%; - color: $warning; - } + """ COMPONENT_CLASSES = {"em", "strong", "s", "code_inline"} From 0894fb0bc5b64753f5fe6d3385b34937a0d927e2 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 14 Jul 2025 11:41:09 +0100 Subject: [PATCH 10/38] tidy --- src/textual/_resolve.py | 10 ++----- src/textual/widgets/_markdown.py | 46 +++++--------------------------- 2 files changed, 9 insertions(+), 47 deletions(-) diff --git a/src/textual/_resolve.py b/src/textual/_resolve.py index e2b0e38f32..94e940b580 100644 --- a/src/textual/_resolve.py +++ b/src/textual/_resolve.py @@ -69,22 +69,17 @@ def resolve( fraction_gutter = Fraction(gutter) if expand or shrink: - total_space = total - total_gutter + used_space = sum(resolved_fractions) if expand: - total_space = total - total_gutter - used_space = sum(resolved_fractions) remaining_space = total_space - used_space if remaining_space > 0: resolved_fractions = [ width + Fraction(width, used_space) * remaining_space for width in resolved_fractions ] - assert sum(resolved_fractions) == total_space if shrink: - used_space = sum(resolved_fractions, start=Fraction(0)) excess_space = used_space - total_space - if minimums is not None and excess_space > 0: for index, width in enumerate(resolved_fractions): remove_space = Fraction(width, used_space) * excess_space @@ -94,8 +89,7 @@ def resolve( ) resolved_fractions[index] = updated_width used_space -= updated_width - - used_space = sum(resolved_fractions, start=Fraction(0)) + used_space = sum(resolved_fractions) excess_space = used_space - total_space if excess_space > 0: diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 153e438693..745563dda5 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -470,38 +470,28 @@ class MarkdownTableContent(Widget): DEFAULT_CSS = """ MarkdownTableContent { - width: 1fr; - + width: 1fr; height: auto; layout: grid; grid-columns: auto; grid-rows: auto; grid-gutter: 1 1; - - height: auto; - - + & > .cell { margin: 0 0; height: auto; - padding: 0 2; + padding: 0 1; text-overflow: ellipsis; } & > .header { height: auto; margin: 0 0; - padding: 0 2; + padding: 0 1; color: $primary; text-overflow: ellipsis; } - keyline: thin $foreground 20%; - # &>.full-width { - # margin-top: 2; - # column-span: 10; - # height: auto; - # } - + keyline: thin $foreground 20%; } MarkdownTableContent > .markdown-table--header { text-style: bold; @@ -524,6 +514,7 @@ def pre_layout(self, layout: Layout) -> None: layout.expand = True layout.shrink = True layout.auto_minimum = True + layout.stretch_height = True def compose(self) -> ComposeResult: for header in self.headers: @@ -533,26 +524,8 @@ def compose(self) -> ComposeResult: yield Static(cell, classes=f"row{row_index} cell").with_tooltip(cell) self.last_row = row_index - # table = Table( - # expand=True, - # box=box.SQUARE, - # style=self.rich_style, - # # header_style=self.get_component_rich_style("markdown-table--header"), - # # border_style=self.get_component_rich_style("markdown-table--lines"), - # show_lines=True, - # # collapse_padding=True, - # # padding=(1, 2), - # ) - # for header in self.headers: - # table.add_column(header.plain) - # for row in self.rows: - # if row: - # table.add_row(*[cell.plain for cell in row]) - - # yield Static(table, expand=True, shrink=True, classes="full-width") - async def _update_rows(self, updated_rows: list[list[Content]]) -> None: - + self.styles.grid_size_columns = len(self.headers) await self.query_children(f".cell.row{self.last_row}").remove() new_cells: list[Static] = [] for row_index, row in enumerate(updated_rows, self.last_row): @@ -564,11 +537,6 @@ async def _update_rows(self, updated_rows: list[list[Content]]) -> None: await self.mount_all(new_cells) def on_mount(self) -> None: - - assert isinstance(self.layout, GridLayout) - self.layout.stretch_height = True - # self.layout.regular = True - # self.styles.grid_columns = ("auto",) * (len(self.headers) - 1) + ("1fr",) self.styles.grid_size_columns = len(self.headers) async def action_link(self, href: str) -> None: From 51eaa06c79c7d0d7326e6ade22d9b15fcb54927b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 14 Jul 2025 15:03:38 +0100 Subject: [PATCH 11/38] refine markdown, fix test --- src/textual/_node_list.py | 5 +- src/textual/_resolve.py | 12 ++-- src/textual/widgets/_markdown.py | 100 ++++++++++++++++--------------- tests/test_markdownviewer.py | 3 +- 4 files changed, 65 insertions(+), 55 deletions(-) diff --git a/src/textual/_node_list.py b/src/textual/_node_list.py index 071fd35105..104fe876d1 100644 --- a/src/textual/_node_list.py +++ b/src/textual/_node_list.py @@ -152,9 +152,8 @@ def _ensure_unique_id(self, widget_id: str) -> None: """ if widget_id in self._nodes_by_id: raise DuplicateIds( - f"Tried to insert a widget with ID {widget_id!r}, but a widget {self._nodes_by_id[widget_id]!r} " - "already exists with that ID in this list of children. " - "The children of a widget must have unique IDs." + f"Tried to insert a widget with ID {widget_id!r}, but a widget {self._nodes_by_id[widget_id]!r} already exist; " + "ensure all child widgets have a unique ID." ) def _remove(self, widget: Widget) -> None: diff --git a/src/textual/_resolve.py b/src/textual/_resolve.py index 94e940b580..a1149b4f3b 100644 --- a/src/textual/_resolve.py +++ b/src/textual/_resolve.py @@ -81,14 +81,18 @@ def resolve( if shrink: excess_space = used_space - total_space if minimums is not None and excess_space > 0: + minimum_fractions = list(map(Fraction, minimums)) + remaining_space = excess_space for index, width in enumerate(resolved_fractions): - remove_space = Fraction(width, used_space) * excess_space - updated_width = max( - Fraction(minimums[index]), - width - remove_space, + remove_space = min( + used_space, Fraction(width, used_space) * excess_space ) + updated_width = max(minimum_fractions[index], width - remove_space) resolved_fractions[index] = updated_width used_space -= updated_width + if used_space <= 0: + break + excess_space = sum(resolved_fractions) - total_space used_space = sum(resolved_fractions) excess_space = used_space - total_space diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 745563dda5..23aa956cb1 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -3,6 +3,7 @@ import asyncio import re from functools import partial +from itertools import starmap from pathlib import Path, PurePath from typing import Callable, Iterable, Optional from urllib.parse import unquote @@ -169,80 +170,81 @@ def build_from_token(self, token: Token) -> None: """ self._token = token + null_style = Style.null() style_stack: list[Style] = [Style()] + pending_content: list[tuple[str, Style]] = [] - pending_content: list[Content] = [] - new_line = Content("\n") + def add_content(text: str, style: Style) -> None: + """Add text and style to output content. + + Args: + text: String of content. + style: Style of string. + """ + if pending_content: + top_text, top_style = pending_content[-1] + if top_style == style: + pending_content[-1] = (top_text + text, style) + else: + pending_content.append((text, style)) + else: + pending_content.append((text, style)) if token.children: for child in token.children: - if child.type == "text": - pending_content.append( - # Ensure repeating spaces and/or tabs get squashed - # down to a single space. - Content.styled( - re.sub(r"\s+", " ", child.content), style_stack[-1] - ) - ) - if child.type == "hardbreak": - pending_content.append(new_line) - if child.type == "softbreak": - pending_content.append(Content.styled(" ", style_stack[-1])) - elif child.type == "code_inline": - pending_content.append( - Content.styled( - child.content, - style_stack[-1] - + self._markdown.get_visual_style("code_inline"), - ) + child_type = child.type + if child_type == "text": + add_content(re.sub(r"\s+", " ", child.content), style_stack[-1]) + if child_type == "hardbreak": + add_content("\n", null_style) + if child_type == "softbreak": + add_content(" ", style_stack[-1]) + elif child_type == "code_inline": + add_content( + child.content, + style_stack[-1] + + self._markdown.get_visual_style("code_inline"), ) - elif child.type == "em_open": + elif child_type == "em_open": style_stack.append( style_stack[-1] + self._markdown.get_visual_style("em", partial=True) ) - elif child.type == "strong_open": + elif child_type == "strong_open": style_stack.append( style_stack[-1] + self._markdown.get_visual_style("strong", partial=True) ) - elif child.type == "s_open": + elif child_type == "s_open": style_stack.append( style_stack[-1] + self._markdown.get_visual_style("s", partial=True) ) - elif child.type == "link_open": + 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}) ) - elif child.type == "image": + 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}) ) - - pending_content.append(Content.styled("🖼 ", style_stack[-1])) + add_content("🖼 ", style_stack[-1]) if alt: - pending_content.append( - Content.styled(f"({alt})", style_stack[-1]) - ) + add_content(f"({alt})", style_stack[-1]) if child.children is not None: for grandchild in child.children: - pending_content.append( - Content.styled(grandchild.content, style_stack[-1]) - ) - + add_content(grandchild.content, style_stack[-1]) style_stack.pop() - elif child.type.endswith("_close"): + elif child_type.endswith("_close"): style_stack.pop() - content = Content("").join(pending_content) + content = Content("").join(starmap(Content.styled, pending_content)) self.set_content(content) @@ -511,9 +513,9 @@ def __init__(self, headers: list[Content], rows: list[list[Content]]): def pre_layout(self, layout: Layout) -> None: assert isinstance(layout, GridLayout) + layout.auto_minimum = True layout.expand = True layout.shrink = True - layout.auto_minimum = True layout.stretch_height = True def compose(self) -> ComposeResult: @@ -550,7 +552,8 @@ class MarkdownTable(MarkdownBlock): DEFAULT_CSS = """ MarkdownTable { - width: 1fr; + width: 1fr; + margin-bottom: 1; &:light { background: white 30%; } @@ -1189,33 +1192,36 @@ def append(self, markdown: str) -> AwaitComplete: table_of_contents: TableOfContentsType = [] self._markdown = updated_markdown = self.source + markdown - existing_blocks = [ - child for child in self.children if isinstance(child, MarkdownBlock) - ] async def await_append() -> None: """Append new markdown widgets.""" - tokens = await asyncio.get_running_loop().run_in_executor( None, parser.parse, updated_markdown ) + existing_blocks = [ + child for child in self.children if isinstance(child, MarkdownBlock) + ] new_blocks = list(self._parse_markdown(tokens, table_of_contents)) - last_index = len(existing_blocks) - 1 async with self.lock: with self.app.batch_update(): if existing_blocks and new_blocks: - last_block = existing_blocks[-1] + last_block = existing_blocks[last_index] try: await last_block._update_from_block(new_blocks[last_index]) except IndexError: pass else: last_index += 1 + append_blocks = new_blocks[last_index:] if append_blocks: - await self.mount_all(append_blocks) + self.log(append=append_blocks, children=self.children) + try: + await self.mount_all(append_blocks) + except Exception as error: + self.log(error) self._table_of_contents = table_of_contents self.post_message( diff --git a/tests/test_markdownviewer.py b/tests/test_markdownviewer.py index c6b367a6a3..31c665a102 100644 --- a/tests/test_markdownviewer.py +++ b/tests/test_markdownviewer.py @@ -5,6 +5,7 @@ import textual.widgets._markdown as MD from textual.app import App, ComposeResult +from textual.content import Content from textual.geometry import Offset from textual.widgets import Markdown, MarkdownViewer, Tree @@ -81,7 +82,7 @@ async def test_headings_that_look_like_they_contain_markup(text: str) -> None: document = f"# {text}" async with MarkdownStringViewerApp(document).run_test() as pilot: - assert pilot.app.query_one(MD.MarkdownH1)._content == Text(text) + assert pilot.app.query_one(MD.MarkdownH1)._content == Content(text) toc_tree = pilot.app.query_one(MD.MarkdownTableOfContents).query_one(Tree) # The toc label looks like "I {text}" but the I is styled so we drop it. toc_label = toc_tree.root.children[0].label From fb5fab375425ddfe6ef5ef0d2438362ac4e25408 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 14 Jul 2025 15:08:29 +0100 Subject: [PATCH 12/38] header align --- src/textual/widgets/_markdown.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 23aa956cb1..3e5d3d39df 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -492,6 +492,7 @@ class MarkdownTableContent(Widget): padding: 0 1; color: $primary; text-overflow: ellipsis; + content-align: left bottom; } keyline: thin $foreground 20%; } From df8e4da79b48673878a3d38fb96a3e4a82de2fce Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 14 Jul 2025 17:13:36 +0100 Subject: [PATCH 13/38] simplify --- src/textual/widgets/_markdown.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 3e5d3d39df..8f30deb76f 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -190,6 +190,7 @@ def add_content(text: str, style: Style) -> None: else: pending_content.append((text, style)) + get_visual_style = self._markdown.get_visual_style if token.children: for child in token.children: child_type = child.type @@ -202,23 +203,19 @@ def add_content(text: str, style: Style) -> None: elif child_type == "code_inline": add_content( child.content, - style_stack[-1] - + self._markdown.get_visual_style("code_inline"), + style_stack[-1] + get_visual_style("code_inline"), ) elif child_type == "em_open": style_stack.append( - style_stack[-1] - + self._markdown.get_visual_style("em", partial=True) + style_stack[-1] + get_visual_style("em", partial=True) ) elif child_type == "strong_open": style_stack.append( - style_stack[-1] - + self._markdown.get_visual_style("strong", partial=True) + style_stack[-1] + get_visual_style("strong", partial=True) ) elif child_type == "s_open": style_stack.append( - style_stack[-1] - + self._markdown.get_visual_style("s", partial=True) + style_stack[-1] + get_visual_style("s", partial=True) ) elif child_type == "link_open": href = child.attrs.get("href", "") From 509bcf69850e7643963021289a886476ce27645b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 14 Jul 2025 17:17:26 +0100 Subject: [PATCH 14/38] simplify --- src/textual/widgets/_markdown.py | 100 ++++++++++++++++--------------- 1 file changed, 51 insertions(+), 49 deletions(-) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 8f30deb76f..defbe4e221 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -191,55 +191,57 @@ def add_content(text: str, style: Style) -> None: pending_content.append((text, style)) get_visual_style = self._markdown.get_visual_style - if token.children: - for child in token.children: - child_type = child.type - if child_type == "text": - add_content(re.sub(r"\s+", " ", child.content), style_stack[-1]) - if child_type == "hardbreak": - add_content("\n", null_style) - if child_type == "softbreak": - add_content(" ", style_stack[-1]) - elif child_type == "code_inline": - add_content( - child.content, - style_stack[-1] + get_visual_style("code_inline"), - ) - elif child_type == "em_open": - style_stack.append( - style_stack[-1] + get_visual_style("em", partial=True) - ) - elif child_type == "strong_open": - style_stack.append( - style_stack[-1] + get_visual_style("strong", partial=True) - ) - elif child_type == "s_open": - style_stack.append( - style_stack[-1] + get_visual_style("s", partial=True) - ) - 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}) - ) - 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]) - if alt: - add_content(f"({alt})", style_stack[-1]) - if child.children is not None: - for grandchild in child.children: - add_content(grandchild.content, style_stack[-1]) - style_stack.pop() - - elif child_type.endswith("_close"): - style_stack.pop() + if token.children is None: + self.set_content(Content("")) + return + for child in token.children: + child_type = child.type + if child_type == "text": + add_content(re.sub(r"\s+", " ", child.content), style_stack[-1]) + if child_type == "hardbreak": + add_content("\n", null_style) + if child_type == "softbreak": + add_content(" ", style_stack[-1]) + elif child_type == "code_inline": + add_content( + child.content, + style_stack[-1] + get_visual_style("code_inline"), + ) + elif child_type == "em_open": + style_stack.append( + style_stack[-1] + get_visual_style("em", partial=True) + ) + elif child_type == "strong_open": + style_stack.append( + style_stack[-1] + get_visual_style("strong", partial=True) + ) + elif child_type == "s_open": + style_stack.append( + style_stack[-1] + get_visual_style("s", partial=True) + ) + 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}) + ) + 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]) + if alt: + add_content(f"({alt})", style_stack[-1]) + if child.children is not None: + for grandchild in child.children: + add_content(grandchild.content, style_stack[-1]) + style_stack.pop() + + elif child_type.endswith("_close"): + style_stack.pop() content = Content("").join(starmap(Content.styled, pending_content)) self.set_content(content) From 9fb7d77480aa97012a7af1c16029e84507cfe437 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 14 Jul 2025 17:43:14 +0100 Subject: [PATCH 15/38] repair table crush --- src/textual/_resolve.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/textual/_resolve.py b/src/textual/_resolve.py index a1149b4f3b..1d0c4cfe01 100644 --- a/src/textual/_resolve.py +++ b/src/textual/_resolve.py @@ -79,20 +79,20 @@ def resolve( for width in resolved_fractions ] if shrink: + one = Fraction(1) excess_space = used_space - total_space if minimums is not None and excess_space > 0: - minimum_fractions = list(map(Fraction, minimums)) - remaining_space = excess_space - for index, width in enumerate(resolved_fractions): - remove_space = min( - used_space, Fraction(width, used_space) * excess_space - ) - updated_width = max(minimum_fractions[index], width - remove_space) + for index, (minimum_width, width) in enumerate( + zip(map(Fraction, minimums), resolved_fractions) + ): + remove_space = max(Fraction(width, used_space), one) * excess_space + updated_width = max(minimum_width, width - remove_space) resolved_fractions[index] = updated_width - used_space -= updated_width - if used_space <= 0: + used_space = used_space - width + updated_width + excess_space = used_space - total_space + if excess_space <= 0: break - excess_space = sum(resolved_fractions) - total_space + used_space = sum(resolved_fractions) excess_space = used_space - total_space From d025fa8afd6c596e077d218b885b81d554d8bb02 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Jul 2025 08:38:29 +0100 Subject: [PATCH 16/38] snapshot test --- src/textual/app.py | 1 - src/textual/widgets/_markdown.py | 34 +++++++++---- .../test_content_switcher_example_switch.svg | 49 ++++++++++--------- .../test_snapshots/test_example_markdown.svg | 4 +- ...t_markdown_component_classes_reloading.svg | 46 ++++++++--------- .../test_snapshots/test_markdown_example.svg | 6 +-- .../test_markdown_viewer_example.svg | 17 ++++--- tests/test_markdown.py | 16 +++--- 8 files changed, 96 insertions(+), 77 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 310893a9a6..bca02350da 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3985,7 +3985,6 @@ async def run_action( action_target, action_name, params = self._parse_action( action, self if default_namespace is None else default_namespace ) - if action_target.check_action(action_name, params): return await self._dispatch_action(action_target, action_name, params) else: diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index defbe4e221..7b460b2401 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -466,6 +466,17 @@ def compose(self) -> ComposeResult: self._blocks.clear() +class MarkdownTableCellContents(Static): + """Widget for table cells. + + A shim over a Static which responds to links. + """ + + async def action_link(self, href: str) -> None: + """Pass a link action on to the MarkdownTable parent.""" + self.post_message(Markdown.LinkClicked(self.query_ancestor(Markdown), href)) + + class MarkdownTableContent(Widget): """Renders a Markdown table.""" @@ -520,10 +531,14 @@ def pre_layout(self, layout: Layout) -> None: def compose(self) -> ComposeResult: for header in self.headers: - yield Static(header, classes="header").with_tooltip(header) + yield MarkdownTableCellContents(header, classes="header").with_tooltip( + header + ) for row_index, row in enumerate(self.rows, 1): for cell in row: - yield Static(cell, classes=f"row{row_index} cell").with_tooltip(cell) + yield MarkdownTableCellContents( + cell, classes=f"row{row_index} cell" + ).with_tooltip(cell.plain) self.last_row = row_index async def _update_rows(self, updated_rows: list[list[Content]]) -> None: @@ -1123,7 +1138,6 @@ def update(self, markdown: str) -> AwaitComplete: table_of_contents: TableOfContentsType = [] markdown_block = self.query("MarkdownBlock") - self._markdown = markdown async def await_update() -> None: @@ -1164,13 +1178,13 @@ async def mount_batch(batch: list[MarkdownBlock]) -> None: if not removed: await markdown_block.remove() - self._table_of_contents = table_of_contents - - self.post_message( - Markdown.TableOfContentsUpdated( - self, self._table_of_contents - ).set_sender(self) - ) + if table_of_contents != self._table_of_contents: + self._table_of_contents = table_of_contents + self.post_message( + Markdown.TableOfContentsUpdated( + self, self._table_of_contents + ).set_sender(self) + ) return AwaitComplete(await_update()) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_content_switcher_example_switch.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_content_switcher_example_switch.svg index 2c52f7cb6d..debe011423 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_content_switcher_example_switch.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_content_switcher_example_switch.svg @@ -41,6 +41,7 @@ .terminal-r7 { fill: #0178d4;font-weight: bold } .terminal-r8 { fill: #e0e0e0 } .terminal-r9 { fill: #ffff00;text-decoration: underline; } +.terminal-r10 { fill: #444444 } @@ -204,7 +205,7 @@ - + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ @@ -223,35 +224,35 @@ Shaun of the Dead - -UK Release   -Flavour   Date        Director    - ───────────────────────────────────  - Strawberry 2004-04-09   Edgar        -                         Wright       - +┌───────────┬────────────┬──────────┐ +UK Release +FlavourDateDirector +├───────────┼────────────┼──────────┤ +Strawber…2004-04-09Edgar +Wright +└───────────┴────────────┴──────────┘ Hot Fuzz - -UK Release    -Flavour Date         Director     - ───────────────────────────────────  - Classico 2007-02-17    Edgar Wright  - - +┌──────────┬────────────┬───────────┐ +UK Release +FlavourDateDirector +├──────────┼────────────┼───────────┤ +Classico2007-02-17Edgar +Wright +└──────────┴────────────┴───────────┘ -The World's End - + +The World's End -UK Release     -FlavourDate          Director     - ───────────────────────────────────  - Mint    2013-07-19     Edgar Wright  - - - +┌─────────┬────────────┬────────────┐ +UK Release +FlavourDateDirector +├─────────┼────────────┼────────────┤ +Mint2013-07-19Edgar +Wright +└─────────┴────────────┴────────────┘ ╰─────────────────────────────────────────╯ 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 2ad5e8656e..e5cea4a0a6 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_example_markdown.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_example_markdown.svg @@ -37,7 +37,7 @@ .terminal-r3 { fill: #a0a3a6 } .terminal-r4 { fill: #3e3e3e } .terminal-r5 { fill: #0178d4;font-weight: bold } -.terminal-r6 { fill: #a1a1a1;font-weight: bold } +.terminal-r6 { fill: #fea62b } .terminal-r7 { fill: #0178d4;text-decoration: underline; } .terminal-r8 { fill: #e2e2e2;text-decoration: underline; } .terminal-r9 { fill: #ffa62b;font-weight: bold } @@ -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 405bbd86f1..9320d97b50 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 @@ -34,15 +34,17 @@ .terminal-r1 { fill: #c5c8c6 } .terminal-r2 { fill: #0178d4;font-weight: bold } -.terminal-r3 { fill: #e0e0e0 } -.terminal-r4 { fill: #e0e0e0;font-weight: bold } -.terminal-r5 { fill: #9e9e9e;font-weight: bold } -.terminal-r6 { fill: #e0e0e0;font-style: italic; } -.terminal-r7 { fill: #e0e0e0;text-decoration: line-through; } -.terminal-r8 { fill: #d2d2d2 } -.terminal-r9 { fill: #82aaff } -.terminal-r10 { fill: #89ddff } -.terminal-r11 { fill: #c3e88d } +.terminal-r3 { fill: #444444 } +.terminal-r4 { fill: #0178d4 } +.terminal-r5 { fill: #e0e0e0 } +.terminal-r6 { fill: #fea62b } +.terminal-r7 { fill: #e0e0e0;font-weight: bold } +.terminal-r8 { fill: #e0e0e0;font-style: italic; } +.terminal-r9 { fill: #e0e0e0;text-decoration: line-through; } +.terminal-r10 { fill: #d2d2d2 } +.terminal-r11 { fill: #82aaff } +.terminal-r12 { fill: #89ddff } +.terminal-r13 { fill: #c3e88d } @@ -128,26 +130,26 @@ - + This is a header - -col1                                 col2                                 - ──────────────────────────────────────────────────────────────────────────  - value 1                               value 2                               - -Here's some code: from itertools import productBold textEmphasized text -strikethrough - +┌────────────────────────────────────┬─────────────────────────────────────┐ +col1col2 +├────────────────────────────────────┼─────────────────────────────────────┤ +value 1value 2 +└────────────────────────────────────┴─────────────────────────────────────┘ + +Here's some code: from itertools import productBold textEmphasized text +strikethrough -print("Hello, world!") - + +print("Hello, world!") -That was some code. - + +That was some code. 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 a8663c5520..5158a5968b 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_example.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_example.svg @@ -39,7 +39,7 @@ .terminal-r5 { fill: #e0e0e0 } .terminal-r6 { fill: #e0e0e0;font-style: italic; } .terminal-r7 { fill: #e0e0e0;font-weight: bold } -.terminal-r8 { fill: #9e9e9e;font-weight: bold } +.terminal-r8 { fill: #fea62b } .terminal-r9 { fill: #000000 } .terminal-r10 { fill: #0f4b79 } .terminal-r11 { fill: #134f7d } @@ -129,7 +129,7 @@ - + @@ -141,7 +141,7 @@ ● Syntax highlighted code blocks ● Tables and more -▁▁ +▄▄ Quotes I must not fear. 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 3f862179f2..df350ad305 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 @@ -38,12 +38,13 @@ .terminal-r4 { fill: #a0a3a6 } .terminal-r5 { fill: #3e3e3e } .terminal-r6 { fill: #0178d4;font-weight: bold } -.terminal-r7 { fill: #a1a1a1;font-weight: bold } -.terminal-r8 { fill: #0178d4;text-decoration: underline; } -.terminal-r9 { fill: #000000 } +.terminal-r7 { fill: #fea62b } +.terminal-r8 { fill: #000000 } +.terminal-r9 { fill: #0178d4;text-decoration: underline; } .terminal-r10 { fill: #e2e2e2;font-weight: bold } .terminal-r11 { fill: #e0e0e0;font-style: italic; } .terminal-r12 { fill: #e0e0e0;font-weight: bold } +.terminal-r13 { fill: #4c4c4c } @@ -129,7 +130,7 @@ - + ▼ Ⅰ Markdown Viewer @@ -138,8 +139,8 @@ ├── Ⅱ Code BlocksThis is an example of Textual's MarkdownViewer └── Ⅱ Litany Against Fearwidget. - -Features▄▄ +▄▄ +Features Markdown syntax and extensions are supported. @@ -150,11 +151,11 @@ ● Tables! -Tables +Tables Tables are displayed in a DataTable widget. - +┌──────────────┬──────┬─────────┬─────────────┐ diff --git a/tests/test_markdown.py b/tests/test_markdown.py index 07dc9eaa7f..c4f548e42b 100644 --- a/tests/test_markdown.py +++ b/tests/test_markdown.py @@ -7,12 +7,12 @@ import pytest from markdown_it.token import Token -from rich.style import Style from rich.text import Span import textual.widgets._markdown as MD from textual import on from textual.app import App, ComposeResult +from textual.style import Style from textual.widget import Widget from textual.widgets import Markdown from textual.widgets.markdown import MarkdownBlock @@ -108,13 +108,13 @@ async def test_softbreak_split_links_rendered_correctly() -> None: paragraph = markdown.children[0] assert isinstance(paragraph, MD.MarkdownParagraph) assert paragraph._content.plain == "My site has this URL" + print(paragraph._content.spans) + expected_spans = [ - Span(8, 11, Style(meta={"@click": "link('https://example.com')"})), - Span(11, 12, Style(meta={"@click": "link('https://example.com')"})), - Span(12, 16, Style(meta={"@click": "link('https://example.com')"})), - Span(16, 17, Style(meta={"@click": "link('https://example.com')"})), - Span(17, 20, Style(meta={"@click": "link('https://example.com')"})), + Span(0, 8, Style()), + Span(8, 20, Style.from_meta({"@click": "link('https://example.com')"})), ] + print(expected_spans) assert paragraph._content.spans == expected_spans @@ -160,6 +160,7 @@ def log_table_of_content_update( messages.append(event.__class__.__name__) async with TableOfContentApp().run_test() as pilot: + assert messages == ["TableOfContentsUpdated"] await pilot.app.query_one(Markdown).update("") await pilot.pause() @@ -195,7 +196,8 @@ def log_markdown_link_clicked( app = MarkdownTableApp() async with app.run_test() as pilot: - await pilot.click(Markdown, offset=(3, 3)) + await pilot.click(Markdown, offset=(8, 3)) + print(app.messages) assert app.messages == ["LinkClicked"] From bc45a3db89697e69da954e73bb39437f20c358a3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Jul 2025 08:40:49 +0100 Subject: [PATCH 17/38] changelog --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6d0a276ca..2b8a81dd0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ 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 + +### Added + +- Added get_minimal_width to Visual protocol +- Added `expand` and `shrink` attributes to GridLayout + +### Changed + +- Improved rendering of Markdown tables (replace Rich table with grid) which allows text selection ## [4.0.0] - 2025-07-12 From a5a8fc6598c53a228f014d25c8931e6d01d5f48e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Jul 2025 08:56:26 +0100 Subject: [PATCH 18/38] changelog --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b8a81dd0f..d16e5d5c80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added -- Added get_minimal_width to Visual protocol -- Added `expand` and `shrink` attributes to GridLayout +- Added get_minimal_width to Visual protocol https://github.com/Textualize/textual/pull/5962 +- Added `expand` and `shrink` attributes to GridLayout https://github.com/Textualize/textual/pull/5962 ### Changed -- Improved rendering of Markdown tables (replace Rich table with grid) which allows text selection +- Improved rendering of Markdown tables (replace Rich table with grid) which allows text selection https://github.com/Textualize/textual/pull/5962 ## [4.0.0] - 2025-07-12 From 1151113ba3ce6dc4264ad005adae818a4ee30794 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Jul 2025 09:04:31 +0100 Subject: [PATCH 19/38] markdown background and snapshots --- src/textual/widgets/_markdown.py | 4 +- .../test_content_switcher_example_switch.svg | 4 +- .../test_snapshots/test_example_markdown.svg | 4 +- .../test_snapshots/test_markdown_append.svg | 6 +-- ...t_markdown_component_classes_reloading.svg | 4 +- .../test_markdown_dark_theme_override.svg | 4 +- .../test_snapshots/test_markdown_example.svg | 39 +++++++++---------- .../test_markdown_light_theme_override.svg | 4 +- .../test_markdown_space_squashing.svg | 8 ++-- .../test_markdown_theme_switching.svg | 4 +- .../test_markdown_viewer_example.svg | 6 +-- .../test_snapshots/test_tabbed_content.svg | 2 +- 12 files changed, 44 insertions(+), 45 deletions(-) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 7b460b2401..516d30ce39 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -794,12 +794,12 @@ def compose(self) -> ComposeResult: class Markdown(Widget): DEFAULT_CSS = """ Markdown { - width:auto; + width:1fr; height: auto; padding: 0 2 1 2; layout: vertical; color: $foreground; - background: $surface; + # background: $surface; overflow-y: auto; &:focus { diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_content_switcher_example_switch.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_content_switcher_example_switch.svg index debe011423..663fd86896 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_content_switcher_example_switch.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_content_switcher_example_switch.svg @@ -41,7 +41,7 @@ .terminal-r7 { fill: #0178d4;font-weight: bold } .terminal-r8 { fill: #e0e0e0 } .terminal-r9 { fill: #ffff00;text-decoration: underline; } -.terminal-r10 { fill: #444444 } +.terminal-r10 { fill: #3b3b3b } @@ -205,7 +205,7 @@ - + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ 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 e5cea4a0a6..d76a6a5ea1 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_example_markdown.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_example_markdown.svg @@ -39,7 +39,7 @@ .terminal-r5 { fill: #0178d4;font-weight: bold } .terminal-r6 { fill: #fea62b } .terminal-r7 { fill: #0178d4;text-decoration: underline; } -.terminal-r8 { fill: #e2e2e2;text-decoration: underline; } +.terminal-r8 { fill: #e1e1e1;text-decoration: underline; } .terminal-r9 { fill: #ffa62b;font-weight: bold } .terminal-r10 { fill: #b47d2f;font-weight: bold } .terminal-r11 { fill: #495259 } @@ -128,7 +128,7 @@ - + ▼ Ⅰ Textual Markdown Browser diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_append.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_append.svg index 8c29c0af03..272f498b0a 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_append.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_append.svg @@ -34,9 +34,9 @@ .terminal-r1 { fill: #c5c8c6 } .terminal-r2 { fill: #0178d4;font-weight: bold } -.terminal-r3 { fill: #e1e1e1;font-weight: bold } +.terminal-r3 { fill: #e0e0e0;font-weight: bold } .terminal-r4 { fill: #e0e0e0 } -.terminal-r5 { fill: #0f4b79 } +.terminal-r5 { fill: #094573 } @@ -122,7 +122,7 @@ - + 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 9320d97b50..6e7213f121 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 @@ -34,7 +34,7 @@ .terminal-r1 { fill: #c5c8c6 } .terminal-r2 { fill: #0178d4;font-weight: bold } -.terminal-r3 { fill: #444444 } +.terminal-r3 { fill: #3b3b3b } .terminal-r4 { fill: #0178d4 } .terminal-r5 { fill: #e0e0e0 } .terminal-r6 { fill: #fea62b } @@ -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 2a14ce6429..a27c354ba5 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 @@ -38,7 +38,7 @@ .terminal-r4 { fill: #859900 } .terminal-r5 { fill: #839496 } .terminal-r6 { fill: #268bd2 } -.terminal-r7 { fill: #435156;font-style: italic; } +.terminal-r7 { fill: #3f4e52;font-style: italic; } .terminal-r8 { fill: #2aa198 } @@ -125,7 +125,7 @@ - + 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 5158a5968b..c538a6c0cd 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_example.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_example.svg @@ -35,15 +35,14 @@ .terminal-r1 { fill: #c5c8c6 } .terminal-r2 { fill: #121212 } .terminal-r3 { fill: #0178d4;text-decoration: underline; } -.terminal-r4 { fill: #e1e1e1;font-weight: bold } +.terminal-r4 { fill: #e0e0e0;font-weight: bold } .terminal-r5 { fill: #e0e0e0 } .terminal-r6 { fill: #e0e0e0;font-style: italic; } -.terminal-r7 { fill: #e0e0e0;font-weight: bold } -.terminal-r8 { fill: #fea62b } -.terminal-r9 { fill: #000000 } -.terminal-r10 { fill: #0f4b79 } -.terminal-r11 { fill: #134f7d } -.terminal-r12 { fill: #175381 } +.terminal-r7 { fill: #fea62b } +.terminal-r8 { fill: #000000 } +.terminal-r9 { fill: #094573 } +.terminal-r10 { fill: #0e4977 } +.terminal-r11 { fill: #124d7b } @@ -129,31 +128,31 @@ - + Markdown -● Typography emphasisstronginline code etc. +● Typography emphasisstronginline code etc. ● Headers ● Lists ● Syntax highlighted code blocks ● Tables and more -▄▄ +▄▄ Quotes -I must not fear. - -Fear is the mind-killer. Fear is the little-death that brings total -obliteration. I will face my fear. - -I will permit it to pass over me and through me. And when it has -gone past, I will turn the inner eye to see its path. Where the -fear has gone there will be nothing. Only I will remain. - - +I must not fear. + +Fear is the mind-killer. Fear is the little-death that brings total +obliteration. I will face my fear. + +I will permit it to pass over me and through me. And when it has +gone past, I will turn the inner eye to see its path. Where the +fear has gone there will be nothing. Only I will remain. + + 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 02d9405d4c..1137c5c82e 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 @@ -38,7 +38,7 @@ .terminal-r4 { fill: #859900 } .terminal-r5 { fill: #657b83 } .terminal-r6 { fill: #268bd2 } -.terminal-r7 { fill: #aeb7b7;font-style: italic; } +.terminal-r7 { fill: #b0b9b9;font-style: italic; } .terminal-r8 { fill: #2aa198 } @@ -125,7 +125,7 @@ - + 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 78d342448b..0c07aa4f8f 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 @@ -35,8 +35,8 @@ .terminal-r1 { fill: #ff0000 } .terminal-r2 { fill: #c5c8c6 } .terminal-r3 { fill: #e0e0e0 } -.terminal-r4 { fill: #1e1e1e } -.terminal-r5 { fill: #e1e1e1;text-decoration: underline; } +.terminal-r4 { fill: #121212 } +.terminal-r5 { fill: #e0e0e0;text-decoration: underline; } .terminal-r6 { fill: #e0e0e0;font-style: italic; } .terminal-r7 { fill: #e0e0e0;font-weight: bold } .terminal-r8 { fill: #000000 } @@ -47,7 +47,7 @@ .terminal-r13 { fill: #eeffff } .terminal-r14 { fill: #ffcb6b } .terminal-r15 { fill: #89ddff } -.terminal-r16 { fill: #405159;font-style: italic; } +.terminal-r16 { fill: #3c4e55;font-style: italic; } .terminal-r17 { fill: #f78c6c } @@ -134,7 +134,7 @@ - + X XX XX X X X X X 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 6967488ec3..109f4822a9 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 @@ -39,7 +39,7 @@ .terminal-r5 { fill: #bbbbbb } .terminal-r6 { fill: #0000ff } .terminal-r7 { fill: #000000 } -.terminal-r8 { fill: #759e9e;font-style: italic; } +.terminal-r8 { fill: #77a0a0;font-style: italic; } .terminal-r9 { fill: #008000 } .terminal-r10 { fill: #ba2121 } @@ -127,7 +127,7 @@ - + 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 df350ad305..93ed375c09 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 @@ -41,10 +41,10 @@ .terminal-r7 { fill: #fea62b } .terminal-r8 { fill: #000000 } .terminal-r9 { fill: #0178d4;text-decoration: underline; } -.terminal-r10 { fill: #e2e2e2;font-weight: bold } +.terminal-r10 { fill: #e1e1e1;font-weight: bold } .terminal-r11 { fill: #e0e0e0;font-style: italic; } .terminal-r12 { fill: #e0e0e0;font-weight: bold } -.terminal-r13 { fill: #4c4c4c } +.terminal-r13 { fill: #444444 } @@ -130,7 +130,7 @@ - + ▼ Ⅰ Markdown Viewer diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_tabbed_content.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_tabbed_content.svg index 8b4b808bdf..a7631db4ce 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_tabbed_content.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_tabbed_content.svg @@ -127,7 +127,7 @@ - + LetoJessicaPaul ━━━━━━╸━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ From 2d055ccf8c243311b25897a2c7c6153f10dc032a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Jul 2025 11:29:42 +0100 Subject: [PATCH 20/38] update dependancies --- poetry.lock | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/poetry.lock b/poetry.lock index 22bf5c6802..653b4f1564 100644 --- a/poetry.lock +++ b/poetry.lock @@ -308,13 +308,13 @@ redis = ["redis (>=2.10.5)"] [[package]] name = "certifi" -version = "2025.6.15" +version = "2025.7.14" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" files = [ - {file = "certifi-2025.6.15-py3-none-any.whl", hash = "sha256:2e0c7ce7cb5d8f8634ca55d2ba7e6ec2689a2fd6537d8dec1296a477a4910057"}, - {file = "certifi-2025.6.15.tar.gz", hash = "sha256:d747aa5a8b9bbbb1bb8c22bb13e22bd1f18e9796defa16bab421f7f7a317323b"}, + {file = "certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2"}, + {file = "certifi-2025.7.14.tar.gz", hash = "sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995"}, ] [[package]] @@ -1186,13 +1186,13 @@ dev = ["click", "codecov", "mkdocs-gen-files", "mkdocs-git-authors-plugin", "mkd [[package]] name = "mkdocs-material" -version = "9.6.14" +version = "9.6.15" description = "Documentation that simply works" optional = false python-versions = ">=3.8" files = [ - {file = "mkdocs_material-9.6.14-py3-none-any.whl", hash = "sha256:3b9cee6d3688551bf7a8e8f41afda97a3c39a12f0325436d76c86706114b721b"}, - {file = "mkdocs_material-9.6.14.tar.gz", hash = "sha256:39d795e90dce6b531387c255bd07e866e027828b7346d3eba5ac3de265053754"}, + {file = "mkdocs_material-9.6.15-py3-none-any.whl", hash = "sha256:ac969c94d4fe5eb7c924b6d2f43d7db41159ea91553d18a9afc4780c34f2717a"}, + {file = "mkdocs_material-9.6.15.tar.gz", hash = "sha256:64adf8fa8dba1a17905b6aee1894a5aafd966d4aeb44a11088519b0f5ca4f1b5"}, ] [package.dependencies] @@ -1736,13 +1736,13 @@ files = [ [[package]] name = "pygments" -version = "2.19.1" +version = "2.19.2" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" files = [ - {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, - {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, ] [package.extras] From aad26d5a94b8f2def656e91e2fd856daaad8c1ea Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Jul 2025 11:30:49 +0100 Subject: [PATCH 21/38] pause markdown --- tests/test_markdown.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_markdown.py b/tests/test_markdown.py index c4f548e42b..203316f728 100644 --- a/tests/test_markdown.py +++ b/tests/test_markdown.py @@ -90,6 +90,7 @@ def markdown_nodes(root: Widget) -> Iterator[MarkdownBlock]: yield from markdown_nodes(node) async with MarkdownApp(document).run_test() as pilot: + await pilot.pause() assert [ node.__class__ for node in markdown_nodes(pilot.app.query_one(Markdown)) ] == expected_nodes From ed2f9aafed356c062a9e46c87c578f8273048a5c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Jul 2025 11:45:38 +0100 Subject: [PATCH 22/38] fix flakey test --- tests/snapshot_tests/test_snapshots.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 54de5c0722..079a9f7175 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio from pathlib import Path import pytest @@ -4396,5 +4397,6 @@ async def on_mount(self) -> None: markdown = self.query_one(Markdown) for line in MD: await markdown.append(line) + await asyncio.sleep(0.1) assert snap_compare(MDApp()) From d34e4677aa21016d16f36af7fb1c4ad83abf435d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Jul 2025 12:48:50 +0100 Subject: [PATCH 23/38] update bragging rights --- src/textual/demo/home.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/demo/home.py b/src/textual/demo/home.py index 3c15d010cd..d7562788fa 100644 --- a/src/textual/demo/home.py +++ b/src/textual/demo/home.py @@ -85,7 +85,7 @@ def compose(self) -> ComposeResult: ## Built on Rich -With over 1.6 *billion* downloads, Rich is the most popular terminal library out there. +With over 3.1 *billion* downloads, Rich is the most popular terminal library out there. Textual builds on Rich to add interactivity, and is fully-compatible with Rich renderables. ## Re-usable widgets From ed6f091f59c65ea79485721a892ec41808c4e74e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Jul 2025 12:53:52 +0100 Subject: [PATCH 24/38] wait for snapshots --- tests/snapshot_tests/test_snapshots.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 079a9f7175..69d15d46fd 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -482,7 +482,7 @@ def test_content_switcher_example_switch(snap_compare): def test_tabbed_content(snap_compare): - assert snap_compare(WIDGET_EXAMPLES_DIR / "tabbed_content.py") + assert snap_compare(WIDGET_EXAMPLES_DIR / "tabbed_content.py", press=["wait:500"]) def test_tabbed_content_with_modified_tabs(snap_compare): @@ -648,7 +648,7 @@ def test_sparkline_component_classes_colors(snap_compare): def test_collapsible_render(snap_compare): - assert snap_compare(WIDGET_EXAMPLES_DIR / "collapsible.py") + assert snap_compare(WIDGET_EXAMPLES_DIR / "collapsible.py", press=["wait:100"]) def test_collapsible_collapsed(snap_compare): From 2178a00c7d7ab05ec80eec2d130ec7b1a7bb55c6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Jul 2025 13:06:18 +0100 Subject: [PATCH 25/38] name --- src/textual/__main__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/textual/__main__.py b/src/textual/__main__.py index 9d5d1b26d9..4753abde21 100644 --- a/src/textual/__main__.py +++ b/src/textual/__main__.py @@ -11,7 +11,8 @@ "[b magenta]Hope you liked the demo![/]\n\n" "Please consider sponsoring me if you get value from my work.\n\n" "Even the price of a ☕ can brighten my day!\n\n" - "https://github.com/sponsors/willmcgugan", + "https://github.com/sponsors/willmcgugan\n\n" + "- Will McGugan", border_style="red", title="Consider sponsoring", ) From aed0b470124324e06fde79700864b8ff2e218dd4 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Jul 2025 13:08:33 +0100 Subject: [PATCH 26/38] better copy --- src/textual/_node_list.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/_node_list.py b/src/textual/_node_list.py index 104fe876d1..939a7a88c8 100644 --- a/src/textual/_node_list.py +++ b/src/textual/_node_list.py @@ -152,7 +152,7 @@ def _ensure_unique_id(self, widget_id: str) -> None: """ if widget_id in self._nodes_by_id: raise DuplicateIds( - f"Tried to insert a widget with ID {widget_id!r}, but a widget {self._nodes_by_id[widget_id]!r} already exist; " + f"Tried to insert a widget with ID {widget_id!r}, but a widget already exists with that ID ({self._nodes_by_id[widget_id]!r}); " "ensure all child widgets have a unique ID." ) From 861f842024b0c7f7ba198e206b7434b33943a825 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Jul 2025 13:11:31 +0100 Subject: [PATCH 27/38] fix for flakeyness --- tests/snapshot_tests/test_snapshots.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 69d15d46fd..6c1169c371 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -482,7 +482,7 @@ def test_content_switcher_example_switch(snap_compare): def test_tabbed_content(snap_compare): - assert snap_compare(WIDGET_EXAMPLES_DIR / "tabbed_content.py", press=["wait:500"]) + assert snap_compare(WIDGET_EXAMPLES_DIR / "tabbed_content.py", press=["1"]) def test_tabbed_content_with_modified_tabs(snap_compare): @@ -648,7 +648,7 @@ def test_sparkline_component_classes_colors(snap_compare): def test_collapsible_render(snap_compare): - assert snap_compare(WIDGET_EXAMPLES_DIR / "collapsible.py", press=["wait:100"]) + assert snap_compare(WIDGET_EXAMPLES_DIR / "collapsible.py", press=["wait:100", "1"]) def test_collapsible_collapsed(snap_compare): @@ -4397,6 +4397,5 @@ async def on_mount(self) -> None: markdown = self.query_one(Markdown) for line in MD: await markdown.append(line) - await asyncio.sleep(0.1) - assert snap_compare(MDApp()) + assert snap_compare(MDApp(), press=["1"]) From 1318b7a2699bc20e4e93af14e540d45cff0798d9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Jul 2025 13:26:27 +0100 Subject: [PATCH 28/38] restore width --- src/textual/widgets/_markdown.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 516d30ce39..28aa488cbe 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -793,8 +793,7 @@ def compose(self) -> ComposeResult: class Markdown(Widget): DEFAULT_CSS = """ - Markdown { - width:1fr; + Markdown { height: auto; padding: 0 2 1 2; layout: vertical; From c1c19d2186e030ad69d596ccd7c066edd9931a6a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Jul 2025 13:38:24 +0100 Subject: [PATCH 29/38] possible fix --- src/textual/widgets/_collapsible.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/widgets/_collapsible.py b/src/textual/widgets/_collapsible.py index 952cb49767..d836e56a40 100644 --- a/src/textual/widgets/_collapsible.py +++ b/src/textual/widgets/_collapsible.py @@ -211,6 +211,7 @@ def _watch_collapsed(self, collapsed: bool) -> None: self.post_message(self.Collapsed(self)) else: self.post_message(self.Expanded(self)) + if self.is_mounted: self.call_after_refresh(self.scroll_visible) def _update_collapsed(self, collapsed: bool) -> None: From 629bcbaf8d32354bd02357f70f4745f0c32f6a63 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Jul 2025 14:03:02 +0100 Subject: [PATCH 30/38] wait longer --- tests/snapshot_tests/test_snapshots.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 6c1169c371..1414ff6200 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -482,7 +482,9 @@ def test_content_switcher_example_switch(snap_compare): def test_tabbed_content(snap_compare): - assert snap_compare(WIDGET_EXAMPLES_DIR / "tabbed_content.py", press=["1"]) + assert snap_compare( + WIDGET_EXAMPLES_DIR / "tabbed_content.py", press=["wait:1000", "1"] + ) def test_tabbed_content_with_modified_tabs(snap_compare): @@ -648,7 +650,9 @@ def test_sparkline_component_classes_colors(snap_compare): def test_collapsible_render(snap_compare): - assert snap_compare(WIDGET_EXAMPLES_DIR / "collapsible.py", press=["wait:100", "1"]) + assert snap_compare( + WIDGET_EXAMPLES_DIR / "collapsible.py", press=["wait:1000", "1"] + ) def test_collapsible_collapsed(snap_compare): From ad2bd6df4a5ac00009f639453062af1e082ab338 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Jul 2025 14:15:55 +0100 Subject: [PATCH 31/38] run before --- tests/snapshot_tests/test_snapshots.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 1414ff6200..17c50463be 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -482,8 +482,13 @@ def test_content_switcher_example_switch(snap_compare): def test_tabbed_content(snap_compare): + async def run_before(pilot: Pilot) -> None: + await pilot.pause() + assert snap_compare( - WIDGET_EXAMPLES_DIR / "tabbed_content.py", press=["wait:1000", "1"] + WIDGET_EXAMPLES_DIR / "tabbed_content.py", + press=["wait:1000", "1"], + run_before=run_before, ) @@ -650,8 +655,13 @@ def test_sparkline_component_classes_colors(snap_compare): def test_collapsible_render(snap_compare): + async def run_before(pilot: Pilot) -> None: + await pilot.pause() + assert snap_compare( - WIDGET_EXAMPLES_DIR / "collapsible.py", press=["wait:1000", "1"] + WIDGET_EXAMPLES_DIR / "collapsible.py", + press=["wait:1000", "1"], + run_before=run_before, ) From 9788045e2d6d036229551a3285de68d0e5042c8d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Jul 2025 15:41:49 +0100 Subject: [PATCH 32/38] fix initial markdown --- src/textual/widgets/_markdown.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 28aa488cbe..e889ab2c2f 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -870,9 +870,10 @@ def __init__( open_links: Open links automatically. If you set this to `False`, you can handle the [`LinkClicked`][textual.widgets.markdown.Markdown.LinkClicked] events. """ super().__init__(name=name, id=id, classes=classes) - self._markdown = markdown + self._initial_markdown: str | None = markdown + self._markdown = "" self._parser_factory = parser_factory - self._table_of_contents: TableOfContentsType | None = None + self._table_of_contents: TableOfContentsType = [] self._open_links = open_links class TableOfContentsUpdated(Message): @@ -944,8 +945,16 @@ def notify_style_update(self) -> None: super().notify_style_update() async def _on_mount(self, _: Mount) -> None: - if self._markdown is not None: - await self.update(self._markdown) + initial_markdown = self._initial_markdown + self._initial_markdown = None + await self.update(initial_markdown or "") + + if initial_markdown is None: + self.post_message( + Markdown.TableOfContentsUpdated( + self, self._table_of_contents + ).set_sender(self) + ) def on_markdown_link_clicked(self, event: LinkClicked) -> None: if self._open_links: From 41c4ba6774a7e291f9a1f65d81ecf6d620628bbc Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Jul 2025 15:54:54 +0100 Subject: [PATCH 33/38] flakey test --- tests/snapshot_tests/test_snapshots.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 17c50463be..4ae398099b 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -4411,5 +4411,6 @@ async def on_mount(self) -> None: markdown = self.query_one(Markdown) for line in MD: await markdown.append(line) + await asyncio.sleep(0.01) - assert snap_compare(MDApp(), press=["1"]) + assert snap_compare(MDApp(), press=["1", "wait:500"]) From 34a698a88fc4b34997ac85a37b088bab51b1452b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Jul 2025 16:09:31 +0100 Subject: [PATCH 34/38] delay in test --- tests/snapshot_tests/test_snapshots.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 4ae398099b..78923dbf41 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -670,7 +670,7 @@ def test_collapsible_collapsed(snap_compare): def test_collapsible_expanded(snap_compare): - assert snap_compare(WIDGET_EXAMPLES_DIR / "collapsible.py", press=["e"]) + assert snap_compare(WIDGET_EXAMPLES_DIR / "collapsible.py", press=["e", "wait:500"]) def test_collapsible_nested(snap_compare): From 3a02d44e8638c9a53f1a9942692d5052d11e35b9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Jul 2025 16:30:00 +0100 Subject: [PATCH 35/38] flaky test --- tests/snapshot_tests/test_snapshots.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 78923dbf41..72b4fe37f4 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -670,7 +670,14 @@ def test_collapsible_collapsed(snap_compare): def test_collapsible_expanded(snap_compare): - assert snap_compare(WIDGET_EXAMPLES_DIR / "collapsible.py", press=["e", "wait:500"]) + async def run_before(pilot: Pilot) -> None: + await pilot.pause() + + assert snap_compare( + WIDGET_EXAMPLES_DIR / "collapsible.py", + press=["e", "wait:1000"], + run_before=run_before, + ) def test_collapsible_nested(snap_compare): From bbc6224514372111ea2c406a530363ae995f27bd Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Jul 2025 16:48:10 +0100 Subject: [PATCH 36/38] test fix --- .../test_collapsible_expanded.svg | 168 ++++++++++++++---- tests/snapshot_tests/test_snapshots.py | 2 +- 2 files changed, 137 insertions(+), 33 deletions(-) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_collapsible_expanded.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_collapsible_expanded.svg index 5d968affeb..e179555d6c 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_collapsible_expanded.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_collapsible_expanded.svg @@ -1,4 +1,4 @@ - + diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 72b4fe37f4..4ffcec995d 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -676,7 +676,7 @@ async def run_before(pilot: Pilot) -> None: assert snap_compare( WIDGET_EXAMPLES_DIR / "collapsible.py", press=["e", "wait:1000"], - run_before=run_before, + terminal_size=(80, 50), ) From fb062bc78761e5c9f51af639710068b926f6e9f2 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Jul 2025 17:17:46 +0100 Subject: [PATCH 37/38] viewer fix --- src/textual/widgets/_markdown.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index e889ab2c2f..2fa5948cac 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -1412,8 +1412,7 @@ def table_of_contents(self) -> MarkdownTableOfContents: return self.query_one(MarkdownTableOfContents) async def _on_mount(self, _: Mount) -> None: - if self._markdown is not None: - await self.document.update(self._markdown) + await self.document.update(self._markdown or "") async def go(self, location: str | PurePath) -> None: """Navigate to a new document path.""" From 95be172bf33ac49f3b815fd8f2d4e5e27e05cd35 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Jul 2025 17:24:03 +0100 Subject: [PATCH 38/38] maybe fix --- tests/test_markdownviewer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_markdownviewer.py b/tests/test_markdownviewer.py index 31c665a102..3c83d271d2 100644 --- a/tests/test_markdownviewer.py +++ b/tests/test_markdownviewer.py @@ -82,6 +82,7 @@ async def test_headings_that_look_like_they_contain_markup(text: str) -> None: document = f"# {text}" async with MarkdownStringViewerApp(document).run_test() as pilot: + await pilot.pause() assert pilot.app.query_one(MD.MarkdownH1)._content == Content(text) toc_tree = pilot.app.query_one(MD.MarkdownTableOfContents).query_one(Tree) # The toc label looks like "I {text}" but the I is styled so we drop it.