From a62b33f604f751eb0f4cc86c4982e88f94f2f864 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Jul 2025 20:18:16 +0100 Subject: [PATCH 01/20] remove borders from command palette --- src/textual/command.py | 4 +- src/textual/highlight.py | 121 +++++++++++++++++++++++++++++++ src/textual/widgets/_markdown.py | 7 +- 3 files changed, 128 insertions(+), 4 deletions(-) create mode 100644 src/textual/highlight.py diff --git a/src/textual/command.py b/src/textual/command.py index 0d74f1993f..04949d3fb1 100644 --- a/src/textual/command.py +++ b/src/textual/command.py @@ -442,7 +442,7 @@ class CommandList(OptionList, can_focus=False): CommandList { visibility: hidden; border-top: blank; - border-bottom: hkey $border; + border-bottom: hkey black 50%; border-left: none; border-right: none; height: auto; @@ -586,7 +586,7 @@ class CommandPalette(SystemModalScreen[None]): CommandPalette #--input { height: auto; visibility: visible; - border: hkey $border; + border: hkey black 50%; } CommandPalette #--input.--list-visible { diff --git a/src/textual/highlight.py b/src/textual/highlight.py new file mode 100644 index 0000000000..eb703401d8 --- /dev/null +++ b/src/textual/highlight.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +from pygments.lexers import get_lexer_by_name +from pygments.token import ( + Comment, + Keyword, + Name, + Number, + Operator, + String, + Token, + Whitespace, +) + +from textual.content import Content, Span + +TokenType = tuple[str, ...] + +THEME: dict[TokenType, str] = { + Token: "", + Whitespace: "", + Comment: "green", + Comment.Preproc: "$success", + Keyword: "$accent bold", + Token.Literal.String.Doc: "$success 50% italic", + Token.Operator: "bold", + Token.Name: "$primary", + Keyword.Type: "bold", + Operator.Word: "bold", + Token.Keyword.Constant: "bold $success 80%", + # Name.Builtin: Style(color="cyan"), + # Name.Function: Style(color="green"), + # Name.Namespace: Style(color="cyan", underline=True), + # Name.Class: Style(color="green", underline=True), + # Name.Exception: Style(color="cyan"), + Name.Decorator: "$primary bold", + Name.Variable: "$secondary", + Name.Constant: "$error", + # Name.Attribute: Style(color="cyan"), + # Name.Tag: Style(color="bright_blue"), + String: "$success", + Number: "$secondary", + Token.Literal.Number.Integer: "$warning", + # Generic.Deleted: Style(color="bright_red"), + # Generic.Inserted: Style(color="green"), + # Generic.Heading: Style(bold=True), + # Generic.Subheading: Style(color="magenta", bold=True), + # Generic.Prompt: Style(bold=True), + # Generic.Error: Style(color="bright_red"), + # Error: Style(color="red", underline=True), +} + + +def highlight(code: str, language: str) -> Content: + lexer = get_lexer_by_name( + language, + stripnl=False, + ensurenl=True, + tabsize=8, + ) + token_start = 0 + spans: list[Span] = [] + for token_type, token in lexer.get_tokens(code): + token_end = token_start + len(token) + if style := THEME.get(token_type, None): + spans.append(Span(token_start, token_end, style)) + token_start = token_end + highlighted_code = Content(code, spans=spans) + return highlighted_code + + +if __name__ == "__main__": + + CODE = '''\ +from typing import Iterable, Tuple, TypeVar + +T = TypeVar("T") + + +def loop_first(values: Iterable[T]) -> Iterable[Tuple[bool, T]]: + """Iterate and generate a tuple with a flag for first value.""" + iter_values = iter(values) + try: + value = next(iter_values) + except StopIteration: + return + yield True, value + for value in iter_values: + yield False, value + + +def loop_last(values: Iterable[T]) -> Iterable[Tuple[bool, T]]: + """Iterate and generate a tuple with a flag for last value.""" + iter_values = iter(values) + try: + previous_value = next(iter_values) + except StopIteration: + return + for value in iter_values: + yield False, previous_value + previous_value = value + yield True, previous_value + + +def loop_first_last(values: Iterable[T]) -> Iterable[Tuple[bool, bool, T]]: + """Iterate and generate a tuple with a flag for first and last value.""" + iter_values = iter(values) + try: + previous_value = next(iter_values) + except StopIteration: + return + first = True + for value in iter_values: + yield first, False, previous_value + first = False + previous_value = value + yield first, True, previous_value + +''' + + highlight(CODE, "python") diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 2fa5948cac..4938affacd 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -21,6 +21,7 @@ from textual.content import Content from textual.css.query import NoMatches from textual.events import Mount +from textual.highlight import highlight from textual.layout import Layout from textual.layouts.grid import GridLayout from textual.message import Message @@ -712,8 +713,9 @@ class MarkdownFence(MarkdownBlock): DEFAULT_CSS = """ MarkdownFence { + padding: 1 2; margin: 1 0; - overflow: auto; + overflow: hidden; width: 100%; height: auto; max-height: 20; @@ -745,6 +747,7 @@ def notify_style_update(self) -> None: def _block(self) -> Syntax: _, background_color = self.background_colors + return highlight(self.code, "python") return Syntax( self.code, lexer=self.lexer, @@ -799,7 +802,7 @@ class Markdown(Widget): layout: vertical; color: $foreground; # background: $surface; - overflow-y: auto; + overflow-y: hidden; &:focus { background-tint: $foreground 5%; From 06c212954201bdc4840cfc8bb673e5a167cdf046 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 16 Jul 2025 08:49:07 +0100 Subject: [PATCH 02/20] highlight improvements --- src/textual/command.py | 2 +- src/textual/content.py | 3 + src/textual/highlight.py | 159 ++++++++++++++++++++----------- src/textual/selection.py | 1 + src/textual/widgets/_markdown.py | 76 ++++----------- tests/test_content.py | 9 ++ 6 files changed, 138 insertions(+), 112 deletions(-) diff --git a/src/textual/command.py b/src/textual/command.py index 04949d3fb1..a05401e999 100644 --- a/src/textual/command.py +++ b/src/textual/command.py @@ -442,7 +442,7 @@ class CommandList(OptionList, can_focus=False): CommandList { visibility: hidden; border-top: blank; - border-bottom: hkey black 50%; + border-bottom: hkey black; border-left: none; border-right: none; height: auto; diff --git a/src/textual/content.py b/src/textual/content.py index 2cc37f2571..3abac71246 100644 --- a/src/textual/content.py +++ b/src/textual/content.py @@ -1226,6 +1226,9 @@ def divide(self, offsets: Sequence[int]) -> list[Content]: _Span = Span for span_start, span_end, style in self._spans: + if span_start >= text_length: + continue + span_end = min(text_length, span_end) lower_bound = 0 upper_bound = line_count start_line_no = (lower_bound + upper_bound) // 2 diff --git a/src/textual/highlight.py b/src/textual/highlight.py index eb703401d8..00390da734 100644 --- a/src/textual/highlight.py +++ b/src/textual/highlight.py @@ -1,81 +1,117 @@ from __future__ import annotations from pygments.lexers import get_lexer_by_name -from pygments.token import ( - Comment, - Keyword, - Name, - Number, - Operator, - String, - Token, - Whitespace, -) +from pygments.token import Token from textual.content import Content, Span TokenType = tuple[str, ...] -THEME: dict[TokenType, str] = { - Token: "", - Whitespace: "", - Comment: "green", - Comment.Preproc: "$success", - Keyword: "$accent bold", - Token.Literal.String.Doc: "$success 50% italic", - Token.Operator: "bold", - Token.Name: "$primary", - Keyword.Type: "bold", - Operator.Word: "bold", - Token.Keyword.Constant: "bold $success 80%", - # Name.Builtin: Style(color="cyan"), - # Name.Function: Style(color="green"), - # Name.Namespace: Style(color="cyan", underline=True), - # Name.Class: Style(color="green", underline=True), - # Name.Exception: Style(color="cyan"), - Name.Decorator: "$primary bold", - Name.Variable: "$secondary", - Name.Constant: "$error", - # Name.Attribute: Style(color="cyan"), - # Name.Tag: Style(color="bright_blue"), - String: "$success", - Number: "$secondary", - Token.Literal.Number.Integer: "$warning", - # Generic.Deleted: Style(color="bright_red"), - # Generic.Inserted: Style(color="green"), - # Generic.Heading: Style(bold=True), - # Generic.Subheading: Style(color="magenta", bold=True), - # Generic.Prompt: Style(bold=True), - # Generic.Error: Style(color="bright_red"), - # Error: Style(color="red", underline=True), -} - - -def highlight(code: str, language: str) -> Content: + +class HighlightTheme: + + STYLES: dict[TokenType, str] = { + # Generic.Deleted: Style(color="bright_red"), + # Generic.Heading: Style(bold=True), + # Generic.Inserted: Style(color="green"), + # Generic.Prompt: Style(bold=True), + # Generic.Subheading: Style(color="magenta", bold=True), + # Name.Attribute: Style(color="cyan"), + # Name.Builtin: Style(color="cyan"), + # Name.Class: Style(color="green", underline=True), + # Name.Exception: Style(color="cyan"), + # Name.Function: Style(color="green"), + # Name.Namespace: Style(color="cyan", underline=True), + # Token: "$text", + # Token.Comment.Double: "green", + # Token.Comment.Preproc: "$success", + Token.Comment: "$text 60%", + Token.Error: "$error on $error-muted", + Token.Generic.Error: "$error on $error-muted", + Token.Keyword: "$accent", + Token.Keyword.Constant: "bold $success 80%", + Token.Keyword.Namespace: "$error", + Token.Keyword.Type: "bold", + Token.Literal.Number.Integer: "$warning", + Token.Literal.String: "$success 90%", + Token.Literal.String.Doc: "$success 80% italic", + Token.Literal.String.Double: "$success 90%", + Token.Name: "$primary", + Token.Name.Builtin: "$accent", + Token.Name.Builtin.Pseudo: "italic", + Token.Name.Class: "$warning bold", + Token.Name.Constant: "$error", + Token.Name.Decorator: "$primary bold", + Token.Name.Function: "$warning underline", + Token.Name.Function.Magic: "$warning underline", + Token.Name.Tag: "$primary", + Token.Name.Variable: "$secondary", + Token.Number: "$secondary", + Token.Operator: "bold", + Token.Operator.Word: "bold", + Token.String: "$success", + Token.Whitespace: "", + } + + +def highlight( + code: str, + language: str = "text", + theme: type[HighlightTheme] = HighlightTheme, + tab_size: int = 8, +) -> Content: + code = "\n".join(code.splitlines()) lexer = get_lexer_by_name( language, stripnl=False, ensurenl=True, - tabsize=8, + tabsize=tab_size, ) token_start = 0 spans: list[Span] = [] + styles = theme.STYLES + for token_type, token in lexer.get_tokens(code): token_end = token_start + len(token) - if style := THEME.get(token_type, None): - spans.append(Span(token_start, token_end, style)) + while True: + if style := styles.get(token_type): + spans.append(Span(token_start, token_end, style)) + break + else: + if (token_type := token_type.parent) is None: + break token_start = token_end - highlighted_code = Content(code, spans=spans) + + highlighted_code = Content(code, spans=spans).stylize_before("$text") return highlighted_code if __name__ == "__main__": - CODE = '''\ + CODE = ''' from typing import Iterable, Tuple, TypeVar T = TypeVar("T") +# This is a comment + +class PygmentsSyntaxTheme(SyntaxTheme): + """Syntax theme that delegates to Pygments theme.""" + + def __init__(self, theme: Union[str, Type[PygmentsStyle]]) -> None: + self._style_cache: Dict[TokenType, Style] = {} + if isinstance(theme, str): + try: + self._pygments_style_class = get_style_by_name(theme) + except ClassNotFound: + self._pygments_style_class = get_style_by_name("default") + else: + self._pygments_style_class = theme + + self._background_color = self._pygments_style_class.background_color + self._background_style = Style(bgcolor=self._background_color) + + def loop_first(values: Iterable[T]) -> Iterable[Tuple[bool, T]]: """Iterate and generate a tuple with a flag for first value.""" @@ -88,7 +124,6 @@ def loop_first(values: Iterable[T]) -> Iterable[Tuple[bool, T]]: for value in iter_values: yield False, value - def loop_last(values: Iterable[T]) -> Iterable[Tuple[bool, T]]: """Iterate and generate a tuple with a flag for last value.""" iter_values = iter(values) @@ -115,7 +150,23 @@ def loop_first_last(values: Iterable[T]) -> Iterable[Tuple[bool, bool, T]]: first = False previous_value = value yield first, True, previous_value - ''' + lexer = get_lexer_by_name( + "python", + stripnl=False, + ensurenl=True, + tabsize=8, + ) + for token_type, token in lexer.get_tokens(CODE): + print(f"{token_type}, {token[:20]!r}") + + # highlight(CODE, "python") + + spans = [ + Span(0, 49, style="$text"), + ] + + c = Content("--- hello.py\t2024-01-15 10:30:00.000000000 -0800", spans=spans) + from textual.style import Style - highlight(CODE, "python") + c.render_strips({}, 80, None, Style()) diff --git a/src/textual/selection.py b/src/textual/selection.py index 94166838aa..0b12fc27ba 100644 --- a/src/textual/selection.py +++ b/src/textual/selection.py @@ -50,6 +50,7 @@ def extract(self, text: str) -> str: end_offset = len(lines[end_line]) else: end_line, end_offset = self.end.transpose + end_line = min(len(lines), end_line) if start_line == end_line: return lines[start_line][start_offset:end_offset] diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 4938affacd..86e6f128f0 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -10,7 +10,6 @@ from markdown_it import MarkdownIt from markdown_it.token import Token -from rich.syntax import Syntax from rich.text import Text from typing_extensions import TypeAlias @@ -619,19 +618,19 @@ async def _update_from_block(self, block: MarkdownBlock) -> None: 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 + if 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) @@ -716,12 +715,12 @@ class MarkdownFence(MarkdownBlock): padding: 1 2; margin: 1 0; overflow: hidden; - width: 100%; - height: auto; - max-height: 20; + width: 1fr; + height: auto; color: rgb(210,210,210); background: black 10%; - + text-wrap: nowrap; + text-overflow: clip; &:light { background: white 30%; } @@ -741,45 +740,8 @@ def __init__(self, markdown: Markdown, code: str, lexer: str) -> None: if self.app.current_theme.dark else self._markdown.code_light_theme ) - - def notify_style_update(self) -> None: - self.call_later(self._retheme) - - def _block(self) -> Syntax: - _, background_color = self.background_colors - return highlight(self.code, "python") - return Syntax( - self.code, - lexer=self.lexer, - word_wrap=False, - indent_guides=self._markdown.code_indent_guides, - padding=(1, 2), - theme=self.theme, - background_color=background_color.css, - ) - - def _on_mount(self, _: Mount) -> None: - """Watch app theme switching.""" - self.watch(self.app, "theme", self._retheme) - - def _retheme(self) -> None: - """Rerender when the theme changes.""" - self.theme = ( - self._markdown.code_dark_theme - if self.app.current_theme.dark - else self._markdown.code_light_theme - ) - try: - self.get_child_by_type(Static).update(self._block()) - except NoMatches: - pass - - def compose(self) -> ComposeResult: - yield Static( - self._block(), - expand=True, - shrink=False, - ) + code_content = highlight(self.code, "python") + self.set_content(code_content) HEADINGS = { diff --git a/tests/test_content.py b/tests/test_content.py index a5c459f3f6..fa54671654 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -274,3 +274,12 @@ def test_first_line(): first_line = content.first_line assert first_line.plain == "foo" assert first_line.spans == [Span(0, 3, "red")] + + +def test_split_and_tabs(): + spans = [ + Span(0, 49, style="$text"), + ] + + content = Content("--- hello.py\t2024-01-15 10:30:00.000000000 -0800", spans=spans) + content.render_strips({}, 80, None, Style()) From 251d2ce3ad622dd6e94e30804b664f5ae99896b9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 16 Jul 2025 14:03:55 +0100 Subject: [PATCH 03/20] expose updater --- src/textual/_compositor.py | 6 ++- src/textual/message_pump.py | 14 ++++++ src/textual/widgets/_markdown.py | 75 ++++++++++++++++++++++++++++---- 3 files changed, 84 insertions(+), 11 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 6941bf96c0..5d44411b4b 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -606,7 +606,7 @@ def add_widget( sub_clip = clip.intersection(child_region) if widget._anchored and not widget._anchor_released: - scroll_y = widget.scroll_y + # scroll_y = widget.scroll_y new_scroll_y = ( arrange_result.spatial_map.total_region.bottom - ( @@ -615,7 +615,9 @@ def add_widget( ) ) widget.set_reactive(Widget.scroll_y, new_scroll_y) - widget.watch_scroll_y(scroll_y, new_scroll_y) + # widget.watch_scroll_y(scroll_y, new_scroll_y) + # self.vertical_scrollbar.position = new_value + widget.vertical_scrollbar._reactive_position = new_scroll_y if visible_only: placements = arrange_result.get_visible_placements( diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 19e37e4a19..dd2ec42270 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -450,6 +450,20 @@ def call_after_refresh(self, callback: Callback, *args: Any, **kwargs: Any) -> b message = messages.InvokeLater(partial(callback, *args, **kwargs)) return self.post_message(message) + async def wait_for_refresh(self) -> None: + """Wait for the next refresh. + + This method should only be called from a task other than the one running this widget. + If called from the same task, it will return immediately to avoid blocking the event loop. + + """ + + if self._task is None or asyncio.current_task() is not self._task: + return + refreshed_event = asyncio.Event() + self.call_after_refresh(refreshed_event.set) + await refreshed_event.wait() + def call_later(self, callback: Callback, *args: Any, **kwargs: Any) -> bool: """Schedule a callback to run after all messages are processed in this object. Positional and keywords arguments are passed to the callable. diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 86e6f128f0..daaa2dfee0 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -36,6 +36,51 @@ """ +class BackgroundUpdater: + """Update markdown document in the background. + + This will accumulate markdown fragments if they can't be rendered fast enough. + + """ + + def __init__(self, markdown_widget: Markdown) -> None: + self.markdown_widget = markdown_widget + self._task: asyncio.Task | None = None + self._new_markup = asyncio.Event() + self._lock = asyncio.Lock() + self._pending: list[str] = [] + + def start(self) -> None: + self._task = asyncio.create_task(self._run()) + + async def stop(self) -> None: + if self._task is not None: + self._task.cancel() + await self._task + self._task = None + + async def append(self, markdown_fragment: str) -> None: + if not markdown_fragment: + return + async with self._lock: + self._pending.append(markdown_fragment) + self._new_markup.set() + + async def _run(self) -> None: + try: + while await self._new_markup.wait(): + new_markdown = "".join(self._pending) + self._new_markup.clear() + self._pending.clear() + await asyncio.shield(self.markdown_widget.append(new_markdown)) + + except asyncio.CancelledError: + + new_markdown = "".join(self._pending) + if new_markdown: + await self.markdown_widget.append(new_markdown) + + class Navigator: """Manages a stack of paths like a browser.""" @@ -921,6 +966,20 @@ async def _on_mount(self, _: Mount) -> None: ).set_sender(self) ) + @classmethod + def get_background_updater(cls, markdown: Markdown) -> BackgroundUpdater: + """Get an object to stream Markdown in the background. + + Args: + markdown: A [Markdown][textual.widgets.Markdown] widget instance. + + Returns: + The background updater object. + """ + updater = BackgroundUpdater(markdown) + updater.start() + return updater + def on_markdown_link_clicked(self, event: LinkClicked) -> None: if self._open_links: self.app.open_url(event.href) @@ -1182,16 +1241,14 @@ def append(self, markdown: str) -> AwaitComplete: 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: + tokens = 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 + with self.app.batch_update(): if existing_blocks and new_blocks: last_block = existing_blocks[last_index] From c9933bca7bcc61bd9396b62ecfe0cb7410ae0337 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 16 Jul 2025 14:15:04 +0100 Subject: [PATCH 04/20] docstrings --- src/textual/widgets/_markdown.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index daaa2dfee0..9a2745d4d7 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -44,38 +44,49 @@ class BackgroundUpdater: """ def __init__(self, markdown_widget: Markdown) -> None: + """ + Args: + markdown_widget: Markdown widget to update. + """ self.markdown_widget = markdown_widget self._task: asyncio.Task | None = None self._new_markup = asyncio.Event() - self._lock = asyncio.Lock() self._pending: list[str] = [] def start(self) -> None: + """Start the updater running in the background.""" self._task = asyncio.create_task(self._run()) async def stop(self) -> None: + """Stop the updater and await its finish.""" if self._task is not None: self._task.cancel() await self._task self._task = None async def append(self, markdown_fragment: str) -> None: + """Append or enqueue a markdown fragment. + + Args: + markdown_fragment: A string to append at the end of the document. + """ if not markdown_fragment: + # Nothing to do for empty strings. return - async with self._lock: - self._pending.append(markdown_fragment) - self._new_markup.set() + # Append the new fragment, and set an event to tell the _run loop to wake up + self._pending.append(markdown_fragment) + self._new_markup.set() async def _run(self) -> None: + """Run a task to append markdown fragments when available.""" try: while await self._new_markup.wait(): new_markdown = "".join(self._pending) - self._new_markup.clear() self._pending.clear() + self._new_markup.clear() await asyncio.shield(self.markdown_widget.append(new_markdown)) - except asyncio.CancelledError: - + # Task has been cancelled, add any outstanding markdown new_markdown = "".join(self._pending) if new_markdown: await self.markdown_widget.append(new_markdown) From a8b2ca2b9a1923cd3cddd191f1f779f0d3aeb86d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 16 Jul 2025 16:37:40 +0100 Subject: [PATCH 05/20] renamed updater to stream --- CHANGELOG.md | 1 + src/textual/widgets/_markdown.py | 53 ++++++++++++++++++++++++++------ 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d16e5d5c80..477fde7a09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - 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 +- Added `Markdown.get_streamer` ### Changed diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 9a2745d4d7..63c865be8b 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -36,11 +36,13 @@ """ -class BackgroundUpdater: - """Update markdown document in the background. +class MarkdownStream: + """An object to manager streaming markdown. This will accumulate markdown fragments if they can't be rendered fast enough. + This object is typically created by the [Markdown.get_stream][textual.widgets.Markdown.get_stream] method. + """ def __init__(self, markdown_widget: Markdown) -> None: @@ -52,24 +54,33 @@ def __init__(self, markdown_widget: Markdown) -> None: self._task: asyncio.Task | None = None self._new_markup = asyncio.Event() self._pending: list[str] = [] + self._stopped = False def start(self) -> None: - """Start the updater running in the background.""" - self._task = asyncio.create_task(self._run()) + """Start the updater running in the background. + + No need to call this, if the object was created by [Markdown.get_stream][textual.widgets.Markdown.get_stream]. + + """ + if self._task is None: + self._task = asyncio.create_task(self._run()) async def stop(self) -> None: - """Stop the updater and await its finish.""" + """Stop the stream and await its finish.""" if self._task is not None: self._task.cancel() await self._task self._task = None + self._stopped = True - async def append(self, markdown_fragment: str) -> None: + async def write(self, markdown_fragment: str) -> None: """Append or enqueue a markdown fragment. Args: markdown_fragment: A string to append at the end of the document. """ + if self._stopped: + raise RuntimeError("Can't write to the stream after it has stopped.") if not markdown_fragment: # Nothing to do for empty strings. return @@ -978,8 +989,32 @@ async def _on_mount(self, _: Mount) -> None: ) @classmethod - def get_background_updater(cls, markdown: Markdown) -> BackgroundUpdater: - """Get an object to stream Markdown in the background. + def get_stream(cls, markdown: Markdown) -> MarkdownStream: + """Get a [MarkdownStream][textual.widgets._markdown.MarkdownStream] instance stream Markdown in the background. + + If you append to the Markdown document many times a second, it is possible the widget won't + be able to update as fast as you write (occurs around 20 appends per second). It will still + work, but the user will have to wait for the UI to catch up after the document has be retrieved. + + Using a [MarkdownStream][textual.widgets._markdown.MarkdownStream] will combine several updates in to one + as necessary to keep up with the incoming data. + + example: + ```python + @work + async def stream_markdown(self) -> None: + markdown_widget = self.query_one(Markdown) + container = self.query_one(VerticalScroll) + container.anchor() + + stream = Markdown.get_stream(markdown_widget) + try: + while (chunk:= await get_chunk()) is not None: + await stream.write(chunk) + finally: + await stream.stop() + ``` + Args: markdown: A [Markdown][textual.widgets.Markdown] widget instance. @@ -987,7 +1022,7 @@ def get_background_updater(cls, markdown: Markdown) -> BackgroundUpdater: Returns: The background updater object. """ - updater = BackgroundUpdater(markdown) + updater = MarkdownStream(markdown) updater.start() return updater From fcd5b0dd1b71564430c927cb9baeac43a3afc65e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 16 Jul 2025 16:39:07 +0100 Subject: [PATCH 06/20] changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 477fde7a09..df54af5a80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - 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 -- Added `Markdown.get_streamer` +- Added `Markdown.get_stream` ### Changed From 1e5e5d76de7c3703d6fc08b3fdaf5be90ccb0755 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 16 Jul 2025 16:40:21 +0100 Subject: [PATCH 07/20] remove comment --- src/textual/_compositor.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 5d44411b4b..6137672ffa 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -606,7 +606,6 @@ def add_widget( sub_clip = clip.intersection(child_region) if widget._anchored and not widget._anchor_released: - # scroll_y = widget.scroll_y new_scroll_y = ( arrange_result.spatial_map.total_region.bottom - ( @@ -615,8 +614,6 @@ def add_widget( ) ) widget.set_reactive(Widget.scroll_y, new_scroll_y) - # widget.watch_scroll_y(scroll_y, new_scroll_y) - # self.vertical_scrollbar.position = new_value widget.vertical_scrollbar._reactive_position = new_scroll_y if visible_only: From b3387a8551daf21a46865928afc5f2a3b19bea9c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 16 Jul 2025 16:43:19 +0100 Subject: [PATCH 08/20] docstreams --- src/textual/widgets/_markdown.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 63c865be8b..b6ef6d7338 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -1001,6 +1001,8 @@ def get_stream(cls, markdown: Markdown) -> MarkdownStream: example: ```python + # self.get_chunk is a hypothetical method that retrieves a + # markdown fragment from the network @work async def stream_markdown(self) -> None: markdown_widget = self.query_one(Markdown) @@ -1009,7 +1011,7 @@ async def stream_markdown(self) -> None: stream = Markdown.get_stream(markdown_widget) try: - while (chunk:= await get_chunk()) is not None: + while (chunk:= await self.get_chunk()) is not None: await stream.write(chunk) finally: await stream.stop() @@ -1020,7 +1022,7 @@ async def stream_markdown(self) -> None: markdown: A [Markdown][textual.widgets.Markdown] widget instance. Returns: - The background updater object. + The Markdown stream object. """ updater = MarkdownStream(markdown) updater.start() From 4659ee939424df93fbd4ab5379ba7367bb83908f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 16 Jul 2025 17:58:56 +0100 Subject: [PATCH 09/20] add api docs --- mkdocs-nav.yml | 1 + poetry.lock | 2 +- pyproject.toml | 1 + src/textual/highlight.py | 144 ++++++++----------------------- src/textual/widgets/_markdown.py | 2 +- 5 files changed, 38 insertions(+), 112 deletions(-) diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml index 3be43d7068..25cdd6fcbf 100644 --- a/mkdocs-nav.yml +++ b/mkdocs-nav.yml @@ -203,6 +203,7 @@ nav: - "api/fuzzy_matcher.md" - "api/geometry.md" - "api/getters.md" + - "api/highlight.md" - "api/layout.md" - "api/lazy.md" - "api/logger.md" diff --git a/poetry.lock b/poetry.lock index 653b4f1564..ce8f992898 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2768,4 +2768,4 @@ syntax = ["tree-sitter", "tree-sitter-bash", "tree-sitter-css", "tree-sitter-go" [metadata] lock-version = "2.0" python-versions = "^3.8.1" -content-hash = "8e8a5322af7485eeb968afb3c82e6de12d3551fe9c956217419758283332c234" +content-hash = "1876ad2e2a1494788a7a7c9a7f75d424c41502b520a7a00d253233cfd430cdc0" diff --git a/pyproject.toml b/pyproject.toml index e5e0b312bd..6b5b43890e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,7 @@ tree-sitter-sql = { version = ">=0.3.0,<0.3.8", optional = true, python = ">=3.9 tree-sitter-java = { version = ">=0.23.0", optional = true, python = ">=3.9" } tree-sitter-bash = { version = ">=0.23.0", optional = true, python = ">=3.9" } # end of [syntax] extras +pygments = "^2.19.2" [tool.poetry.extras] syntax = [ diff --git a/src/textual/highlight.py b/src/textual/highlight.py index 00390da734..a64668a88d 100644 --- a/src/textual/highlight.py +++ b/src/textual/highlight.py @@ -2,6 +2,7 @@ from pygments.lexers import get_lexer_by_name from pygments.token import Token +from pygments.util import ClassNotFound from textual.content import Content, Span @@ -9,22 +10,9 @@ class HighlightTheme: + """Contains the style definition for user with the highlight method.""" STYLES: dict[TokenType, str] = { - # Generic.Deleted: Style(color="bright_red"), - # Generic.Heading: Style(bold=True), - # Generic.Inserted: Style(color="green"), - # Generic.Prompt: Style(bold=True), - # Generic.Subheading: Style(color="magenta", bold=True), - # Name.Attribute: Style(color="cyan"), - # Name.Builtin: Style(color="cyan"), - # Name.Class: Style(color="green", underline=True), - # Name.Exception: Style(color="cyan"), - # Name.Function: Style(color="green"), - # Name.Namespace: Style(color="cyan", underline=True), - # Token: "$text", - # Token.Comment.Double: "green", - # Token.Comment.Preproc: "$success", Token.Comment: "$text 60%", Token.Error: "$error on $error-muted", Token.Generic.Error: "$error on $error-muted", @@ -32,11 +20,12 @@ class HighlightTheme: Token.Keyword.Constant: "bold $success 80%", Token.Keyword.Namespace: "$error", Token.Keyword.Type: "bold", - Token.Literal.Number.Integer: "$warning", + Token.Literal.Number: "$warning", Token.Literal.String: "$success 90%", Token.Literal.String.Doc: "$success 80% italic", Token.Literal.String.Double: "$success 90%", Token.Name: "$primary", + Token.Name.Attribute: "$warning", Token.Name.Builtin: "$accent", Token.Name.Builtin.Pseudo: "italic", Token.Name.Class: "$warning bold", @@ -44,11 +33,11 @@ class HighlightTheme: Token.Name.Decorator: "$primary bold", Token.Name.Function: "$warning underline", Token.Name.Function.Magic: "$warning underline", - Token.Name.Tag: "$primary", + Token.Name.Tag: "$primary bold", Token.Name.Variable: "$secondary", - Token.Number: "$secondary", + Token.Number: "$warning", Token.Operator: "bold", - Token.Operator.Word: "bold", + Token.Operator.Word: "bold $error", Token.String: "$success", Token.Whitespace: "", } @@ -57,16 +46,37 @@ class HighlightTheme: def highlight( code: str, language: str = "text", + *, theme: type[HighlightTheme] = HighlightTheme, tab_size: int = 8, ) -> Content: + """Apply syntax highlighting to a string. + + Args: + code: A string to highlight. + language: The language to highlight. + theme: A HighlightTheme class (type not instance). + tab_size: Number of spaces in a tab. Defaults to 8. + + Returns: + A Content instance which may be used in a widget. + """ code = "\n".join(code.splitlines()) - lexer = get_lexer_by_name( - language, - stripnl=False, - ensurenl=True, - tabsize=tab_size, - ) + try: + lexer = get_lexer_by_name( + language, + stripnl=False, + ensurenl=True, + tabsize=tab_size, + ) + except ClassNotFound: + lexer = get_lexer_by_name( + "text", + stripnl=False, + ensurenl=True, + tabsize=tab_size, + ) + token_start = 0 spans: list[Span] = [] styles = theme.STYLES @@ -84,89 +94,3 @@ def highlight( highlighted_code = Content(code, spans=spans).stylize_before("$text") return highlighted_code - - -if __name__ == "__main__": - - CODE = ''' -from typing import Iterable, Tuple, TypeVar - -T = TypeVar("T") - -# This is a comment - -class PygmentsSyntaxTheme(SyntaxTheme): - """Syntax theme that delegates to Pygments theme.""" - - def __init__(self, theme: Union[str, Type[PygmentsStyle]]) -> None: - self._style_cache: Dict[TokenType, Style] = {} - if isinstance(theme, str): - try: - self._pygments_style_class = get_style_by_name(theme) - except ClassNotFound: - self._pygments_style_class = get_style_by_name("default") - else: - self._pygments_style_class = theme - - self._background_color = self._pygments_style_class.background_color - self._background_style = Style(bgcolor=self._background_color) - - - -def loop_first(values: Iterable[T]) -> Iterable[Tuple[bool, T]]: - """Iterate and generate a tuple with a flag for first value.""" - iter_values = iter(values) - try: - value = next(iter_values) - except StopIteration: - return - yield True, value - for value in iter_values: - yield False, value - -def loop_last(values: Iterable[T]) -> Iterable[Tuple[bool, T]]: - """Iterate and generate a tuple with a flag for last value.""" - iter_values = iter(values) - try: - previous_value = next(iter_values) - except StopIteration: - return - for value in iter_values: - yield False, previous_value - previous_value = value - yield True, previous_value - - -def loop_first_last(values: Iterable[T]) -> Iterable[Tuple[bool, bool, T]]: - """Iterate and generate a tuple with a flag for first and last value.""" - iter_values = iter(values) - try: - previous_value = next(iter_values) - except StopIteration: - return - first = True - for value in iter_values: - yield first, False, previous_value - first = False - previous_value = value - yield first, True, previous_value -''' - lexer = get_lexer_by_name( - "python", - stripnl=False, - ensurenl=True, - tabsize=8, - ) - for token_type, token in lexer.get_tokens(CODE): - print(f"{token_type}, {token[:20]!r}") - - # highlight(CODE, "python") - - spans = [ - Span(0, 49, style="$text"), - ] - - c = Content("--- hello.py\t2024-01-15 10:30:00.000000000 -0800", spans=spans) - from textual.style import Style - - c.render_strips({}, 80, None, Style()) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index b6ef6d7338..17c764b3a8 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -807,7 +807,7 @@ def __init__(self, markdown: Markdown, code: str, lexer: str) -> None: if self.app.current_theme.dark else self._markdown.code_light_theme ) - code_content = highlight(self.code, "python") + code_content = highlight(self.code, lexer) self.set_content(code_content) From b0216f3f14b210a0d2c42bacce7502356f99c0f1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 16 Jul 2025 18:02:51 +0100 Subject: [PATCH 10/20] snapshots --- CHANGELOG.md | 4 +- .../test_ansi_command_palette.svg | 134 +++++++++--------- ...ommands_opens_and_displays_search_list.svg | 4 +- .../test_snapshots/test_command_palette.svg | 4 +- .../test_command_palette_discovery.svg | 4 +- ...t_markdown_component_classes_reloading.svg | 10 +- .../test_markdown_dark_theme_override.svg | 16 +-- .../test_markdown_light_theme_override.svg | 16 +-- .../test_markdown_space_squashing.svg | 42 +++--- .../test_markdown_theme_switching.svg | 18 +-- .../test_markup_command_list.svg | 4 +- .../test_snapshots/test_system_commands.svg | 4 +- 12 files changed, 124 insertions(+), 136 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df54af5a80..6574d9d739 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,11 +11,13 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - 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 -- Added `Markdown.get_stream` +- Added `Markdown.get_stream` https://github.com/Textualize/textual/pull/5966 +- Added `textual.highlight` module for syntax highlighting https://github.com/Textualize/textual/pull/5966 ### Changed - Improved rendering of Markdown tables (replace Rich table with grid) which allows text selection https://github.com/Textualize/textual/pull/5962 +- Change look of command palette, to drop accented borders https://github.com/Textualize/textual/pull/5966 ## [4.0.0] - 2025-07-12 diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_ansi_command_palette.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_ansi_command_palette.svg index 665565b457..3239f025e4 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_ansi_command_palette.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_ansi_command_palette.svg @@ -19,144 +19,144 @@ font-weight: 700; } - .terminal-2909242669-matrix { + .terminal-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2909242669-title { + .terminal-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2909242669-r1 { fill: #8a4346 } -.terminal-2909242669-r2 { fill: #868887 } -.terminal-2909242669-r3 { fill: #6b546f } -.terminal-2909242669-r4 { fill: #e0e0e0 } -.terminal-2909242669-r5 { fill: #292929 } -.terminal-2909242669-r6 { fill: #c5c8c6 } -.terminal-2909242669-r7 { fill: #0178d4 } -.terminal-2909242669-r8 { fill: #00ff00 } -.terminal-2909242669-r9 { fill: #000000 } -.terminal-2909242669-r10 { fill: #8d8d8d } -.terminal-2909242669-r11 { fill: #7e8486 } -.terminal-2909242669-r12 { fill: #e0e0e0;font-weight: bold } -.terminal-2909242669-r13 { fill: #9eafbd } -.terminal-2909242669-r14 { fill: #a1a5a8 } + .terminal-r1 { fill: #8a4346 } +.terminal-r2 { fill: #868887 } +.terminal-r3 { fill: #6b546f } +.terminal-r4 { fill: #e0e0e0 } +.terminal-r5 { fill: #292929 } +.terminal-r6 { fill: #c5c8c6 } +.terminal-r7 { fill: #0a0f13 } +.terminal-r8 { fill: #00ff00 } +.terminal-r9 { fill: #000000 } +.terminal-r10 { fill: #8d8d8d } +.terminal-r11 { fill: #7e8486 } +.terminal-r12 { fill: #e0e0e0;font-weight: bold } +.terminal-r13 { fill: #9eafbd } +.terminal-r14 { fill: #a1a5a8 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - CommandPaletteApp + CommandPaletteApp - + - - RedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed -MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed -MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed -▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - -🔎Search for commands… - - -Quit the application -Quit the application as soon as possible -Save screenshot -Save an SVG 'screenshot' of the current screen -Show keys and help panel -Show help for the focused widget and a summary of available keys -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ -MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed -MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed -MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed -MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed -MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed -MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed -MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed -MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed -MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed + + RedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed +MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed +MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + +🔎Search for commands… + + +Quit the application +Quit the application as soon as possible +Save screenshot +Save an SVG 'screenshot' of the current screen +Show keys and help panel +Show help for the focused widget and a summary of available keys +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed +MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed +MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed +MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed +MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed +MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed +MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed +MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed +MagentaRedMagentaRedMagentaRedMagentaRedMagentaRedMagentaRed diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_app_search_commands_opens_and_displays_search_list.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_app_search_commands_opens_and_displays_search_list.svg index 376c21d409..c02c89a047 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_app_search_commands_opens_and_displays_search_list.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_app_search_commands_opens_and_displays_search_list.svg @@ -35,7 +35,7 @@ .terminal-r1 { fill: #646464 } .terminal-r2 { fill: #e0e0e0 } .terminal-r3 { fill: #c5c8c6 } -.terminal-r4 { fill: #0178d4 } +.terminal-r4 { fill: #0a0f13 } .terminal-r5 { fill: #00ff00 } .terminal-r6 { fill: #000000 } .terminal-r7 { fill: #121212 } @@ -138,7 +138,7 @@ bar baz -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_command_palette.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_command_palette.svg index 57a3f2e72b..be1b1b894b 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_command_palette.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_command_palette.svg @@ -34,7 +34,7 @@ .terminal-r1 { fill: #e0e0e0 } .terminal-r2 { fill: #c5c8c6 } -.terminal-r3 { fill: #0178d4 } +.terminal-r3 { fill: #0a0f13 } .terminal-r4 { fill: #00ff00 } .terminal-r5 { fill: #000000 } .terminal-r6 { fill: #121212 } @@ -145,7 +145,7 @@ This is a test of this code 2 This is a test of this code 1 This is a test of this code 0 -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_command_palette_discovery.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_command_palette_discovery.svg index 2aa0ab9dc9..1157c6ea19 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_command_palette_discovery.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_command_palette_discovery.svg @@ -34,7 +34,7 @@ .terminal-r1 { fill: #e0e0e0 } .terminal-r2 { fill: #c5c8c6 } -.terminal-r3 { fill: #0178d4 } +.terminal-r3 { fill: #0a0f13 } .terminal-r4 { fill: #00ff00 } .terminal-r5 { fill: #000000 } .terminal-r6 { fill: #121212 } @@ -145,7 +145,7 @@ This is a test of this code 7 This is a test of this code 8 This is a test of this code 9 -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 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 6e7213f121..50841dacce 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 @@ -41,10 +41,8 @@ .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 } +.terminal-r10 { fill: #ffffff } +.terminal-r11 { fill: #47ad67 } @@ -130,7 +128,7 @@ - + @@ -146,7 +144,7 @@ strikethrough -print("Hello, world!") +print("Hello, world!") 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 a27c354ba5..a8084dcdb8 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 @@ -34,12 +34,10 @@ .terminal-r1 { fill: #c5c8c6 } .terminal-r2 { fill: #0178d4;font-weight: bold } -.terminal-r3 { fill: #d2d2d2 } -.terminal-r4 { fill: #859900 } -.terminal-r5 { fill: #839496 } -.terminal-r6 { fill: #268bd2 } -.terminal-r7 { fill: #3f4e52;font-style: italic; } -.terminal-r8 { fill: #2aa198 } +.terminal-r3 { fill: #fea62b } +.terminal-r4 { fill: #ffffff } +.terminal-r5 { fill: #fea62b;text-decoration: underline; } +.terminal-r6 { fill: #47ad67 } @@ -125,15 +123,15 @@ - + This is a H1 -defmain(): -│   print("Hello world!") +defmain(): +print("Hello world!") 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 1137c5c82e..318133af19 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 @@ -34,12 +34,10 @@ .terminal-r1 { fill: #c5c8c6 } .terminal-r2 { fill: #004578;font-weight: bold } -.terminal-r3 { fill: #d2d2d2 } -.terminal-r4 { fill: #859900 } -.terminal-r5 { fill: #657b83 } -.terminal-r6 { fill: #268bd2 } -.terminal-r7 { fill: #b0b9b9;font-style: italic; } -.terminal-r8 { fill: #2aa198 } +.terminal-r3 { fill: #fea62b } +.terminal-r4 { fill: #000000 } +.terminal-r5 { fill: #fea62b;text-decoration: underline; } +.terminal-r6 { fill: #5dc37d } @@ -125,15 +123,15 @@ - + This is a H1 -defmain(): -│   print("Hello world!") +defmain(): +print("Hello world!") diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_space_squashing.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_space_squashing.svg index 0c07aa4f8f..6c8414fc1a 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,20 +35,16 @@ .terminal-r1 { fill: #ff0000 } .terminal-r2 { fill: #c5c8c6 } .terminal-r3 { fill: #e0e0e0 } -.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 } -.terminal-r9 { fill: #e0e0e0;text-decoration: line-through; } -.terminal-r10 { fill: #d2d2d2 } -.terminal-r11 { fill: #546e7a;font-style: italic; } -.terminal-r12 { fill: #bb80b3 } -.terminal-r13 { fill: #eeffff } -.terminal-r14 { fill: #ffcb6b } -.terminal-r15 { fill: #89ddff } -.terminal-r16 { fill: #3c4e55;font-style: italic; } -.terminal-r17 { fill: #f78c6c } +.terminal-r4 { fill: #e0e0e0;text-decoration: underline; } +.terminal-r5 { fill: #e0e0e0;font-style: italic; } +.terminal-r6 { fill: #e0e0e0;font-weight: bold } +.terminal-r7 { fill: #e0e0e0;text-decoration: line-through; } +.terminal-r8 { fill: #9f9f9f } +.terminal-r9 { fill: #fea62b } +.terminal-r10 { fill: #ffffff } +.terminal-r11 { fill: #fea62b;font-weight: bold } +.terminal-r12 { fill: #419c5d;font-style: italic; } +.terminal-r13 { fill: #0178d4 } @@ -134,23 +130,23 @@ - + - X XX XX X X X X X + X XX XX X X X X X -X XX XX X X X X X +X XX XX X X X X X -X XX X X X X X +X XX X X X X X -X X▇▇X X X X X X▇▇ +X XX X X X X X ┌─────────────────────────────────────────────────────────────────────────────── -# Two spaces:  see? -classFoo: -│   '''This is    a doc    string.''' -│   some_code(1,2,3,4) +# Two spaces:  see? +classFoo: +'''This is    a doc    string.''' +some_code(1,  2,   3,      4) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_theme_switching.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markdown_theme_switching.svg index 109f4822a9..318133af19 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 @@ -34,14 +34,10 @@ .terminal-r1 { fill: #c5c8c6 } .terminal-r2 { fill: #004578;font-weight: bold } -.terminal-r3 { fill: #d2d2d2 } -.terminal-r4 { fill: #008000;font-weight: bold } -.terminal-r5 { fill: #bbbbbb } -.terminal-r6 { fill: #0000ff } -.terminal-r7 { fill: #000000 } -.terminal-r8 { fill: #77a0a0;font-style: italic; } -.terminal-r9 { fill: #008000 } -.terminal-r10 { fill: #ba2121 } +.terminal-r3 { fill: #fea62b } +.terminal-r4 { fill: #000000 } +.terminal-r5 { fill: #fea62b;text-decoration: underline; } +.terminal-r6 { fill: #5dc37d } @@ -127,15 +123,15 @@ - + This is a H1 -defmain(): -│   print("Hello world!") +defmain(): +print("Hello world!") diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markup_command_list.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markup_command_list.svg index 0e1989ccd1..f95b0fd901 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_markup_command_list.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_markup_command_list.svg @@ -34,7 +34,7 @@ .terminal-r1 { fill: #e0e0e0 } .terminal-r2 { fill: #c5c8c6 } -.terminal-r3 { fill: #0178d4 } +.terminal-r3 { fill: #0a0f13 } .terminal-r4 { fill: #00ff00 } .terminal-r5 { fill: #000000 } .terminal-r6 { fill: #121212 } @@ -140,7 +140,7 @@ Hello World Help text -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_system_commands.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_system_commands.svg index f6c2fc6436..92cda48848 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_system_commands.svg +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_system_commands.svg @@ -36,7 +36,7 @@ .terminal-r2 { fill: #0b3a5f } .terminal-r3 { fill: #c5c8c6 } .terminal-r4 { fill: #e0e0e0 } -.terminal-r5 { fill: #0178d4 } +.terminal-r5 { fill: #0a0f13 } .terminal-r6 { fill: #00ff00 } .terminal-r7 { fill: #000000 } .terminal-r8 { fill: #6d7479 } @@ -166,7 +166,7 @@ Save an SVG 'screenshot' of the current screen Show keys and help panel Show help for the focused widget and a summary of available keys -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ From ecf870a02f029ce6ebc89c683e1a3c5ab94a2fa7 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 16 Jul 2025 18:04:24 +0100 Subject: [PATCH 11/20] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6574d9d739..e554e03b9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `expand` and `shrink` attributes to GridLayout https://github.com/Textualize/textual/pull/5962 - Added `Markdown.get_stream` https://github.com/Textualize/textual/pull/5966 - Added `textual.highlight` module for syntax highlighting https://github.com/Textualize/textual/pull/5966 +- Added `MessagePump.wait_for_refresh` method https://github.com/Textualize/textual/pull/5966 ### Changed From 6bc487313837c3e29f4b9fc8cb253cdfab367932 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 16 Jul 2025 18:31:35 +0100 Subject: [PATCH 12/20] updated code browser to use new code highlight --- examples/code_browser.py | 12 ++++------- examples/code_browser.tcss | 2 ++ src/textual/highlight.py | 44 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/examples/code_browser.py b/examples/code_browser.py index d693cd0e21..56382f1a60 100644 --- a/examples/code_browser.py +++ b/examples/code_browser.py @@ -9,12 +9,13 @@ from __future__ import annotations import sys +from pathlib import Path -from rich.syntax import Syntax from rich.traceback import Traceback from textual.app import App, ComposeResult from textual.containers import Container, VerticalScroll +from textual.highlight import highlight from textual.reactive import reactive, var from textual.widgets import DirectoryTree, Footer, Header, Static @@ -68,13 +69,8 @@ def watch_path(self, path: str | None) -> None: code_view.update("") return try: - syntax = Syntax.from_path( - path, - line_numbers=True, - word_wrap=False, - indent_guides=True, - theme="github-dark" if self.current_theme.dark else "github-light", - ) + code = Path(path).read_text(encoding="utf-8") + syntax = highlight(code, path=path) except Exception: code_view.update(Traceback(theme="github-dark", width=None)) self.sub_title = "ERROR" diff --git a/examples/code_browser.tcss b/examples/code_browser.tcss index d3f1793304..7d2e5f6255 100644 --- a/examples/code_browser.tcss +++ b/examples/code_browser.tcss @@ -26,4 +26,6 @@ CodeBrowser.-show-tree #tree-view { } #code { width: auto; + padding: 0 1; + background: $surface; } diff --git a/src/textual/highlight.py b/src/textual/highlight.py index a64668a88d..cea1290366 100644 --- a/src/textual/highlight.py +++ b/src/textual/highlight.py @@ -1,6 +1,9 @@ from __future__ import annotations -from pygments.lexers import get_lexer_by_name +import os + +from pygments.lexer import Lexer +from pygments.lexers import get_lexer_by_name, guess_lexer_for_filename from pygments.token import Token from pygments.util import ClassNotFound @@ -16,6 +19,8 @@ class HighlightTheme: Token.Comment: "$text 60%", Token.Error: "$error on $error-muted", Token.Generic.Error: "$error on $error-muted", + Token.Generic.Heading: "$primary underline", + Token.Generic.Subheading: "$primary", Token.Keyword: "$accent", Token.Keyword.Constant: "bold $success 80%", Token.Keyword.Namespace: "$error", @@ -45,8 +50,9 @@ class HighlightTheme: def highlight( code: str, - language: str = "text", *, + language: str | None = None, + path: str | None = None, theme: type[HighlightTheme] = HighlightTheme, tab_size: int = 8, ) -> Content: @@ -61,6 +67,40 @@ def highlight( Returns: A Content instance which may be used in a widget. """ + if language is None and path is None: + raise RuntimeError("One of 'language' or 'path' must be supplied.") + + if language is None and path is not None: + if os.path.splitext(path)[-1] == ".tcss": + language = "scss" + + if language is None and path is not None: + lexer: Lexer | None = None + lexer_name = "default" + if code: + try: + lexer = guess_lexer_for_filename(path, code) + except ClassNotFound: + pass + + if not lexer: + try: + _, ext = os.path.splitext(path) + if ext: + extension = ext.lstrip(".").lower() + lexer = get_lexer_by_name(extension) + except ClassNotFound: + pass + + if lexer: + if lexer.aliases: + lexer_name = lexer.aliases[0] + else: + lexer_name = lexer.name + + language = lexer_name + + assert language is not None code = "\n".join(code.splitlines()) try: lexer = get_lexer_by_name( From faab42d2fd3d0d4cae26fcf1ee40afdb85b250f3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 16 Jul 2025 20:00:17 +0100 Subject: [PATCH 13/20] fix highlight --- src/textual/widgets/_markdown.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 17c764b3a8..5c115ec2dc 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -807,7 +807,7 @@ def __init__(self, markdown: Markdown, code: str, lexer: str) -> None: if self.app.current_theme.dark else self._markdown.code_light_theme ) - code_content = highlight(self.code, lexer) + code_content = highlight(self.code, language=lexer) self.set_content(code_content) From 4121fd59e33ec1b958287f47ded622daaf669890 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 16 Jul 2025 20:03:26 +0100 Subject: [PATCH 14/20] typing --- src/textual/highlight.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/textual/highlight.py b/src/textual/highlight.py index cea1290366..5ddcb95047 100644 --- a/src/textual/highlight.py +++ b/src/textual/highlight.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +from typing import Tuple from pygments.lexer import Lexer from pygments.lexers import get_lexer_by_name, guess_lexer_for_filename @@ -9,7 +10,7 @@ from textual.content import Content, Span -TokenType = tuple[str, ...] +TokenType = Tuple[str, ...] class HighlightTheme: From c32e0146d7b1d04d4aa8777a439a14d87720f64c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 16 Jul 2025 21:32:22 +0100 Subject: [PATCH 15/20] tests --- src/textual/highlight.py | 72 ++++++++++++++++++++++++---------------- tests/test_highlight.py | 30 +++++++++++++++++ 2 files changed, 73 insertions(+), 29 deletions(-) create mode 100644 tests/test_highlight.py diff --git a/src/textual/highlight.py b/src/textual/highlight.py index 5ddcb95047..0ca934518b 100644 --- a/src/textual/highlight.py +++ b/src/textual/highlight.py @@ -49,6 +49,46 @@ class HighlightTheme: } +def guess_language(code: str, path: str) -> str: + """Guess the language based on the code and path. + + Args: + code: The code to guess from. + path: A path to the code. + + Returns: + The language, suitable for use with Pygments. + """ + + if path is not None and os.path.splitext(path)[-1] == ".tcss": + return "scss" + + lexer: Lexer | None = None + lexer_name = "default" + if code: + try: + lexer = guess_lexer_for_filename(path, code) + except ClassNotFound: + pass + + if not lexer: + try: + _, ext = os.path.splitext(path) + if ext: + extension = ext.lstrip(".").lower() + lexer = get_lexer_by_name(extension) + except ClassNotFound: + pass + + if lexer: + if lexer.aliases: + lexer_name = lexer.aliases[0] + else: + lexer_name = lexer.name + + return lexer_name + + def highlight( code: str, *, @@ -71,35 +111,9 @@ def highlight( if language is None and path is None: raise RuntimeError("One of 'language' or 'path' must be supplied.") - if language is None and path is not None: - if os.path.splitext(path)[-1] == ".tcss": - language = "scss" - - if language is None and path is not None: - lexer: Lexer | None = None - lexer_name = "default" - if code: - try: - lexer = guess_lexer_for_filename(path, code) - except ClassNotFound: - pass - - if not lexer: - try: - _, ext = os.path.splitext(path) - if ext: - extension = ext.lstrip(".").lower() - lexer = get_lexer_by_name(extension) - except ClassNotFound: - pass - - if lexer: - if lexer.aliases: - lexer_name = lexer.aliases[0] - else: - lexer_name = lexer.name - - language = lexer_name + if language is None: + assert path is not None + language = guess_language(code, path) assert language is not None code = "\n".join(code.splitlines()) diff --git a/tests/test_highlight.py b/tests/test_highlight.py new file mode 100644 index 0000000000..8d8e53eece --- /dev/null +++ b/tests/test_highlight.py @@ -0,0 +1,30 @@ +import pytest + +from textual.content import Span +from textual.highlight import guess_language, highlight + + +def test_highlight() -> None: + """Test simple application of highlight.""" + import_this = highlight("import this", language="python") + assert import_this.plain == "import this" + print(import_this.spans) + assert import_this.spans == [ + Span(0, 11, style="$text"), + Span(0, 6, style="$error"), + Span(7, 11, style="$primary"), + ] + + +@pytest.mark.parametrize( + "code,path,language", + [ + ("import this", "foo.py", "python"), + ("", "foo.xml", "xml"), + ("{}", "data.json", "json"), + ("#! python2", "script.py", "python2"), + ], +) +def test_guess_language(code: str, path: str, language: str) -> None: + """Test guess_language is working.""" + assert guess_language(code, path) == language From 79f287a42eb7d85e1e50acfd85058a49f03e6388 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 16 Jul 2025 21:34:58 +0100 Subject: [PATCH 16/20] tidy --- src/textual/highlight.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/textual/highlight.py b/src/textual/highlight.py index 0ca934518b..f4b354c973 100644 --- a/src/textual/highlight.py +++ b/src/textual/highlight.py @@ -61,6 +61,7 @@ def guess_language(code: str, path: str) -> str: """ if path is not None and os.path.splitext(path)[-1] == ".tcss": + # A special case for TCSS files which aren't known outside of Textual return "scss" lexer: Lexer | None = None @@ -108,11 +109,9 @@ def highlight( Returns: A Content instance which may be used in a widget. """ - if language is None and path is None: - raise RuntimeError("One of 'language' or 'path' must be supplied.") - if language is None: - assert path is not None + if path is None: + raise RuntimeError("One of 'language' or 'path' must be supplied.") language = guess_language(code, path) assert language is not None From 3c5791b2327ccbfc5184223e68289f0af2774f65 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 16 Jul 2025 21:52:17 +0100 Subject: [PATCH 17/20] fix test --- tests/test_highlight.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_highlight.py b/tests/test_highlight.py index 8d8e53eece..9596620f14 100644 --- a/tests/test_highlight.py +++ b/tests/test_highlight.py @@ -22,7 +22,6 @@ def test_highlight() -> None: ("import this", "foo.py", "python"), ("", "foo.xml", "xml"), ("{}", "data.json", "json"), - ("#! python2", "script.py", "python2"), ], ) def test_guess_language(code: str, path: str, language: str) -> None: From 21d8709c5b18d356239b06527f7897d2f5b37331 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 16 Jul 2025 21:53:49 +0100 Subject: [PATCH 18/20] simplify --- src/textual/highlight.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/textual/highlight.py b/src/textual/highlight.py index f4b354c973..268b137ebf 100644 --- a/src/textual/highlight.py +++ b/src/textual/highlight.py @@ -141,9 +141,8 @@ def highlight( if style := styles.get(token_type): spans.append(Span(token_start, token_end, style)) break - else: - if (token_type := token_type.parent) is None: - break + if (token_type := token_type.parent) is None: + break token_start = token_end highlighted_code = Content(code, spans=spans).stylize_before("$text") From 7773bab643e7381b85e42f081e9c49ec43427c4c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 17 Jul 2025 09:31:55 +0100 Subject: [PATCH 19/20] optimized parsing --- src/textual/widgets/_markdown.py | 45 ++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index 5c115ec2dc..be65a2a316 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -13,7 +13,7 @@ from rich.text import Text from typing_extensions import TypeAlias -from textual._slug import TrackedSlugs +from textual._slug import TrackedSlugs, slug from textual.app import ComposeResult from textual.await_complete import AwaitComplete from textual.containers import Horizontal, Vertical, VerticalScroll @@ -907,6 +907,7 @@ def __init__( self._parser_factory = parser_factory self._table_of_contents: TableOfContentsType = [] self._open_links = open_links + self._last_parsed_line = 0 class TableOfContentsUpdated(Message): """The table of contents was updated.""" @@ -1117,7 +1118,9 @@ def unhandled_token(self, token: Token) -> MarkdownBlock | None: return None def _parse_markdown( - self, tokens: Iterable[Token], table_of_contents: TableOfContentsType + self, + tokens: Iterable[Token], + table_of_contents: TableOfContentsType, ) -> Iterable[MarkdownBlock]: """Create a stream of MarkdownBlock widgets from markdown. @@ -1131,13 +1134,11 @@ def _parse_markdown( stack: list[MarkdownBlock] = [] stack_append = stack.append - block_id: int = 0 for token in tokens: token_type = token.type if token_type == "heading_open": - block_id += 1 - stack_append(HEADINGS[token.tag](self, id=f"block{block_id}")) + stack_append(HEADINGS[token.tag](self)) elif token_type == "hr": yield MarkdownHorizontalRule(self) elif token_type == "paragraph_open": @@ -1180,6 +1181,7 @@ def _parse_markdown( if token.type == "heading_close": heading = block._content.plain level = int(token.tag[1:]) + block.id = f"{slug(heading)}-{len(table_of_contents) + 1}" table_of_contents.append((level, heading, block.id)) if stack: stack[-1]._blocks.append(block) @@ -1224,12 +1226,13 @@ async def await_update() -> None: """Update in batches.""" BATCH_SIZE = 200 batch: list[MarkdownBlock] = [] - tokens = await asyncio.get_running_loop().run_in_executor( - None, parser.parse, markdown - ) # Lock so that you can't update with more than one document simultaneously async with self.lock: + tokens = await asyncio.get_running_loop().run_in_executor( + None, parser.parse, markdown + ) + # Remove existing blocks for the first batch only removed: bool = False @@ -1284,34 +1287,36 @@ def append(self, markdown: str) -> AwaitComplete: ) table_of_contents: TableOfContentsType = [] - - self._markdown = updated_markdown = self.source + markdown + self._markdown = self.source + markdown + updated_source = "\n".join( + self._markdown.splitlines()[self._last_parsed_line :] + ) async def await_append() -> None: """Append new markdown widgets.""" async with self.lock: - tokens = parser.parse(updated_markdown) + tokens = parser.parse(updated_source) existing_blocks = [ child for child in self.children if isinstance(child, MarkdownBlock) ] + for token in reversed(tokens): + if token.map is not None and token.level == 0: + self._last_parsed_line += token.map[0] + break new_blocks = list(self._parse_markdown(tokens, table_of_contents)) - last_index = len(existing_blocks) - 1 - with self.app.batch_update(): if existing_blocks and new_blocks: - last_block = existing_blocks[last_index] + last_block = existing_blocks[-1] try: - await last_block._update_from_block(new_blocks[last_index]) + await last_block._update_from_block(new_blocks[0]) except IndexError: pass else: - last_index += 1 + new_blocks = new_blocks[1:] - append_blocks = new_blocks[last_index:] - if append_blocks: - self.log(append=append_blocks, children=self.children) + if new_blocks: try: - await self.mount_all(append_blocks) + await self.mount_all(new_blocks) except Exception as error: self.log(error) From 94a8fdeb0a20096a47d606b08b3f700ede748b39 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 17 Jul 2025 11:43:24 +0100 Subject: [PATCH 20/20] thin hr --- src/textual/widgets/_markdown.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/textual/widgets/_markdown.py b/src/textual/widgets/_markdown.py index be65a2a316..4e4eb9f994 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -409,7 +409,7 @@ class MarkdownHorizontalRule(MarkdownBlock): DEFAULT_CSS = """ MarkdownHorizontalRule { - border-bottom: heavy $secondary; + border-bottom: solid $secondary; height: 1; padding-top: 1; margin-bottom: 1; @@ -1315,10 +1315,7 @@ async def await_append() -> None: new_blocks = new_blocks[1:] if new_blocks: - try: - await self.mount_all(new_blocks) - except Exception as error: - self.log(error) + await self.mount_all(new_blocks) self._table_of_contents = table_of_contents self.post_message(