diff --git a/CHANGELOG.md b/CHANGELOG.md index f6d0a276ca..d16e5d5c80 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 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 https://github.com/Textualize/textual/pull/5962 ## [4.0.0] - 2025-07-12 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] 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", ) diff --git a/src/textual/_node_list.py b/src/textual/_node_list.py index 071fd35105..939a7a88c8 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 already exists with that ID ({self._nodes_by_id[widget_id]!r}); " + "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 5f7e85ca81..1d0c4cfe01 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,42 @@ 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 + used_space = sum(resolved_fractions) + if expand: + remaining_space = total_space - used_space + if remaining_space > 0: + resolved_fractions = [ + width + Fraction(width, used_space) * remaining_space + 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: + 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 = used_space - width + updated_width + excess_space = used_space - total_space + if excess_space <= 0: + break + + used_space = sum(resolved_fractions) + 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/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/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 20d9480562..2cc37f2571 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,21 @@ 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 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 + 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/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 diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py index 5f6b934a74..f2315f4f87 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 - self.regular = 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,32 @@ 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 + ) + + 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/widget.py b/src/textual/widget.py index 4c92931e56..febb033ea4 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: @@ -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/_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: diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index a491e17458..2fa5948cac 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -3,16 +3,14 @@ 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 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 @@ -20,10 +18,14 @@ 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.layout import Layout +from textual.layouts.grid import GridLayout 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 @@ -110,6 +112,7 @@ class MarkdownBlock(Static): DEFAULT_CSS = """ MarkdownBlock { + width: 1fr; height: auto; } """ @@ -117,7 +120,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 = Content() self._token: Token | None = None self._blocks: list[MarkdownBlock] = [] super().__init__(*args, **kwargs) @@ -130,9 +133,13 @@ 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 _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.""" @@ -163,73 +170,80 @@ def build_from_token(self, token: Token) -> None: """ self._token = token + null_style = Style.null() style_stack: list[Style] = [Style()] - content = Text() - if token.children: - for child in token.children: - if child.type == "text": - content.append( - # Ensure repeating spaces and/or tabs get squashed - # down to a single space. - re.sub(r"\s+", " ", child.content), - style_stack[-1], - ) - if child.type == "hardbreak": - content.append("\n") - if child.type == "softbreak": - content.append(" ", 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 - ), - ) - elif child.type == "em_open": - style_stack.append( - style_stack[-1] - + self._markdown.get_component_rich_style("em", partial=True) - ) - elif child.type == "strong_open": - style_stack.append( - style_stack[-1] - + self._markdown.get_component_rich_style( - "strong", partial=True - ) - ) - elif child.type == "s_open": - style_stack.append( - style_stack[-1] - + self._markdown.get_component_rich_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}) - ) - - content.append("🖼 ", style_stack[-1]) - if alt: - content.append(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: list[tuple[str, Style]] = [] - style_stack.pop() - - elif child.type.endswith("_close"): - style_stack.pop() + 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)) + + get_visual_style = self._markdown.get_visual_style + 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) @@ -452,14 +466,45 @@ 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.""" DEFAULT_CSS = """ MarkdownTableContent { - width: 100%; + width: 1fr; height: auto; - + layout: grid; + grid-columns: auto; + grid-rows: auto; + grid-gutter: 1 1; + + & > .cell { + margin: 0 0; + height: auto; + padding: 0 1; + text-overflow: ellipsis; + + } + & > .header { + height: auto; + margin: 0 0; + padding: 0 1; + color: $primary; + text-overflow: ellipsis; + content-align: left bottom; + } + keyline: thin $foreground 20%; } MarkdownTableContent > .markdown-table--header { text-style: bold; @@ -468,30 +513,48 @@ class MarkdownTableContent(Widget): COMPONENT_CLASSES = {"markdown-table--header", "markdown-table--lines"} - def __init__(self, headers: list[Text], rows: list[list[Text]]): - self.headers = headers + def __init__(self, headers: list[Content], rows: list[list[Content]]): + 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 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 pre_layout(self, layout: Layout) -> None: + assert isinstance(layout, GridLayout) + layout.auto_minimum = True + layout.expand = True + layout.shrink = True + layout.stretch_height = True + + def compose(self) -> ComposeResult: for header in self.headers: - table.add_column(header) - for row in self.rows: - if row: - table.add_row(*row) - return table + yield MarkdownTableCellContents(header, classes="header").with_tooltip( + header + ) + for row_index, row in enumerate(self.rows, 1): + for cell in row: + 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: + 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): + 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: + self.styles.grid_size_columns = len(self.headers) async def action_link(self, href: str) -> None: """Pass a link action on to the MarkdownTable parent.""" @@ -504,33 +567,71 @@ class MarkdownTable(MarkdownBlock): DEFAULT_CSS = """ MarkdownTable { - width: 100%; - background: black 10%; + width: 1fr; + margin-bottom: 1; &:light { background: white 30%; - } + } } """ + 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) + + 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: 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(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) + 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: + """Special case to update a Markdown table. + + Args: + block: Existing markdown block. + """ + 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): @@ -573,8 +674,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): @@ -692,20 +793,28 @@ def compose(self) -> ComposeResult: class Markdown(Widget): DEFAULT_CSS = """ - Markdown { + Markdown { height: auto; padding: 0 2 1 2; layout: vertical; color: $foreground; - background: $surface; + # 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; + text-style: italic; } .strong { text-style: bold; @@ -713,9 +822,7 @@ class Markdown(Widget): .s { text-style: strike; } - .code_inline { - text-style: bold dim; - } + """ COMPONENT_CLASSES = {"em", "strong", "s", "code_inline"} @@ -763,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): @@ -832,9 +940,21 @@ 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) + 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: @@ -986,7 +1106,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: @@ -1026,7 +1146,6 @@ def update(self, markdown: str) -> AwaitComplete: table_of_contents: TableOfContentsType = [] markdown_block = self.query("MarkdownBlock") - self._markdown = markdown async def await_update() -> None: @@ -1067,13 +1186,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()) @@ -1095,25 +1214,36 @@ def append(self, markdown: str) -> AwaitComplete: table_of_contents: TableOfContentsType = [] self._markdown = updated_markdown = self.source + markdown - existing_blocks = list(self.children) 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(): - for block in existing_blocks[last_index:]: - await block.remove() + if existing_blocks and new_blocks: + 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( @@ -1282,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.""" 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/__snapshots__/test_snapshots/test_content_switcher_example_switch.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_content_switcher_example_switch.svg index 2c52f7cb6d..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,6 +41,7 @@ .terminal-r7 { fill: #0178d4;font-weight: bold } .terminal-r8 { fill: #e0e0e0 } .terminal-r9 { fill: #ffff00;text-decoration: underline; } +.terminal-r10 { fill: #3b3b3b } @@ -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..d76a6a5ea1 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_example_markdown.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_example_markdown.svg @@ -37,9 +37,9 @@ .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-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 405bbd86f1..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,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: #3b3b3b } +.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_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 a8663c5520..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: #9e9e9e;font-weight: bold } -.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 3f862179f2..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 @@ -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-r10 { fill: #e2e2e2;font-weight: bold } +.terminal-r7 { fill: #fea62b } +.terminal-r8 { fill: #000000 } +.terminal-r9 { fill: #0178d4;text-decoration: underline; } +.terminal-r10 { fill: #e1e1e1;font-weight: bold } .terminal-r11 { fill: #e0e0e0;font-style: italic; } .terminal-r12 { fill: #e0e0e0;font-weight: bold } +.terminal-r13 { fill: #444444 } @@ -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/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 ━━━━━━╸━━━━━━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 54de5c0722..4ffcec995d 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 @@ -481,7 +482,14 @@ def test_content_switcher_example_switch(snap_compare): def test_tabbed_content(snap_compare): - assert snap_compare(WIDGET_EXAMPLES_DIR / "tabbed_content.py") + async def run_before(pilot: Pilot) -> None: + await pilot.pause() + + assert snap_compare( + WIDGET_EXAMPLES_DIR / "tabbed_content.py", + press=["wait:1000", "1"], + run_before=run_before, + ) def test_tabbed_content_with_modified_tabs(snap_compare): @@ -647,7 +655,14 @@ def test_sparkline_component_classes_colors(snap_compare): def test_collapsible_render(snap_compare): - assert snap_compare(WIDGET_EXAMPLES_DIR / "collapsible.py") + async def run_before(pilot: Pilot) -> None: + await pilot.pause() + + assert snap_compare( + WIDGET_EXAMPLES_DIR / "collapsible.py", + press=["wait:1000", "1"], + run_before=run_before, + ) def test_collapsible_collapsed(snap_compare): @@ -655,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"]) + async def run_before(pilot: Pilot) -> None: + await pilot.pause() + + assert snap_compare( + WIDGET_EXAMPLES_DIR / "collapsible.py", + press=["e", "wait:1000"], + terminal_size=(80, 50), + ) def test_collapsible_nested(snap_compare): @@ -4396,5 +4418,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()) + assert snap_compare(MDApp(), press=["1", "wait:500"]) diff --git a/tests/test_markdown.py b/tests/test_markdown.py index 4d1214c0d8..203316f728 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 @@ -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 @@ -107,16 +108,16 @@ 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" + 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._text.spans == expected_spans + assert paragraph._content.spans == expected_spans async def test_load_non_existing_file() -> None: @@ -160,6 +161,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 +197,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"] diff --git a/tests/test_markdownviewer.py b/tests/test_markdownviewer.py index a1646f5a2e..3c83d271d2 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,8 @@ 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) + 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. toc_label = toc_tree.root.children[0].label