diff --git a/CHANGELOG.md b/CHANGELOG.md index d16e5d5c80..e554e03b9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,14 @@ 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` 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 - 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/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/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/_compositor.py b/src/textual/_compositor.py index 6941bf96c0..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,7 +614,7 @@ def add_widget( ) ) widget.set_reactive(Widget.scroll_y, new_scroll_y) - widget.watch_scroll_y(scroll_y, new_scroll_y) + widget.vertical_scrollbar._reactive_position = new_scroll_y if visible_only: placements = arrange_result.get_visible_placements( diff --git a/src/textual/command.py b/src/textual/command.py index 0d74f1993f..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 $border; + border-bottom: hkey black; 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/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 new file mode 100644 index 0000000000..268b137ebf --- /dev/null +++ b/src/textual/highlight.py @@ -0,0 +1,149 @@ +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 +from pygments.token import Token +from pygments.util import ClassNotFound + +from textual.content import Content, Span + +TokenType = Tuple[str, ...] + + +class HighlightTheme: + """Contains the style definition for user with the highlight method.""" + + STYLES: dict[TokenType, str] = { + 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", + Token.Keyword.Type: "bold", + 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", + Token.Name.Constant: "$error", + Token.Name.Decorator: "$primary bold", + Token.Name.Function: "$warning underline", + Token.Name.Function.Magic: "$warning underline", + Token.Name.Tag: "$primary bold", + Token.Name.Variable: "$secondary", + Token.Number: "$warning", + Token.Operator: "bold", + Token.Operator.Word: "bold $error", + Token.String: "$success", + Token.Whitespace: "", + } + + +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": + # A special case for TCSS files which aren't known outside of Textual + 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, + *, + language: str | None = None, + path: str | None = None, + 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. + """ + if language is 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 + code = "\n".join(code.splitlines()) + 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 + + for token_type, token in lexer.get_tokens(code): + token_end = token_start + len(token) + while True: + if style := styles.get(token_type): + spans.append(Span(token_start, token_end, style)) + break + if (token_type := token_type.parent) is None: + break + token_start = token_end + + highlighted_code = Content(code, spans=spans).stylize_before("$text") + return highlighted_code 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/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 2fa5948cac..4e4eb9f994 100644 --- a/src/textual/widgets/_markdown.py +++ b/src/textual/widgets/_markdown.py @@ -10,17 +10,17 @@ 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 -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 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 @@ -36,6 +36,73 @@ """ +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: + """ + Args: + markdown_widget: Markdown widget to update. + """ + self.markdown_widget = markdown_widget + 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. + + 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 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 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 + # 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._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) + + class Navigator: """Manages a stack of paths like a browser.""" @@ -342,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; @@ -618,19 +685,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) @@ -712,14 +779,15 @@ class MarkdownFence(MarkdownBlock): DEFAULT_CSS = """ MarkdownFence { + padding: 1 2; margin: 1 0; - overflow: auto; - width: 100%; - height: auto; - max-height: 20; + overflow: hidden; + width: 1fr; + height: auto; color: rgb(210,210,210); background: black 10%; - + text-wrap: nowrap; + text-overflow: clip; &:light { background: white 30%; } @@ -739,44 +807,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 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, language=lexer) + self.set_content(code_content) HEADINGS = { @@ -799,7 +831,7 @@ class Markdown(Widget): layout: vertical; color: $foreground; # background: $surface; - overflow-y: auto; + overflow-y: hidden; &:focus { background-tint: $foreground 5%; @@ -875,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.""" @@ -956,6 +989,46 @@ async def _on_mount(self, _: Mount) -> None: ).set_sender(self) ) + @classmethod + 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 + # 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) + container = self.query_one(VerticalScroll) + container.anchor() + + stream = Markdown.get_stream(markdown_widget) + try: + while (chunk:= await self.get_chunk()) is not None: + await stream.write(chunk) + finally: + await stream.stop() + ``` + + + Args: + markdown: A [Markdown][textual.widgets.Markdown] widget instance. + + Returns: + The Markdown stream object. + """ + updater = MarkdownStream(markdown) + updater.start() + return updater + def on_markdown_link_clicked(self, event: LinkClicked) -> None: if self._open_links: self.app.open_url(event.href) @@ -1045,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. @@ -1059,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": @@ -1108,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) @@ -1152,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 @@ -1212,38 +1287,35 @@ 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.""" - 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_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)) 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) - try: - await self.mount_all(append_blocks) - except Exception as error: - self.log(error) + if new_blocks: + await self.mount_all(new_blocks) self._table_of_contents = table_of_contents self.post_message( 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 -▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ 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()) diff --git a/tests/test_highlight.py b/tests/test_highlight.py new file mode 100644 index 0000000000..9596620f14 --- /dev/null +++ b/tests/test_highlight.py @@ -0,0 +1,29 @@ +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"), + ], +) +def test_guess_language(code: str, path: str, language: str) -> None: + """Test guess_language is working.""" + assert guess_language(code, path) == language