summaryrefslogtreecommitdiffstats
path: root/contrib/python
diff options
context:
space:
mode:
authorrobot-piglet <[email protected]>2026-05-12 21:20:36 +0300
committerrobot-piglet <[email protected]>2026-05-12 21:50:01 +0300
commit0a39e4306959ffbe146072ec95f6c3bd23b3ec81 (patch)
treeda79285e7d71f84af9f6c748d55da1be8119ca6c /contrib/python
parentf60daa06f7e5e86306edf466d944534ab66c3bf1 (diff)
Intermediate changes
commit_hash:6f99e4234152d8dc77848a96be0b67a933e60c0d
Diffstat (limited to 'contrib/python')
-rw-r--r--contrib/python/textual/.dist-info/METADATA2
-rw-r--r--contrib/python/textual/textual/_compositor.py3
-rw-r--r--contrib/python/textual/textual/_tree_sitter.py67
-rw-r--r--contrib/python/textual/textual/app.py20
-rw-r--r--contrib/python/textual/textual/content.py174
-rw-r--r--contrib/python/textual/textual/css/_style_properties.py2
-rw-r--r--contrib/python/textual/textual/css/_styles_builder.py1
-rw-r--r--contrib/python/textual/textual/css/styles.py7
-rw-r--r--contrib/python/textual/textual/document/_syntax_aware_document.py62
-rw-r--r--contrib/python/textual/textual/dom.py46
-rw-r--r--contrib/python/textual/textual/drivers/_writer_thread.py2
-rw-r--r--contrib/python/textual/textual/drivers/linux_driver.py2
-rw-r--r--contrib/python/textual/textual/drivers/linux_inline_driver.py2
-rw-r--r--contrib/python/textual/textual/drivers/web_driver.py4
-rw-r--r--contrib/python/textual/textual/drivers/win32.py2
-rw-r--r--contrib/python/textual/textual/markup.py12
-rw-r--r--contrib/python/textual/textual/pilot.py2
-rw-r--r--contrib/python/textual/textual/screen.py2
-rw-r--r--contrib/python/textual/textual/scroll_view.py7
-rw-r--r--contrib/python/textual/textual/scrollbar.py3
-rw-r--r--contrib/python/textual/textual/style.py67
-rw-r--r--contrib/python/textual/textual/visual.py17
-rw-r--r--contrib/python/textual/textual/widget.py35
-rw-r--r--contrib/python/textual/textual/widgets/_button.py35
-rw-r--r--contrib/python/textual/textual/widgets/_label.py7
-rw-r--r--contrib/python/textual/textual/widgets/_log.py9
-rw-r--r--contrib/python/textual/textual/widgets/_select.py3
-rw-r--r--contrib/python/textual/textual/widgets/_selection_list.py33
-rw-r--r--contrib/python/textual/textual/widgets/_static.py14
-rw-r--r--contrib/python/textual/textual/widgets/_tabbed_content.py5
-rw-r--r--contrib/python/textual/textual/widgets/_tabs.py29
-rw-r--r--contrib/python/textual/textual/widgets/_text_area.py138
-rw-r--r--contrib/python/textual/textual/widgets/_toggle_button.py65
-rw-r--r--contrib/python/textual/textual/widgets/_tree.py2
-rw-r--r--contrib/python/textual/textual/widgets/text_area.py2
-rw-r--r--contrib/python/textual/ya.make2
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)