Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 4 additions & 8 deletions examples/code_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions examples/code_browser.tcss
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,6 @@ CodeBrowser.-show-tree #tree-view {
}
#code {
width: auto;
padding: 0 1;
background: $surface;
}
1 change: 1 addition & 0 deletions mkdocs-nav.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
3 changes: 1 addition & 2 deletions src/textual/_compositor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
- (
Expand All @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions src/textual/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions src/textual/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
149 changes: 149 additions & 0 deletions src/textual/highlight.py
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions src/textual/message_pump.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/textual/selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading
Loading