diff options
| author | robot-piglet <[email protected]> | 2026-05-12 21:20:36 +0300 |
|---|---|---|
| committer | robot-piglet <[email protected]> | 2026-05-12 21:50:01 +0300 |
| commit | 0a39e4306959ffbe146072ec95f6c3bd23b3ec81 (patch) | |
| tree | da79285e7d71f84af9f6c748d55da1be8119ca6c /contrib/python | |
| parent | f60daa06f7e5e86306edf466d944534ab66c3bf1 (diff) | |
Intermediate changes
commit_hash:6f99e4234152d8dc77848a96be0b67a933e60c0d
Diffstat (limited to 'contrib/python')
36 files changed, 545 insertions, 340 deletions
diff --git a/contrib/python/textual/.dist-info/METADATA b/contrib/python/textual/.dist-info/METADATA index d99b9bcfe07..a03aa2700b6 100644 --- a/contrib/python/textual/.dist-info/METADATA +++ b/contrib/python/textual/.dist-info/METADATA @@ -1,6 +1,6 @@ Metadata-Version: 2.1 Name: textual -Version: 2.1.2 +Version: 3.0.1 Summary: Modern Text User Interface framework Home-page: https://github.com/Textualize/textual License: MIT diff --git a/contrib/python/textual/textual/_compositor.py b/contrib/python/textual/textual/_compositor.py index 528ec258f67..76103ec47cc 100644 --- a/contrib/python/textual/textual/_compositor.py +++ b/contrib/python/textual/textual/_compositor.py @@ -900,6 +900,9 @@ class Compositor: x -= region.x + gutter_left y -= region.y + gutter_right + if y < 0: + return None, None + visible_screen_stack.set(widget.app._background_screens) line = widget.render_line(y) diff --git a/contrib/python/textual/textual/_tree_sitter.py b/contrib/python/textual/textual/_tree_sitter.py index 193bf16fd41..9d099c109b7 100644 --- a/contrib/python/textual/textual/_tree_sitter.py +++ b/contrib/python/textual/textual/_tree_sitter.py @@ -1,49 +1,44 @@ from __future__ import annotations +from importlib import import_module + +from textual import log + try: - import tree_sitter_bash - import tree_sitter_css - import tree_sitter_go - import tree_sitter_html - import tree_sitter_java - import tree_sitter_javascript - import tree_sitter_json - import tree_sitter_markdown - import tree_sitter_python - import tree_sitter_regex - import tree_sitter_rust - import tree_sitter_sql - import tree_sitter_toml - import tree_sitter_xml - import tree_sitter_yaml from tree_sitter import Language - _tree_sitter = True + _LANGUAGE_CACHE: dict[str, Language] = {} - _languages = { - "python": Language(tree_sitter_python.language()), - "json": Language(tree_sitter_json.language()), - "markdown": Language(tree_sitter_markdown.language()), - "yaml": Language(tree_sitter_yaml.language()), - "toml": Language(tree_sitter_toml.language()), - "rust": Language(tree_sitter_rust.language()), - "html": Language(tree_sitter_html.language()), - "css": Language(tree_sitter_css.language()), - "xml": Language(tree_sitter_xml.language_xml()), - "regex": Language(tree_sitter_regex.language()), - "sql": Language(tree_sitter_sql.language()), - "javascript": Language(tree_sitter_javascript.language()), - "java": Language(tree_sitter_java.language()), - "bash": Language(tree_sitter_bash.language()), - "go": Language(tree_sitter_go.language()), - } + _tree_sitter = True def get_language(language_name: str) -> Language | None: - return _languages.get(language_name) + if language_name in _LANGUAGE_CACHE: + return _LANGUAGE_CACHE[language_name] + + try: + module = import_module(f"tree_sitter_{language_name}") + except ImportError: + return None + else: + try: + if language_name == "xml": + # xml uses language_xml() instead of language() + # it's the only outlier amongst the languages in the `textual[syntax]` extra + language = Language(module.language_xml(), name=language_name) + else: + language = Language(module.language(), name=language_name) + except (OSError, AttributeError): + log.warning(f"Could not load language {language_name!r}.") + return None + else: + _LANGUAGE_CACHE[language_name] = language + return language except ImportError: _tree_sitter = False - _languages = {} + + def get_language(language_name: str) -> Language | None: + return None + TREE_SITTER = _tree_sitter -BUILTIN_LANGUAGES: dict[str, "Language"] = _languages diff --git a/contrib/python/textual/textual/app.py b/contrib/python/textual/textual/app.py index 0dc77cae309..94bafee479b 100644 --- a/contrib/python/textual/textual/app.py +++ b/contrib/python/textual/textual/app.py @@ -798,6 +798,9 @@ class App(Generic[ReturnType], DOMNode): self.supports_smooth_scrolling: bool = False """Does the terminal support smooth scrolling?""" + self._compose_screen: Screen | None = None + """The screen composed by App.compose.""" + if self.ENABLE_COMMAND_PALETTE: for _key, binding in self._bindings: if binding.action in {"command_palette", "app.command_palette"}: @@ -833,6 +836,10 @@ class App(Generic[ReturnType], DOMNode): return super().__init_subclass__(*args, **kwargs) + def _get_dom_base(self) -> DOMNode: + """When querying from the app, we want to query the default screen.""" + return self.default_screen + def validate_title(self, title: Any) -> str: """Make sure the title is set to a string.""" return str(title) @@ -842,6 +849,11 @@ class App(Generic[ReturnType], DOMNode): return str(sub_title) @property + def default_screen(self) -> Screen: + """The default screen instance.""" + return self.screen if self._compose_screen is None else self._compose_screen + + @property def workers(self) -> WorkerManager: """The [worker](/guide/workers/) manager. @@ -1026,7 +1038,7 @@ class App(Generic[ReturnType], DOMNode): @property def debug(self) -> bool: """Is debug mode enabled?""" - return "debug" in self.features + return "debug" in self.features or constants.DEBUG @property def is_headless(self) -> bool: @@ -1773,6 +1785,10 @@ class App(Generic[ReturnType], DOMNode): ) -> None: """Bind a key to an action. + !!! warning + This method may be private or removed in a future version of Textual. + See [dynamic actions](/guide/actions#dynamic-actions) for a more flexible alternative to updating bindings. + Args: keys: A comma separated list of keys, i.e. action: Action to bind to. @@ -2671,6 +2687,7 @@ class App(Generic[ReturnType], DOMNode): if self._screen_stack: self.screen.post_message(events.ScreenSuspend()) + self.screen.refresh() next_screen, await_mount = self._get_screen(screen) try: message_pump = active_message_pump.get() @@ -3240,6 +3257,7 @@ class App(Generic[ReturnType], DOMNode): async def _on_compose(self) -> None: _rich_traceback_omit = True + self._compose_screen = self.screen try: widgets = [*self.screen._nodes, *compose(self)] except TypeError as error: diff --git a/contrib/python/textual/textual/content.py b/contrib/python/textual/textual/content.py index 397addff475..08b45aec6f9 100644 --- a/contrib/python/textual/textual/content.py +++ b/contrib/python/textual/textual/content.py @@ -1,6 +1,6 @@ """ Content is a container for text, with spans marked up with color / style. -If is equivalent to Rich's Text object, with support for more of Textual features. +It is equivalent to Rich's Text object, with support for more of Textual features. Unlike Rich Text, Content is *immutable* so you can't modify it in place, and most methods will return a new Content instance. This is more like the builtin str, and allows Textual to make some significant optimizations. @@ -39,6 +39,9 @@ __all__ = ["ContentType", "Content", "Span"] ContentType: TypeAlias = Union["Content", str] """Type alias used where content and a str are interchangeable in a function.""" +ContentText: TypeAlias = Union["Content", Text, str] +"""A type that may be used to construct Text.""" + ANSI_DEFAULT = Style( background=Color(0, 0, 0, 0, ansi=-1), foreground=Color(0, 0, 0, 0, ansi=-1), @@ -119,11 +122,12 @@ class Content(Visual): def __init__( self, - text: str, + text: str = "", spans: list[Span] | None = None, cell_length: int | None = None, ) -> None: """ + Initialize a Content object. Args: text: text content. @@ -133,6 +137,8 @@ class Content(Visual): self._text: str = _strip_control_codes(text) self._spans: list[Span] = [] if spans is None else spans self._cell_length = cell_length + self._optimal_width_cache: int | None = None + self._height_cache: tuple[tuple[int, str, bool] | None, int] = (None, 0) def __str__(self) -> str: return self._text @@ -168,6 +174,46 @@ class Content(Visual): return markup @classmethod + def empty(cls) -> Content: + """Get an empty (blank) content""" + return EMPTY_CONTENT + + @classmethod + def from_text( + cls, markup_content_or_text: ContentText, markup: bool = True + ) -> Content: + """Construct content from Text or str. If the argument is already Content, then + return it unmodified. + + This method exists to make (Rich) Text and Content interchangeable. While Content + is preferred, we don't want to make it harder than necessary for apps to use Text. + + Args: + markup_content_or_text: Value to create Content from. + markup: If `True`, then str values will be parsed as markup, otherwise they will + be considered literals. + + Raises: + TypeError: If the supplied argument is not a valid type. + + Returns: + A new Content instance. + """ + if isinstance(markup_content_or_text, Content): + return markup_content_or_text + elif isinstance(markup_content_or_text, str): + if markup: + return cls.from_markup(markup_content_or_text) + else: + return cls(markup_content_or_text) + elif isinstance(markup_content_or_text, Text): + return cls.from_rich_text(markup_content_or_text) + else: + raise TypeError( + "This method expects a str, a Text instance, or a Content instance" + ) + + @classmethod def from_markup(cls, markup: str | Content, **variables: object) -> Content: """Create content from Textual markup, optionally combined with template variables. @@ -207,6 +253,8 @@ class Content(Visual): Args: text: String or Rich Text. + console: A Console object to use if parsing Rich Console markup, or `None` to + use app default. Returns: New Content. @@ -219,7 +267,12 @@ class Content(Visual): if console is not None: get_style = console.get_style else: - get_style = RichStyle.parse + try: + app = active_app.get() + except LookupError: + get_style = RichStyle.parse + else: + get_style = app.console.get_style if text._spans: try: @@ -279,7 +332,7 @@ class Content(Visual): @classmethod def assemble( - cls, *parts: str | Content | tuple[str, str], end: str = "" + cls, *parts: str | Content | tuple[str, str | Style], end: str = "" ) -> Content: """Construct new content from string, content, or tuples of (TEXT, STYLE). @@ -366,11 +419,7 @@ class Content(Visual): return False return self.spans == content.spans - def get_optimal_width( - self, - rules: RulesMap, - container_width: int, - ) -> int: + def get_optimal_width(self, rules: RulesMap, container_width: int) -> int: """Get optimal width of the Visual to display its content. The exact definition of "optimal width" is dependant on the Visual, but @@ -379,14 +428,18 @@ class Content(Visual): Args: rules: A mapping of style rules, such as the Widgets `styles` object. - container_width: The size of the container in cells. Returns: A width in cells. """ - width = max(cell_len(line) for line in self.plain.split("\n")) - return width + if self._optimal_width_cache is None: + self._optimal_width_cache = width = max( + cell_len(line) for line in self.plain.split("\n") + ) + else: + width = self._optimal_width_cache + return width + rules.get("line_pad", 0) * 2 def get_height(self, rules: RulesMap, width: int) -> int: """Get the height of the Visual if rendered at the given width. @@ -398,12 +451,20 @@ class Content(Visual): Returns: A height in lines. """ - lines = self.without_spans._wrap_and_format( - width, - overflow=rules.get("text_overflow", "fold"), - no_wrap=rules.get("text_wrap", "wrap") == "nowrap", - ) - return len(lines) + get_rule = rules.get + line_pad = get_rule("line_pad", 0) * 2 + overflow = get_rule("text_overflow", "fold") + no_wrap = get_rule("text_wrap", "wrap") == "nowrap" + cache_key = (width + line_pad, overflow, no_wrap) + if self._height_cache[0] == cache_key: + height = self._height_cache[1] + else: + lines = self.without_spans._wrap_and_format( + width - line_pad, overflow=overflow, no_wrap=no_wrap + ) + height = len(lines) + self._height_cache = (cache_key, height) + return height def _wrap_and_format( self, @@ -411,9 +472,11 @@ class Content(Visual): align: TextAlign = "left", overflow: TextOverflow = "fold", no_wrap: bool = False, + line_pad: int = 0, tab_size: int = 8, selection: Selection | None = None, selection_style: Style | None = None, + post_style: Style | None = None, ) -> list[_FormattedLine]: """Wraps the text and applies formatting. @@ -439,6 +502,10 @@ class Content(Visual): return None for y, line in enumerate(self.split(allow_blank=True)): + + if post_style is not None: + line = line.stylize(post_style) + if selection_style is not None and (span := get_span(y)) is not None: start, end = span if end == -1: @@ -460,15 +527,27 @@ class Content(Visual): new_lines = [content_line] else: content_line = _FormattedLine(line, width, y=y, align=align) - offsets = divide_line(line.plain, width, fold=overflow == "fold") + offsets = divide_line( + line.plain, width - line_pad * 2, fold=overflow == "fold" + ) divided_lines = content_line.content.divide(offsets) + ellipsis = overflow == "ellipsis" divided_lines = [ - line.truncate(width, ellipsis=overflow == "ellipsis") - for line in divided_lines + ( + line.truncate(width, ellipsis=ellipsis) + if last + else line.rstrip().truncate(width, ellipsis=ellipsis) + ) + for last, line in loop_last(divided_lines) ] + new_lines = [ _FormattedLine( - content.rstrip_end(width), width, offset, y, align=align + content.rstrip_end(width).pad(line_pad, line_pad), + width, + offset, + y, + align=align, ) for content, offset in zip(divided_lines, [0, *offsets]) ] @@ -486,6 +565,7 @@ class Content(Visual): style: Style, selection: Selection | None = None, selection_style: Style | None = None, + post_style: Style | None = None, ) -> list[Strip]: """Render the visual into an iterable of strips. Part of the Visual protocol. @@ -496,6 +576,7 @@ class Content(Visual): style: The base style to render on top of. selection: Selection information, if applicable, otherwise `None`. selection_style: Selection style if `selection` is not `None`. + post_style: Style | None = None, Returns: An list of Strips. @@ -504,14 +585,17 @@ class Content(Visual): if not width: return [] + get_rule = rules.get lines = self._wrap_and_format( width, - align=rules.get("text_align", "left"), - overflow=rules.get("text_overflow", "fold"), - no_wrap=rules.get("text_wrap", "wrap") == "nowrap", + align=get_rule("text_align", "left"), + overflow=get_rule("text_overflow", "fold"), + no_wrap=get_rule("text_wrap", "wrap") == "nowrap", + line_pad=get_rule("line_pad", 0), tab_size=8, selection=selection, selection_style=selection_style, + post_style=post_style, ) if height is not None: @@ -565,6 +649,13 @@ class Content(Visual): """The content with no spans""" return Content(self.plain, [], self._cell_length) + @property + def first_line(self) -> Content: + """The first line of the content.""" + if "\n" not in self.plain: + return self + return self[: self.plain.index("\n")] + def __getitem__(self, slice: int | slice) -> Content: def get_text_at(offset: int) -> "Content": _Span = Span @@ -836,6 +927,34 @@ class Content(Visual): ) return self + def pad(self, left: int, right: int, character: str = " ") -> Content: + """Pad both the left and right edges with a given number of characters. + + Args: + left (int): Number of characters to pad on the left. + right (int): Number of characters to pad on the right. + character (str, optional): Character to pad with. Defaults to " ". + """ + assert len(character) == 1, "Character must be a string of length 1" + if left or right: + text = f"{character * left}{self.plain}{character * right}" + _Span = Span + if left: + spans = [ + _Span(start + left, end + left, style) + for start, end, style in self._spans + ] + else: + spans = self._spans + content = Content( + text, + spans, + None if self._cell_length is None else self._cell_length + left + right, + ) + return content + + return self + def center(self, width: int, ellipsis: bool = False) -> Content: """Align a line to the center. @@ -849,7 +968,7 @@ class Content(Visual): content = self.rstrip().truncate(width, ellipsis=ellipsis) left = (width - content.cell_length) // 2 right = width - left - content = content.pad_left(left).pad_right(right) + content = content.pad(left, right) return content def right(self, width: int, ellipsis: bool = False) -> Content: @@ -1403,3 +1522,6 @@ class _FormattedLine: if style is not None ] return segments + + +EMPTY_CONTENT: Final = Content("") diff --git a/contrib/python/textual/textual/css/_style_properties.py b/contrib/python/textual/textual/css/_style_properties.py index be591276a9b..472e5fd26c4 100644 --- a/contrib/python/textual/textual/css/_style_properties.py +++ b/contrib/python/textual/textual/css/_style_properties.py @@ -126,7 +126,7 @@ class IntegerProperty(GenericProperty[int, int]): if isinstance(value, (int, float)): return int(value) else: - raise StyleValueError(f"Expected a number here, got f{value}") + raise StyleValueError(f"Expected a number here, got {value!r}") class BooleanProperty(GenericProperty[bool, bool]): diff --git a/contrib/python/textual/textual/css/_styles_builder.py b/contrib/python/textual/textual/css/_styles_builder.py index 668a7606610..fe52f639033 100644 --- a/contrib/python/textual/textual/css/_styles_builder.py +++ b/contrib/python/textual/textual/css/_styles_builder.py @@ -1069,6 +1069,7 @@ class StylesBuilder: process_row_span = _process_integer process_grid_size_columns = _process_integer process_grid_size_rows = _process_integer + process_line_pad = _process_integer def process_grid_gutter(self, name: str, tokens: list[Token]) -> None: if not tokens: diff --git a/contrib/python/textual/textual/css/styles.py b/contrib/python/textual/textual/css/styles.py index 24930f4810a..03907bea4a9 100644 --- a/contrib/python/textual/textual/css/styles.py +++ b/contrib/python/textual/textual/css/styles.py @@ -204,6 +204,8 @@ class RulesMap(TypedDict, total=False): text_wrap: TextWrap text_overflow: TextOverflow + line_pad: int + RULE_NAMES = list(RulesMap.__annotations__.keys()) RULE_NAMES_SET = frozenset(RULE_NAMES) @@ -243,6 +245,7 @@ class StylesBase: "link_background_hover", "text_wrap", "text_overflow", + "line_pad", } node: DOMNode | None = None @@ -489,6 +492,8 @@ class StylesBase: text_overflow: StringEnumProperty[TextOverflow] = StringEnumProperty( VALID_TEXT_OVERFLOW, "fold" ) + line_pad = IntegerProperty(default=0, layout=True) + """Padding added to left and right of lines.""" def __textual_animation__( self, @@ -1284,6 +1289,8 @@ class Styles(StylesBase): append_declaration("text-wrap", self.text_wrap) if "text_overflow" in rules: append_declaration("text-overflow", self.text_overflow) + if "line_pad" in rules: + append_declaration("line-pad", str(self.line_pad)) lines.sort() return lines diff --git a/contrib/python/textual/textual/document/_syntax_aware_document.py b/contrib/python/textual/textual/document/_syntax_aware_document.py index 162d3fbd544..00305dcec2f 100644 --- a/contrib/python/textual/textual/document/_syntax_aware_document.py +++ b/contrib/python/textual/textual/document/_syntax_aware_document.py @@ -8,7 +8,6 @@ except ImportError: TREE_SITTER = False -from textual._tree_sitter import BUILTIN_LANGUAGES from textual.document._document import Document, EditResult, Location, _utf8_encode @@ -17,59 +16,30 @@ class SyntaxAwareDocumentError(Exception): class SyntaxAwareDocument(Document): - """A wrapper around a Document which also maintains a tree-sitter syntax + """A subclass of Document which also maintains a tree-sitter syntax tree when the document is edited. - - The primary reason for this split is actually to keep tree-sitter stuff separate, - since it isn't supported in Python 3.7. By having the tree-sitter code - isolated in this subclass, it makes it easier to conditionally import. However, - it does come with other design flaws (e.g. Document is required to have methods - which only really make sense on SyntaxAwareDocument). - - If you're reading this and Python 3.7 is no longer supported by Textual, - consider merging this subclass into the `Document` superclass. """ def __init__( self, text: str, - language: str | Language, + language: Language, ): """Construct a SyntaxAwareDocument. Args: text: The initial text contained in the document. - language: The language to use. You can pass a string to use a supported - language, or pass in your own tree-sitter `Language` object. + language: The tree-sitter language to use. """ if not TREE_SITTER: - raise RuntimeError("SyntaxAwareDocument unavailable.") + raise RuntimeError( + "SyntaxAwareDocument unavailable - tree-sitter is not installed." + ) super().__init__(text) - self.language: Language | None = None - """The tree-sitter Language or None if tree-sitter is unavailable.""" - - self._parser: Parser | None = None - - from textual._tree_sitter import get_language - - # If the language is `None`, then avoid doing any parsing related stuff. - if isinstance(language, str): - if language not in BUILTIN_LANGUAGES: - raise SyntaxAwareDocumentError(f"Invalid language {language!r}") - # If tree-sitter-languages is not installed properly, get_language - # and get_parser may raise an OSError when unable to load their - # resources - - try: - self.language = get_language(language) - except OSError as e: - raise SyntaxAwareDocumentError( - f"Could not find binaries for {language!r}" - ) from e - else: - self.language = language + self.language: Language = language + """The tree-sitter Language.""" self._parser = Parser(self.language) """The tree-sitter Parser or None if tree-sitter is unavailable.""" @@ -90,16 +60,6 @@ class SyntaxAwareDocument(Document): Returns: The prepared query. """ - if not TREE_SITTER: - raise SyntaxAwareDocumentError( - "Couldn't prepare query - tree-sitter is not available on this architecture." - ) - - if self.language is None: - raise SyntaxAwareDocumentError( - "Couldn't prepare query - no language assigned." - ) - return self.language.query(query) def query_syntax_tree( @@ -122,12 +82,6 @@ class SyntaxAwareDocument(Document): Returns: A tuple containing the nodes and text captured by the query. """ - - if not TREE_SITTER: - raise SyntaxAwareDocumentError( - "tree-sitter is not available on this architecture." - ) - captures_kwargs = {} if start_point is not None: captures_kwargs["start_point"] = start_point diff --git a/contrib/python/textual/textual/dom.py b/contrib/python/textual/textual/dom.py index 9587bca5865..00b5595c659 100644 --- a/contrib/python/textual/textual/dom.py +++ b/contrib/python/textual/textual/dom.py @@ -235,6 +235,17 @@ class DOMNode(MessagePump): super().__init__() + def _get_dom_base(self) -> DOMNode: + """Get the DOM base node (typically self). + + All DOM queries on this node will use the return value as the root node. + This method allows the App to query the default screen, and not the active screen. + + Returns: + DOMNode. + """ + return self + def set_reactive( self, reactive: Reactive[ReactiveType], value: ReactiveType ) -> None: @@ -1380,10 +1391,11 @@ class DOMNode(MessagePump): from textual.css.query import DOMQuery, QueryType from textual.widget import Widget + node = self._get_dom_base() if isinstance(selector, str) or selector is None: - return DOMQuery[Widget](self, filter=selector) + return DOMQuery[Widget](node, filter=selector) else: - return DOMQuery[QueryType](self, filter=selector.__name__) + return DOMQuery[QueryType](node, filter=selector.__name__) if TYPE_CHECKING: @@ -1411,10 +1423,11 @@ class DOMNode(MessagePump): from textual.css.query import DOMQuery, QueryType from textual.widget import Widget + node = self._get_dom_base() if isinstance(selector, str) or selector is None: - return DOMQuery[Widget](self, deep=False, filter=selector) + return DOMQuery[Widget](node, deep=False, filter=selector) else: - return DOMQuery[QueryType](self, deep=False, filter=selector.__name__) + return DOMQuery[QueryType](node, deep=False, filter=selector.__name__) if TYPE_CHECKING: @@ -1449,6 +1462,8 @@ class DOMNode(MessagePump): """ _rich_traceback_omit = True + base_node = self._get_dom_base() + if isinstance(selector, str): query_selector = selector else: @@ -1462,20 +1477,20 @@ class DOMNode(MessagePump): ) from None if all(selectors.is_simple for selectors in selector_set): - cache_key = (self._nodes._updates, query_selector, expect_type) - cached_result = self._query_one_cache.get(cache_key) + cache_key = (base_node._nodes._updates, query_selector, expect_type) + cached_result = base_node._query_one_cache.get(cache_key) if cached_result is not None: return cached_result else: cache_key = None - for node in walk_depth_first(self, with_root=False): + for node in walk_depth_first(base_node, with_root=False): if not match(selector_set, node): continue if expect_type is not None and not isinstance(node, expect_type): continue if cache_key is not None: - self._query_one_cache[cache_key] = node + base_node._query_one_cache[cache_key] = node return node raise NoMatches(f"No nodes match {selector!r} on {self!r}") @@ -1518,6 +1533,8 @@ class DOMNode(MessagePump): """ _rich_traceback_omit = True + base_node = self._get_dom_base() + if isinstance(selector, str): query_selector = selector else: @@ -1531,14 +1548,14 @@ class DOMNode(MessagePump): ) from None if all(selectors.is_simple for selectors in selector_set): - cache_key = (self._nodes._updates, query_selector, expect_type) - cached_result = self._query_one_cache.get(cache_key) + cache_key = (base_node._nodes._updates, query_selector, expect_type) + cached_result = base_node._query_one_cache.get(cache_key) if cached_result is not None: return cached_result else: cache_key = None - children = walk_depth_first(self, with_root=False) + children = walk_depth_first(base_node, with_root=False) iter_children = iter(children) for node in iter_children: if not match(selector_set, node): @@ -1553,7 +1570,7 @@ class DOMNode(MessagePump): "Call to query_one resulted in more than one matched node" ) if cache_key is not None: - self._query_one_cache[cache_key] = node + base_node._query_one_cache[cache_key] = node return node raise NoMatches(f"No nodes match {selector!r} on {self!r}") @@ -1589,6 +1606,7 @@ class DOMNode(MessagePump): Returns: A DOMNode or subclass if `expect_type` is provided. """ + base_node = self._get_dom_base() if isinstance(selector, str): query_selector = selector else: @@ -1600,8 +1618,8 @@ class DOMNode(MessagePump): raise InvalidQueryFormat( f"Unable to parse {query_selector!r} as a query; check for syntax errors" ) from None - if self.parent is not None: - for node in self.parent.ancestors_with_self: + if base_node.parent is not None: + for node in base_node.parent.ancestors_with_self: if not match(selector_set, node): continue if expect_type is not None and not isinstance(node, expect_type): diff --git a/contrib/python/textual/textual/drivers/_writer_thread.py b/contrib/python/textual/textual/drivers/_writer_thread.py index 572d353aaba..a26ef46fbbc 100644 --- a/contrib/python/textual/textual/drivers/_writer_thread.py +++ b/contrib/python/textual/textual/drivers/_writer_thread.py @@ -13,7 +13,7 @@ class WriterThread(threading.Thread): """A thread / file-like to do writes to stdout in the background.""" def __init__(self, file: IO[str]) -> None: - super().__init__(daemon=True) + super().__init__(daemon=True, name="textual-output") self._queue: Queue[str | None] = Queue(MAX_QUEUED_WRITES) self._file = file diff --git a/contrib/python/textual/textual/drivers/linux_driver.py b/contrib/python/textual/textual/drivers/linux_driver.py index 4d1c34ce975..66f4c4d9784 100644 --- a/contrib/python/textual/textual/drivers/linux_driver.py +++ b/contrib/python/textual/textual/drivers/linux_driver.py @@ -276,7 +276,7 @@ class LinuxDriver(Driver): self.write("\x1b[>1u") # https://sw.kovidgoyal.net/kitty/keyboard-protocol/ self.flush() - self._key_thread = Thread(target=self._run_input_thread) + self._key_thread = Thread(target=self._run_input_thread, name="textual-input") send_size_event() self._key_thread.start() self._request_terminal_sync_mode_support() diff --git a/contrib/python/textual/textual/drivers/linux_inline_driver.py b/contrib/python/textual/textual/drivers/linux_inline_driver.py index 173cb606b79..14aa61fba0a 100644 --- a/contrib/python/textual/textual/drivers/linux_inline_driver.py +++ b/contrib/python/textual/textual/drivers/linux_inline_driver.py @@ -237,7 +237,7 @@ class LinuxInlineDriver(Driver): termios.tcsetattr(self.fileno, termios.TCSANOW, newattr) - self._key_thread = Thread(target=self._run_input_thread) + self._key_thread = Thread(target=self._run_input_thread, name="textual-input") send_size_event() self._key_thread.start() self._request_terminal_sync_mode_support() diff --git a/contrib/python/textual/textual/drivers/web_driver.py b/contrib/python/textual/textual/drivers/web_driver.py index 2dd319f54de..f21d19ed381 100644 --- a/contrib/python/textual/textual/drivers/web_driver.py +++ b/contrib/python/textual/textual/drivers/web_driver.py @@ -62,7 +62,9 @@ class WebDriver(Driver): self.fileno = sys.__stdout__.fileno() self._write = partial(os.write, self.fileno) self.exit_event = Event() - self._key_thread: Thread = Thread(target=self.run_input_thread) + self._key_thread: Thread = Thread( + target=self.run_input_thread, name="textual-input" + ) self._input_reader = InputReader() self._deliveries: dict[str, BinaryIO | TextIO] = {} diff --git a/contrib/python/textual/textual/drivers/win32.py b/contrib/python/textual/textual/drivers/win32.py index 059bb0275a1..a8eee1724b6 100644 --- a/contrib/python/textual/textual/drivers/win32.py +++ b/contrib/python/textual/textual/drivers/win32.py @@ -223,7 +223,7 @@ class EventMonitor(threading.Thread): self.app = app self.exit_event = exit_event self.process_event = process_event - super().__init__() + super().__init__(name="textual-input") def run(self) -> None: exit_requested = self.exit_event.is_set diff --git a/contrib/python/textual/textual/markup.py b/contrib/python/textual/textual/markup.py index 93d1d12ed5e..11ee5a132ff 100644 --- a/contrib/python/textual/textual/markup.py +++ b/contrib/python/textual/textual/markup.py @@ -119,12 +119,22 @@ class StyleTokenizer(TokenizerState): } -STYLES = {"bold", "dim", "italic", "underline", "reverse", "strike"} +STYLES = { + "bold", + "dim", + "italic", + "underline", + "underline2", + "reverse", + "strike", + "blink", +} STYLE_ABBREVIATIONS = { "b": "bold", "d": "dim", "i": "italic", "u": "underline", + "uu": "underline2", "r": "reverse", "s": "strike", } diff --git a/contrib/python/textual/textual/pilot.py b/contrib/python/textual/textual/pilot.py index 115c10cb703..a9ff1d2067c 100644 --- a/contrib/python/textual/textual/pilot.py +++ b/contrib/python/textual/textual/pilot.py @@ -414,7 +414,7 @@ class Pilot(Generic[ReturnType]): elif isinstance(widget, Widget): target_widget = widget else: - target_widget = app.query_one(widget) + target_widget = app.screen.query_one(widget) message_arguments = _get_mouse_message_arguments( target_widget, diff --git a/contrib/python/textual/textual/screen.py b/contrib/python/textual/textual/screen.py index df69e690a8c..3178435c4ab 100644 --- a/contrib/python/textual/textual/screen.py +++ b/contrib/python/textual/textual/screen.py @@ -1306,7 +1306,7 @@ class Screen(Generic[ScreenResultType], Widget): inline_height = min(self.app.size.height, inline_height) return inline_height - def _screen_resized(self, size: Size): + def _screen_resized(self, size: Size) -> None: """Called by App when the screen is resized.""" if self.stack_updates: self._refresh_layout(size) diff --git a/contrib/python/textual/textual/scroll_view.py b/contrib/python/textual/textual/scroll_view.py index 9cb1c9f1ede..03a05013b18 100644 --- a/contrib/python/textual/textual/scroll_view.py +++ b/contrib/python/textual/textual/scroll_view.py @@ -33,13 +33,15 @@ class ScrollView(ScrollableContainer): return True def watch_scroll_x(self, old_value: float, new_value: float) -> None: - if self.show_horizontal_scrollbar and old_value != new_value: + if self.show_horizontal_scrollbar: self.horizontal_scrollbar.position = new_value + if round(old_value) != round(new_value): self.refresh() def watch_scroll_y(self, old_value: float, new_value: float) -> None: - if self.show_vertical_scrollbar and (old_value) != (new_value): + if self.show_vertical_scrollbar: self.vertical_scrollbar.position = new_value + if round(old_value) != round(new_value): self.refresh() def on_mount(self): @@ -174,7 +176,6 @@ class ScrollView(ScrollableContainer): y_start: First line to refresh. line_count: Total number of lines to refresh. """ - refresh_region = Region( 0, y_start - self.scroll_offset.y, diff --git a/contrib/python/textual/textual/scrollbar.py b/contrib/python/textual/textual/scrollbar.py index 4f5ab6212fa..e28d9db9419 100644 --- a/contrib/python/textual/textual/scrollbar.py +++ b/contrib/python/textual/textual/scrollbar.py @@ -394,9 +394,6 @@ class ScrollBarCorner(Widget): """Widget which fills the gap between horizontal and vertical scrollbars, should they both be present.""" - def __init__(self, name: str | None = None): - super().__init__(name=name) - def render(self) -> RenderableType: assert self.parent is not None styles = self.parent.styles diff --git a/contrib/python/textual/textual/style.py b/contrib/python/textual/textual/style.py index c96c9b7af65..43c4be09207 100644 --- a/contrib/python/textual/textual/style.py +++ b/contrib/python/textual/textual/style.py @@ -10,6 +10,7 @@ from __future__ import annotations from dataclasses import dataclass from functools import cached_property, lru_cache from marshal import dumps, loads +from operator import attrgetter from typing import TYPE_CHECKING, Any, Iterable, Mapping import rich.repr @@ -23,6 +24,23 @@ if TYPE_CHECKING: from textual.css.styles import StylesBase +_get_hash_attributes = attrgetter( + "background", + "foreground", + "bold", + "dim", + "italic", + "underline", + "underline2", + "reverse", + "strike", + "blink", + "link", + "auto_color", + "_meta", +) + + @rich.repr.auto(angular=True) @dataclass(frozen=True) class Style: @@ -38,8 +56,10 @@ class Style: dim: bool | None = None italic: bool | None = None underline: bool | None = None + underline2: bool | None = None reverse: bool | None = None strike: bool | None = None + blink: bool | None = None link: str | None = None _meta: bytes | None = None auto_color: bool = False @@ -51,8 +71,10 @@ class Style: yield "dim", self.dim, None yield "italic", self.italic, None yield "underline", self.underline, None + yield "underline2", self.underline2, None yield "reverse", self.reverse, None yield "strike", self.strike, None + yield "blink", self.blink, None yield "link", self.link, None if self._meta is not None: @@ -67,29 +89,18 @@ class Style: and self.dim is None and self.italic is None and self.underline is None + and self.underline2 is None and self.reverse is None and self.strike is None + and self.blink is None and self.link is None and self._meta is None ) @cached_property def hash(self) -> int: - return hash( - ( - self.background, - self.foreground, - self.bold, - self.dim, - self.italic, - self.underline, - self.reverse, - self.strike, - self.link, - self.auto_color, - self._meta, - ) - ) + """A hash of the style's attributes.""" + return hash(_get_hash_attributes(self)) def __hash__(self) -> int: return self.hash @@ -122,8 +133,12 @@ class Style: output_append("italic" if self.italic else "not italic") if self.underline is not None: output_append("underline" if self.underline else "not underline") + if self.underline2 is not None: + output_append("underline2" if self.underline2 else "not underline2") if self.strike is not None: output_append("strike" if self.strike else "not strike") + if self.blink is not None: + output_append("blink" if self.blink else "not blink") if self.link is not None: if "'" not in self.link: output_append(f"link='{self.link}'") @@ -160,8 +175,12 @@ class Style: output_append("italic" if self.italic else "not italic") if self.underline is not None: output_append("underline" if self.underline else "not underline") + if self.underline2 is not None: + output_append("underline2" if self.underline2 else "not underline2") if self.strike is not None: output_append("strike" if self.strike else "not strike") + if self.blink is not None: + output_append("blink" if self.blink else "not blink") if self.link is not None: output_append("link") if self._meta is not None: @@ -189,8 +208,10 @@ class Style: self.dim if other.dim is None else other.dim, self.italic if other.italic is None else other.italic, self.underline if other.underline is None else other.underline, + self.underline2 if other.underline2 is None else other.underline2, self.reverse if other.reverse is None else other.reverse, self.strike if other.strike is None else other.strike, + self.blink if other.blink is None else other.blink, self.link if other.link is None else other.link, ( dumps({**self.meta, **other.meta}) @@ -275,8 +296,10 @@ class Style: dim=rich_style.dim, italic=rich_style.italic, underline=rich_style.underline, + underline2=rich_style.underline2, reverse=rich_style.reverse, strike=rich_style.strike, + blink=rich_style.blink, link=rich_style.link, _meta=rich_style._meta, ) @@ -301,13 +324,14 @@ class Style: dim=text_style.italic, italic=text_style.italic, underline=text_style.underline, + underline2=text_style.underline2, reverse=text_style.reverse, strike=text_style.strike, auto_color=styles.auto_color, ) @classmethod - def from_meta(cls, meta: dict[str, str]) -> Style: + def from_meta(cls, meta: Mapping[str, Any]) -> Style: """Create a Visual Style containing meta information. Args: @@ -333,8 +357,10 @@ class Style: dim=self.dim, italic=self.italic, underline=self.underline, + underline2=self.underline2, reverse=self.reverse, strike=self.strike, + blink=self.blink, link=self.link, meta=None if self._meta is None else self.meta, ) @@ -359,8 +385,10 @@ class Style: dim=self.dim, italic=self.italic, underline=self.underline, + underline2=self.underline2, reverse=self.reverse, strike=self.strike, + blink=self.blink, link=self.link, meta={**self.meta, "offset": (x, y)}, ) @@ -373,8 +401,10 @@ class Style: dim=self.dim, italic=self.italic, underline=self.underline, + underline2=self.underline2, reverse=self.reverse, strike=self.strike, + blink=self.blink, link=self.link, _meta=self._meta, ) @@ -384,6 +414,11 @@ class Style: """Just the background color, with no other attributes.""" return Style(self.background, _meta=self._meta) + @property + def has_transparent_foreground(self) -> bool: + """Is the foreground transparent (or not set)?""" + return self.foreground is None or self.foreground.a == 0 + @classmethod def combine(cls, styles: Iterable[Style]) -> Style: """Add a number of styles and get the result.""" diff --git a/contrib/python/textual/textual/visual.py b/contrib/python/textual/textual/visual.py index af58d5ef0a7..edd20e31d42 100644 --- a/contrib/python/textual/textual/visual.py +++ b/contrib/python/textual/textual/visual.py @@ -119,6 +119,7 @@ class Visual(ABC): style: Style, selection: Selection | None = None, selection_style: Style | None = None, + post_style: Style | None = None, ) -> list[Strip]: """Render the Visual into an iterable of strips. @@ -129,6 +130,7 @@ class Visual(ABC): style: The base style to render on top of. selection: Selection information, if applicable, otherwise `None`. selection_style: Selection style if `selection` is not `None`. + post_style: Optional style to apply post render. Returns: An list of Strips. @@ -144,7 +146,8 @@ class Visual(ABC): Args: rules: A mapping of style rules, such as the Widgets `styles` object. - container_width: The size of the container in cells. + container_width: The width of the container, used by Rich Renderables. + May be ignored for Textual Visuals. Returns: A width in cells. @@ -173,6 +176,7 @@ class Visual(ABC): style: Style, *, pad: bool = False, + post_style: Style | None = None, ) -> list[Strip]: """High level function to render a visual to strips. @@ -183,6 +187,7 @@ class Visual(ABC): height: Desired height (in lines) or `None` for no limit. style: A (Visual) Style instance. pad: Pad to desired width? + post_style: Optional Style to apply to strips after rendering. Returns: A list of Strips containing the render. @@ -208,7 +213,7 @@ class Visual(ABC): if height is None: height = len(strips) - rich_style = style.rich_style + rich_style = (style + Style(reverse=False)).rich_style if pad: strips = [strip.extend_cell_length(width, rich_style) for strip in strips] content_align = widget.styles.content_align @@ -261,7 +266,6 @@ class RichVisual(Visual): width = measure( console, self._renderable, container_width, container_width=container_width ) - return width def get_height(self, rules: RulesMap, width: int) -> int: @@ -292,6 +296,7 @@ class RichVisual(Visual): style: Style, selection: Selection | None = None, selection_style: Style | None = None, + post_style: Style | None = None, ) -> list[Strip]: console = active_app.get().console options = console.options.update( @@ -340,7 +345,10 @@ class Padding(Visual): ) def get_height(self, rules: RulesMap, width: int) -> int: - return self._visual.get_height(rules, width) + self._spacing.height + return ( + self._visual.get_height(rules, width - self._spacing.width) + + self._spacing.height + ) def render_strips( self, @@ -350,6 +358,7 @@ class Padding(Visual): style: Style, selection: Selection | None = None, selection_style: Style | None = None, + post_style: Style | None = None, ) -> list[Strip]: padding = self._spacing top, right, bottom, left = self._spacing diff --git a/contrib/python/textual/textual/widget.py b/contrib/python/textual/textual/widget.py index 2d56a37ff97..21214c0e465 100644 --- a/contrib/python/textual/textual/widget.py +++ b/contrib/python/textual/textual/widget.py @@ -651,6 +651,22 @@ class Widget(DOMNode): """Text selection information, or `None` if no text is selected in this widget.""" return self.screen.selections.get(self, None) + def preflight_checks(self) -> None: + """Called in debug mode to do preflight checks. + + This is used by Textual to log some common errors, but you could implement this + in custom widgets to perform additional checks. + + """ + + if hasattr(self, "CSS"): + from textual.screen import Screen + + if not isinstance(self, Screen): + self.log.warning( + f"'{self.__class__.__name__}.CSS' will be ignored (use 'DEFAULT_CSS' class variable for widgets)" + ) + def _cover(self, widget: Widget) -> None: """Set a widget used to replace the visuals of this widget (used for loading indicator). @@ -1090,7 +1106,7 @@ class Widget(DOMNode): else: text_background = background if has_rule("color"): - color = styles.color + color = styles.color.multiply_alpha(styles.text_opacity) style += styles.text_style if has_rule("auto_color") and styles.auto_color: color = text_background.get_contrast_text(color.a) @@ -1114,7 +1130,7 @@ class Widget(DOMNode): @overload def render_str(self, text_content: Content) -> Content: ... - def render_str(self, text_content: str | Content) -> Content | Text: + def render_str(self, text_content: str | Content) -> Content: """Convert str into a [Content][textual.content.Content] instance. If you pass in an existing Content instance it will be returned unaltered. @@ -1475,6 +1491,8 @@ class Widget(DOMNode): tie_breaker=tie_breaker, scope=scope, ) + if app.debug: + app.call_next(self.preflight_checks) def _get_box_model( self, @@ -1642,7 +1660,7 @@ class Widget(DOMNode): return self._content_width_cache[1] visual = self._render() - width = visual.get_optimal_width(self, container.width) + width = visual.get_optimal_width(self.styles, container.width) if self.expand: width = max(container.width, width) @@ -3853,6 +3871,7 @@ class Widget(DOMNode): bold=style.bold, dim=style.dim, italic=style.italic, + reverse=style.reverse, underline=style.underline, strike=style.strike, ) @@ -3984,7 +4003,6 @@ class Widget(DOMNode): Returns: The `Widget` instance. """ - if layout: self._layout_required = True for ancestor in self.ancestors: @@ -4314,10 +4332,11 @@ class Widget(DOMNode): async def _on_click(self, event: events.Click) -> None: if event.widget is self: - if event.chain == 2: - self.text_select_all() - elif event.chain == 3 and self.parent is not None: - self.select_container.text_select_all() + if self.allow_select and self.screen.allow_select and self.app.ALLOW_SELECT: + if event.chain == 2: + self.text_select_all() + elif event.chain == 3 and self.parent is not None: + self.select_container.text_select_all() await self.broker_event("click", event) diff --git a/contrib/python/textual/textual/widgets/_button.py b/contrib/python/textual/textual/widgets/_button.py index 20b45722071..c11ff39fb09 100644 --- a/contrib/python/textual/textual/widgets/_button.py +++ b/contrib/python/textual/textual/widgets/_button.py @@ -6,7 +6,6 @@ from typing import TYPE_CHECKING, cast import rich.repr from rich.cells import cell_len from rich.console import ConsoleRenderable, RenderableType -from rich.text import Text, TextType from typing_extensions import Literal, Self from textual import events @@ -17,10 +16,10 @@ if TYPE_CHECKING: from rich.style import Style from textual.binding import Binding +from textual.content import Content, ContentText from textual.css._error_tools import friendly_list from textual.geometry import Size from textual.message import Message -from textual.pad import HorizontalPad from textual.reactive import reactive from textual.widget import Widget @@ -58,6 +57,7 @@ class Button(Widget, can_focus=True): text-align: center; content-align: center middle; text-style: bold; + line-pad: 1; &:disabled { text-opacity: 0.6; @@ -154,7 +154,7 @@ class Button(Widget, can_focus=True): BINDINGS = [Binding("enter", "press", "Press button", show=False)] - label: reactive[TextType] = reactive[TextType]("") + label: reactive[ContentText] = reactive[ContentText](Content.empty) """The text label that appears within the button.""" variant = reactive("default", init=False) @@ -182,7 +182,7 @@ class Button(Widget, can_focus=True): def __init__( self, - label: TextType | None = None, + label: ContentText | None = None, variant: ButtonVariant = "default", *, name: str | None = None, @@ -209,7 +209,7 @@ class Button(Widget, can_focus=True): if label is None: label = self.css_identifier_styled - self.label = label + self.label = Content.from_text(label) self.variant = variant self.action = action self.active_effect_duration = 0.2 @@ -219,6 +219,7 @@ class Button(Widget, can_focus=True): self.tooltip = tooltip def get_content_width(self, container: Size, viewport: Size) -> int: + assert isinstance(self.label, Content) try: return max([cell_len(line) for line in self.label.plain.splitlines()]) + 2 except ValueError: @@ -240,23 +241,13 @@ class Button(Widget, can_focus=True): self.remove_class(f"-{old_variant}") self.add_class(f"-{variant}") - def validate_label(self, label: TextType) -> Text: + def validate_label(self, label: ContentText) -> Content: """Parse markup for self.label""" - if isinstance(label, str): - return Text.from_markup(label) - return label + return Content.from_text(label) def render(self) -> RenderResult: - assert isinstance(self.label, Text) - label = self.label.copy() - label.stylize_before(self.rich_style) - return HorizontalPad( - label, - 1, - 1, - self.rich_style, - self._get_justify_method() or "center", - ) + assert isinstance(self.label, Content) + return self.label def post_render( self, renderable: RenderableType, base_style: Style @@ -305,7 +296,7 @@ class Button(Widget, can_focus=True): @classmethod def success( cls, - label: TextType | None = None, + label: ContentText | None = None, *, name: str | None = None, id: str | None = None, @@ -338,7 +329,7 @@ class Button(Widget, can_focus=True): @classmethod def warning( cls, - label: TextType | None = None, + label: ContentText | None = None, *, name: str | None = None, id: str | None = None, @@ -371,7 +362,7 @@ class Button(Widget, can_focus=True): @classmethod def error( cls, - label: TextType | None = None, + label: ContentText | None = None, *, name: str | None = None, id: str | None = None, diff --git a/contrib/python/textual/textual/widgets/_label.py b/contrib/python/textual/textual/widgets/_label.py index 9cd739e729b..89a1dc1db18 100644 --- a/contrib/python/textual/textual/widgets/_label.py +++ b/contrib/python/textual/textual/widgets/_label.py @@ -4,9 +4,7 @@ from __future__ import annotations from typing import Literal -from rich.console import RenderableType - -from textual.visual import SupportsVisual +from textual.visual import VisualType from textual.widgets._static import Static LabelVariant = Literal["success", "error", "warning", "primary", "secondary", "accent"] @@ -50,7 +48,8 @@ class Label(Static): def __init__( self, - renderable: RenderableType | SupportsVisual = "", + # TODO: Should probably be renamed to `content`. + renderable: VisualType = "", *, variant: LabelVariant | None = None, expand: bool = False, diff --git a/contrib/python/textual/textual/widgets/_log.py b/contrib/python/textual/textual/widgets/_log.py index 7e7fadaefaf..bcf9c871320 100644 --- a/contrib/python/textual/textual/widgets/_log.py +++ b/contrib/python/textual/textual/widgets/_log.py @@ -195,16 +195,21 @@ class Log(ScrollView, can_focus=True): self.scroll_end(animate=False, immediate=True, x_axis=False) return self - def write_line(self, line: str) -> Self: + def write_line( + self, + line: str, + scroll_end: bool | None = None, + ) -> Self: """Write content on a new line. Args: line: String to write to the log. + scroll_end: Scroll to the end after writing, or `None` to use `self.auto_scroll`. Returns: The `Log` instance. """ - self.write_lines([line]) + self.write_lines([line], scroll_end) return self def write_lines( diff --git a/contrib/python/textual/textual/widgets/_select.py b/contrib/python/textual/textual/widgets/_select.py index c87112ae923..5ba4df571a4 100644 --- a/contrib/python/textual/textual/widgets/_select.py +++ b/contrib/python/textual/textual/widgets/_select.py @@ -477,7 +477,8 @@ class Select(Generic[SelectType], Vertical, can_focus=True): """ value = self.value - assert not isinstance(value, NoSelection) + if isinstance(value, NoSelection): + return None return value def _setup_variables_for_options( diff --git a/contrib/python/textual/textual/widgets/_selection_list.py b/contrib/python/textual/textual/widgets/_selection_list.py index 8ef829491ca..7fe2636f05d 100644 --- a/contrib/python/textual/textual/widgets/_selection_list.py +++ b/contrib/python/textual/textual/widgets/_selection_list.py @@ -8,11 +8,11 @@ from typing import Callable, ClassVar, Generic, Iterable, TypeVar, cast from rich.repr import Result from rich.segment import Segment from rich.style import Style -from rich.text import Text, TextType from typing_extensions import Self from textual import events from textual.binding import Binding +from textual.content import Content, ContentText from textual.messages import Message from textual.strip import Strip from textual.widgets._option_list import ( @@ -39,7 +39,7 @@ class Selection(Generic[SelectionType], Option): def __init__( self, - prompt: TextType, + prompt: ContentText, value: SelectionType, initial_state: bool = False, id: str | None = None, @@ -54,10 +54,9 @@ class Selection(Generic[SelectionType], Option): id: The optional ID for the selection. disabled: The initial enabled/disabled state. Enabled by default. """ - if isinstance(prompt, str): - prompt = Text.from_markup(prompt, overflow="ellipsis") - prompt.no_wrap = True - super().__init__(prompt.split()[0], id, disabled) + + selection_prompt = Content.from_text(prompt) + super().__init__(selection_prompt.split()[0], id, disabled) self._value: SelectionType = value """The value associated with the selection.""" self._initial_state: bool = initial_state @@ -102,6 +101,8 @@ class SelectionList(Generic[SelectionType], OptionList): DEFAULT_CSS = """ SelectionList { height: auto; + text-wrap: nowrap; + text-overflow: ellipsis; & > .selection-list--button { color: $panel-darken-2; @@ -213,8 +214,8 @@ class SelectionList(Generic[SelectionType], OptionList): def __init__( self, *selections: Selection[SelectionType] - | tuple[TextType, SelectionType] - | tuple[TextType, SelectionType, bool], + | tuple[ContentText, SelectionType] + | tuple[ContentText, SelectionType, bool], name: str | None = None, id: str | None = None, classes: str | None = None, @@ -440,8 +441,8 @@ class SelectionList(Generic[SelectionType], OptionList): self, selection: ( Selection[SelectionType] - | tuple[TextType, SelectionType] - | tuple[TextType, SelectionType, bool] + | tuple[ContentText, SelectionType] + | tuple[ContentText, SelectionType, bool] ), ) -> Selection[SelectionType]: """Turn incoming selection data into a `Selection` instance. @@ -461,7 +462,7 @@ class SelectionList(Generic[SelectionType], OptionList): if isinstance(selection, tuple): if len(selection) == 2: selection = cast( - "tuple[TextType, SelectionType, bool]", (*selection, False) + "tuple[ContentText, SelectionType, bool]", (*selection, False) ) elif len(selection) != 3: raise SelectionError(f"Expected 2 or 3 values, got {len(selection)}") @@ -626,8 +627,8 @@ class SelectionList(Generic[SelectionType], OptionList): items: Iterable[ OptionListContent | Selection[SelectionType] - | tuple[TextType, SelectionType] - | tuple[TextType, SelectionType, bool] + | tuple[ContentText, SelectionType] + | tuple[ContentText, SelectionType, bool] ], ) -> Self: """Add new selection options to the end of the list. @@ -654,7 +655,7 @@ class SelectionList(Generic[SelectionType], OptionList): cleaned_options.append( self._make_selection( cast( - "tuple[TextType, SelectionType] | tuple[TextType, SelectionType, bool]", + "tuple[ContentText, SelectionType] | tuple[ContentText, SelectionType, bool]", item, ) ) @@ -681,8 +682,8 @@ class SelectionList(Generic[SelectionType], OptionList): item: ( OptionListContent | Selection - | tuple[TextType, SelectionType] - | tuple[TextType, SelectionType, bool] + | tuple[ContentText, SelectionType] + | tuple[ContentText, SelectionType, bool] ) = None, ) -> Self: """Add a new selection option to the end of the list. diff --git a/contrib/python/textual/textual/widgets/_static.py b/contrib/python/textual/textual/widgets/_static.py index 7a354dab738..097d2cb165d 100644 --- a/contrib/python/textual/textual/widgets/_static.py +++ b/contrib/python/textual/textual/widgets/_static.py @@ -2,14 +2,13 @@ from __future__ import annotations from typing import TYPE_CHECKING -from rich.console import RenderableType from rich.protocol import is_renderable if TYPE_CHECKING: from textual.app import RenderResult from textual.errors import RenderError -from textual.visual import SupportsVisual, Visual, VisualType, visualize +from textual.visual import Visual, VisualType, visualize from textual.widget import Widget @@ -33,7 +32,7 @@ class Static(Widget, inherit_bindings=False): """A widget to display simple static content, or use as a base class for more complex widgets. Args: - content: A Rich renderable, or string containing console markup. + content: A Content object, Rich renderable, or string containing console markup. expand: Expand content if required to fill container. shrink: Shrink content if required to fill container. markup: True if markup should be parsed and rendered. @@ -49,11 +48,11 @@ class Static(Widget, inherit_bindings=False): } """ - _renderable: RenderableType | SupportsVisual + _renderable: VisualType def __init__( self, - content: RenderableType | SupportsVisual = "", + content: VisualType = "", *, expand: bool = False, shrink: bool = False, @@ -78,11 +77,12 @@ class Static(Widget, inherit_bindings=False): return self._visual @property - def renderable(self) -> RenderableType | SupportsVisual: + def renderable(self) -> VisualType: return self._content or "" + # TODO: Should probably be renamed to `content`. @renderable.setter - def renderable(self, renderable: RenderableType | SupportsVisual) -> None: + def renderable(self, renderable: VisualType) -> None: self._renderable = renderable self._visual = None self.clear_cached_dimensions() diff --git a/contrib/python/textual/textual/widgets/_tabbed_content.py b/contrib/python/textual/textual/widgets/_tabbed_content.py index cca85280904..a7fcb9b0aa6 100644 --- a/contrib/python/textual/textual/widgets/_tabbed_content.py +++ b/contrib/python/textual/textual/widgets/_tabbed_content.py @@ -6,13 +6,12 @@ from itertools import zip_longest from typing import Awaitable from rich.repr import Result -from rich.text import TextType from typing_extensions import Final from textual import events from textual.app import ComposeResult from textual.await_complete import AwaitComplete -from textual.content import ContentType +from textual.content import ContentText, ContentType from textual.css.query import NoMatches from textual.message import Message from textual.reactive import reactive @@ -79,7 +78,7 @@ class ContentTabs(Tabs): def __init__( self, - *tabs: Tab | TextType, + *tabs: Tab | ContentText, active: str | None = None, tabbed_content: TabbedContent, ): diff --git a/contrib/python/textual/textual/widgets/_tabs.py b/contrib/python/textual/textual/widgets/_tabs.py index d4d2f455c30..ac856ebd651 100644 --- a/contrib/python/textual/textual/widgets/_tabs.py +++ b/contrib/python/textual/textual/widgets/_tabs.py @@ -1,29 +1,28 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, ClassVar +from typing import ClassVar import rich.repr from rich.style import Style -from rich.text import Text, TextType +from rich.text import Text from textual import events from textual.app import ComposeResult, RenderResult from textual.await_complete import AwaitComplete from textual.binding import Binding, BindingType from textual.containers import Container, Horizontal, Vertical +from textual.content import Content, ContentText from textual.css.query import NoMatches from textual.events import Mount from textual.geometry import Offset from textual.message import Message from textual.reactive import reactive from textual.renderables.bar import Bar +from textual.visual import VisualType from textual.widget import Widget from textual.widgets import Static -if TYPE_CHECKING: - from textual.content import Content, ContentType - class Underline(Widget): """The animated underline beneath tabs.""" @@ -150,7 +149,7 @@ class Tab(Static): def __init__( self, - label: ContentType, + label: ContentText, *, id: str | None = None, classes: str | None = None, @@ -167,7 +166,7 @@ class Tab(Static): super().__init__(id=id, classes=classes, disabled=disabled) self._label: Content # Setter takes Text or str - self.label = label # type: ignore[assignment] + self.label = Content.from_text(label) @property def label(self) -> Content: @@ -175,13 +174,13 @@ class Tab(Static): return self._label @label.setter - def label(self, label: ContentType) -> None: - self._label = self.render_str(label) + def label(self, label: ContentText) -> None: + self._label = Content.from_text(label) self.update(self._label) - def update(self, content: ContentType = "") -> None: + def update(self, content: VisualType = "") -> None: self.post_message(self.Relabelled(self)) - return super().update(self.render_str(content)) + return super().update(content) @property def label_text(self) -> str: @@ -347,7 +346,7 @@ class Tabs(Widget, can_focus=True): def __init__( self, - *tabs: Tab | TextType, + *tabs: Tab | ContentText, active: str | None = None, name: str | None = None, id: str | None = None, @@ -369,7 +368,7 @@ class Tabs(Widget, can_focus=True): add_tabs = [ ( Tab(tab, id=f"tab-{self._new_tab_id}") - if isinstance(tab, (str, Text)) + if isinstance(tab, (str, Content, Text)) else self._auto_tab_id(tab) ) for tab in tabs @@ -435,7 +434,7 @@ class Tabs(Widget, can_focus=True): def add_tab( self, - tab: Tab | str | Text, + tab: Tab | ContentText, *, before: Tab | str | None = None, after: Tab | str | None = None, @@ -487,7 +486,7 @@ class Tabs(Widget, can_focus=True): from_empty = self.tab_count == 0 tab_widget = ( Tab(tab, id=f"tab-{self._new_tab_id}") - if isinstance(tab, (str, Text)) + if isinstance(tab, (str, Content, Text)) else self._auto_tab_id(tab) ) diff --git a/contrib/python/textual/textual/widgets/_text_area.py b/contrib/python/textual/textual/widgets/_text_area.py index 687ef8107d1..542ff28cd1b 100644 --- a/contrib/python/textual/textual/widgets/_text_area.py +++ b/contrib/python/textual/textual/widgets/_text_area.py @@ -14,7 +14,7 @@ from rich.text import Text from typing_extensions import Literal from textual._text_area_theme import TextAreaTheme -from textual._tree_sitter import BUILTIN_LANGUAGES, TREE_SITTER +from textual._tree_sitter import TREE_SITTER, get_language from textual.color import Color from textual.document._document import ( Document, @@ -57,6 +57,25 @@ HighlightName = str Highlight = Tuple[StartColumn, EndColumn, HighlightName] """A tuple representing a syntax highlight within one line.""" +BUILTIN_LANGUAGES = [ + "python", + "markdown", + "json", + "toml", + "yaml", + "html", + "css", + "javascript", + "rust", + "go", + "regex", + "sql", + "java", + "bash", + "xml", +] +"""Languages that are included in the `syntax` extras.""" + class ThemeDoesNotExist(Exception): """Raised when the user tries to use a theme which does not exist. @@ -72,17 +91,16 @@ class LanguageDoesNotExist(Exception): @dataclass class TextAreaLanguage: - """A container for a language which has been registered with the TextArea. - - Attributes: - name: The name of the language. - language: The tree-sitter Language. - highlight_query: The tree-sitter highlight query corresponding to the language, as a string. - """ + """A container for a language which has been registered with the TextArea.""" name: str - language: "Language" + """The name of the language""" + + language: "Language" | None + """The tree-sitter language object if that has been overridden, or None if it is a built-in language.""" + highlight_query: str + """The tree-sitter highlight query to use for syntax highlighting.""" class TextArea(ScrollView): @@ -423,14 +441,12 @@ TextArea { super().__init__(name=name, id=id, classes=classes, disabled=disabled) self._languages: dict[str, TextAreaLanguage] = {} - """Maps language names to TextAreaLanguage.""" - - for language_name, language_object in BUILTIN_LANGUAGES.items(): - self._languages[language_name] = TextAreaLanguage( - language_name, - language_object, - self._get_builtin_highlight_query(language_name), - ) + """Maps language names to TextAreaLanguage. This is only used for languages + registered by end-users using `TextArea.register_language`. If a user attempts + to set `TextArea.language` to a language that is not registered here, we'll + attempt to get it from the environment. If that fails, we'll fall back to + plain text. + """ self._themes: dict[str, TextAreaTheme] = {} """Maps theme names to TextAreaTheme.""" @@ -740,16 +756,6 @@ TextArea { def _watch_language(self, language: str | None) -> None: """When the language is updated, update the type of document.""" - if not TREE_SITTER: - return - - if language is not None and language not in self.available_languages: - raise LanguageDoesNotExist( - f"{language!r} is not a builtin language, or it has not been registered. " - f"To use a custom language, register it first using `register_language`, " - f"then switch to it by setting the `TextArea.language` attribute." - ) - self._set_document(self.document.text, language) def _watch_show_line_numbers(self) -> None: @@ -839,14 +845,13 @@ TextArea { @property def available_languages(self) -> set[str]: - """A list of the names of languages available to the `TextArea`. + """A set of the names of languages available to the `TextArea`. - The values in this list can be assigned to the `language` reactive attribute + The values in this set can be assigned to the `language` reactive attribute of `TextArea`. - The returned list contains the builtin languages plus those registered via the - `register_language` method. Builtin languages will be listed before - user-registered languages, but there are no other ordering guarantees. + The returned set contains the builtin languages installed with the syntax extras, + plus those registered via the `register_language` method. """ return set(BUILTIN_LANGUAGES) | self._languages.keys() @@ -872,8 +877,6 @@ TextArea { language: A tree-sitter `Language` object. highlight_query: The highlight query to use for syntax highlighting this language. """ - if not TREE_SITTER: - return self._languages[name] = TextAreaLanguage(name, language, highlight_query) def update_highlight_query(self, name: str, highlight_query: str) -> None: @@ -884,11 +887,12 @@ TextArea { highlight_query: The highlight query to use for syntax highlighting this language. """ if name not in self._languages: - raise LanguageDoesNotExist( - f"{name!r} is not a registered language.\n" - f"To register a language, call `TextArea.register_language`." - ) - self._languages[name].highlight_query = highlight_query + self._languages[name] = TextAreaLanguage(name, None, highlight_query) + else: + self._languages[name].highlight_query = highlight_query + + # If this is the currently loaded language, reload the document because + # it could be a different highlight query for the same language. if name == self.language: self._set_document(self.text, name) @@ -897,39 +901,57 @@ TextArea { Args: text: The text of the document. - language: The name of the language to use. This must either be a - built-in supported language, or a language previously registered - via the `register_language` method. + language: The name of the language to use. This must correspond to a tree-sitter + language available in the current environment (e.g. use `python` for `tree-sitter-python`). + If None, the document will be treated as plain text. """ self._highlight_query = None if TREE_SITTER and language: - # Attempt to get the override language. - text_area_language = self._languages.get(language, None) - document_language: "str | Language" - if text_area_language: - document_language = text_area_language.language - highlight_query = text_area_language.highlight_query + if language in self._languages: + # User-registered languages take priority. + highlight_query = self._languages[language].highlight_query + document_language = self._languages[language].language + if document_language is None: + document_language = get_language(language) else: - document_language = language + # No user-registered language, so attempt to use a built-in language. highlight_query = self._get_builtin_highlight_query(language) - document: DocumentBase - try: - document = SyntaxAwareDocument(text, document_language) - except SyntaxAwareDocumentError: - document = Document(text) - log.warning( - f"Parser not found for language {document_language!r}. Parsing disabled." + document_language = get_language(language) + + # No built-in language, and no user-registered language: use plain text and warn. + if document_language is None: + raise LanguageDoesNotExist( + f"tree-sitter is available, but no built-in or user-registered language called {language!r}.\n" + f"Ensure the language is installed (e.g. `pip install tree-sitter-ruby`)\n" + f"Falling back to plain text." ) else: - self._highlight_query = document.prepare_query(highlight_query) + document: DocumentBase + try: + document = SyntaxAwareDocument(text, document_language) + except SyntaxAwareDocumentError: + document = Document(text) + log.warning( + f"Parser not found for language {document_language!r}. Parsing disabled." + ) + else: + self._highlight_query = document.prepare_query(highlight_query) elif language and not TREE_SITTER: + # User has supplied a language i.e. `TextArea(language="python")`, but they + # don't have tree-sitter available in the environment. We fallback to plain text. log.warning( "tree-sitter not available in this environment. Parsing disabled.\n" "You may need to install the `syntax` extras alongside textual.\n" - "Try `pip install 'textual[syntax]'` or '`poetry add textual[syntax]'." + "Try `pip install 'textual[syntax]'` or '`poetry add textual[syntax]' to get started quickly.\n\n" + "Alternatively, install tree-sitter manually (`pip install tree-sitter`) and then\n" + "install the required language (e.g. `pip install tree-sitter-ruby`), then register it.\n" + "and it's highlight query using TextArea.register_language().\n\n" + "Falling back to plain text for now." ) document = Document(text) else: + # tree-sitter is available, but the user has supplied None or "" for the language. + # Use a regular plain-text document. document = Document(text) self.document = document diff --git a/contrib/python/textual/textual/widgets/_toggle_button.py b/contrib/python/textual/textual/widgets/_toggle_button.py index 0b35ca2a8ea..d36b386ccda 100644 --- a/contrib/python/textual/textual/widgets/_toggle_button.py +++ b/contrib/python/textual/textual/widgets/_toggle_button.py @@ -8,15 +8,14 @@ from __future__ import annotations from typing import TYPE_CHECKING, ClassVar from rich.console import RenderableType -from rich.style import Style -from rich.text import Text, TextType -from textual.app import RenderResult from textual.binding import Binding, BindingType +from textual.content import Content, ContentText from textual.events import Click from textual.geometry import Size from textual.message import Message from textual.reactive import reactive +from textual.style import Style from textual.widgets._static import Static if TYPE_CHECKING: @@ -59,6 +58,8 @@ class ToggleButton(Static, can_focus=True): border: tall $border-blurred; padding: 0 1; background: $surface; + text-wrap: nowrap; + text-overflow: ellipsis; & > .toggle--button { color: $panel-darken-2; @@ -101,7 +102,7 @@ class ToggleButton(Static, can_focus=True): def __init__( self, - label: TextType = "", + label: ContentText = "", value: bool = False, button_first: bool = True, *, @@ -132,76 +133,72 @@ class ToggleButton(Static, can_focus=True): if tooltip is not None: self.tooltip = tooltip - def _make_label(self, label: TextType) -> Text: - """Make a `Text` label from a `TextType` value. + def _make_label(self, label: ContentText) -> Content: + """Make label content. Args: label: The source value for the label. Returns: - A `Text` rendering of the label for use in the button. + A `Content` rendering of the label for use in the button. """ - label = Text.from_markup(label) if isinstance(label, str) else label - try: - # Only use the first line if it's a multi-line label. - label = label.split()[0] - except IndexError: - pass - + label = Content.from_text(label).first_line return label @property - def label(self) -> Text: + def label(self) -> Content: """The label associated with the button.""" return self._label @label.setter - def label(self, label: TextType) -> None: + def label(self, label: ContentText) -> None: self._label = self._make_label(label) self.refresh(layout=True) @property - def _button(self) -> Text: + def _button(self) -> Content: """The button, reflecting the current value.""" # Grab the button style. - button_style = self.get_component_rich_style("toggle--button") + button_style = self.get_visual_style("toggle--button") # Building the style for the side characters. Note that this is # sensitive to the type of character used, so pay attention to # BUTTON_LEFT and BUTTON_RIGHT. - side_style = Style.from_color( - button_style.bgcolor, self.background_colors[1].rich_color + side_style = Style( + foreground=button_style.background, + background=self.background_colors[1], ) - return Text.assemble( + return Content.assemble( (self.BUTTON_LEFT, side_style), (self.BUTTON_INNER, button_style), (self.BUTTON_RIGHT, side_style), ) - def render(self) -> RenderResult: + def render(self) -> Content: """Render the content of the widget. Returns: The content to render for the widget. """ button = self._button - label = self._label.copy() - label.stylize_before(self.get_component_rich_style("toggle--label")) + label_style = self.get_visual_style("toggle--label") + label = self._label.stylize_before(label_style) spacer = " " if label else "" - return Text.assemble( - *( - (button, spacer, label) - if self._button_first - else (label, spacer, button) - ), - no_wrap=True, - overflow="ellipsis", - ) + + if self._button_first: + content = Content.assemble(button, spacer, label) + else: + content = Content.assemble(label, spacer, button) + return content def get_content_width(self, container: Size, viewport: Size) -> int: - return self._button.cell_len + (1 if self._label else 0) + self._label.cell_len + return ( + self._button.get_optimal_width(self.styles, 0) + + (1 if self._label else 0) + + self._label.get_optimal_width(self.styles, 0) + ) def get_content_height(self, container: Size, viewport: Size, width: int) -> int: return 1 diff --git a/contrib/python/textual/textual/widgets/_tree.py b/contrib/python/textual/textual/widgets/_tree.py index 1614fc8a308..8f9119a5560 100644 --- a/contrib/python/textual/textual/widgets/_tree.py +++ b/contrib/python/textual/textual/widgets/_tree.py @@ -553,7 +553,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True): | Key(s) | Description | | :- | :- | | enter | Select the current item. | - | space | Toggle the expand/collapsed space of the current item. | + | space | Toggle the expand/collapsed state of the current item. | | up | Move the cursor up. | | down | Move the cursor down. | """ diff --git a/contrib/python/textual/textual/widgets/text_area.py b/contrib/python/textual/textual/widgets/text_area.py index 1d4621a78b8..a879e2594c6 100644 --- a/contrib/python/textual/textual/widgets/text_area.py +++ b/contrib/python/textual/textual/widgets/text_area.py @@ -1,5 +1,4 @@ from textual._text_area_theme import TextAreaTheme -from textual._tree_sitter import BUILTIN_LANGUAGES from textual.document._document import ( Document, DocumentBase, @@ -19,6 +18,7 @@ from textual.widgets._text_area import ( LanguageDoesNotExist, StartColumn, ThemeDoesNotExist, + BUILTIN_LANGUAGES, ) __all__ = [ diff --git a/contrib/python/textual/ya.make b/contrib/python/textual/ya.make index d5b22285e75..b8f5dd8bb08 100644 --- a/contrib/python/textual/ya.make +++ b/contrib/python/textual/ya.make @@ -2,7 +2,7 @@ PY3_LIBRARY() -VERSION(2.1.2) +VERSION(3.0.1) LICENSE(MIT) |
