diff options
| author | robot-piglet <[email protected]> | 2026-05-14 15:31:53 +0300 |
|---|---|---|
| committer | robot-piglet <[email protected]> | 2026-05-14 16:52:49 +0300 |
| commit | fcb28e782913fc14dcca6710596132b5927f5725 (patch) | |
| tree | 1704aff543747884ac8c0d6d769f3a03cd375c82 /contrib/python | |
| parent | ddc6055c2a69720add2f4f588920e6dba00c778c (diff) | |
Intermediate changes
commit_hash:14d8d6fa889ca5cee28e5d6f7f5b864fb442ed35
Diffstat (limited to 'contrib/python')
19 files changed, 335 insertions, 197 deletions
diff --git a/contrib/python/textual/.dist-info/METADATA b/contrib/python/textual/.dist-info/METADATA index cdad74316a5..f7abccbcf85 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: 3.7.1 +Version: 4.0.0 Summary: Modern Text User Interface framework Home-page: https://github.com/Textualize/textual License: MIT diff --git a/contrib/python/textual/textual/__init__.py b/contrib/python/textual/textual/__init__.py index f0b587248b4..65f917cff10 100644 --- a/contrib/python/textual/textual/__init__.py +++ b/contrib/python/textual/textual/__init__.py @@ -90,7 +90,7 @@ class Logger: ) output = f"{output} {key_values}" if output else key_values - with open(constants.LOG_FILE, "a") as log_file: + with open(constants.LOG_FILE, "a", encoding="utf-8") as log_file: print(output, file=log_file) app = self.app diff --git a/contrib/python/textual/textual/_compositor.py b/contrib/python/textual/textual/_compositor.py index 245abf6ccbc..6941bf96c0f 100644 --- a/contrib/python/textual/textual/_compositor.py +++ b/contrib/python/textual/textual/_compositor.py @@ -36,12 +36,12 @@ from textual._loop import loop_last from textual.geometry import NULL_SPACING, Offset, Region, Size, Spacing from textual.map_geometry import MapGeometry from textual.strip import Strip, StripRenderable +from textual.widget import Widget if TYPE_CHECKING: from typing_extensions import TypeAlias from textual.screen import Screen - from textual.widget import Widget class ReflowResult(NamedTuple): @@ -605,6 +605,18 @@ class Compositor: # Get the region that will be updated sub_clip = clip.intersection(child_region) + if widget._anchored and not widget._anchor_released: + scroll_y = widget.scroll_y + new_scroll_y = ( + arrange_result.spatial_map.total_region.bottom + - ( + widget.container_size.height + - widget.scrollbar_size_horizontal + ) + ) + widget.set_reactive(Widget.scroll_y, new_scroll_y) + widget.watch_scroll_y(scroll_y, new_scroll_y) + if visible_only: placements = arrange_result.get_visible_placements( sub_clip - child_region.offset + widget.scroll_offset diff --git a/contrib/python/textual/textual/_xterm_parser.py b/contrib/python/textual/textual/_xterm_parser.py index 431a4866e7d..fc9f4baa612 100644 --- a/contrib/python/textual/textual/_xterm_parser.py +++ b/contrib/python/textual/textual/_xterm_parser.py @@ -18,7 +18,7 @@ from textual.message import Message # to be unsuccessful? _MAX_SEQUENCE_SEARCH_THRESHOLD = 32 -_re_mouse_event = re.compile("^" + re.escape("\x1b[") + r"(<?[-?\d;]+[mM]|M...)\Z") +_re_mouse_event = re.compile("^" + re.escape("\x1b[") + r"(<?[-\d;]+[mM]|M...)\Z") _re_terminal_mode_response = re.compile( "^" + re.escape("\x1b[") + r"\?(?P<mode_id>\d+);(?P<setting_parameter>\d)\$y" ) @@ -50,7 +50,7 @@ IS_ITERM = ( class XTermParser(Parser[Message]): - _re_sgr_mouse = re.compile(r"\x1b\[<(-?\d+);(-?\d+);(-?\d+)([Mm])") + _re_sgr_mouse = re.compile(r"\x1b\[<(\d+);(-?\d+);(-?\d+)([Mm])") def __init__(self, debug: bool = False) -> None: self.last_x = 0.0 diff --git a/contrib/python/textual/textual/app.py b/contrib/python/textual/textual/app.py index 01996615ff6..310893a9a6d 100644 --- a/contrib/python/textual/textual/app.py +++ b/contrib/python/textual/textual/app.py @@ -1237,7 +1237,7 @@ class App(Generic[ReturnType], DOMNode): yield SystemCommand( "Save screenshot", "Save an SVG 'screenshot' of the current screen", - self.deliver_screenshot, + lambda: self.set_timer(0.1, self.deliver_screenshot), ) def get_default_screen(self) -> Screen: diff --git a/contrib/python/textual/textual/css/parse.py b/contrib/python/textual/textual/css/parse.py index 61b759253b4..e030cc4acae 100644 --- a/contrib/python/textual/textual/css/parse.py +++ b/contrib/python/textual/textual/css/parse.py @@ -1,6 +1,7 @@ from __future__ import annotations import dataclasses +import re from functools import lru_cache from typing import Iterable, Iterator, NoReturn @@ -16,7 +17,13 @@ from textual.css.model import ( SelectorType, ) from textual.css.styles import Styles -from textual.css.tokenize import Token, tokenize, tokenize_declarations, tokenize_values +from textual.css.tokenize import ( + IDENTIFIER, + Token, + tokenize, + tokenize_declarations, + tokenize_values, +) from textual.css.tokenizer import ReferencedBy, UnexpectedEnd from textual.css.types import CSSLocation, Specificity3 from textual.suggestions import get_suggestion @@ -33,6 +40,21 @@ SELECTOR_MAP: dict[str, tuple[SelectorType, Specificity3]] = { "nested": (SelectorType.NESTED, (0, 0, 0)), } +RE_ID_SELECTOR = re.compile("#" + IDENTIFIER) + + +@lru_cache(maxsize=128) +def is_id_selector(selector: str) -> bool: + """Is the selector a single ID selector, i.e. "#foo"? + + Args: + selector: A CSS selector. + + Returns: + `True` if the selector is a simple ID selector, otherwise `False`. + """ + return RE_ID_SELECTOR.fullmatch(selector) is not None + def _add_specificity( specificity1: Specificity3, specificity2: Specificity3 diff --git a/contrib/python/textual/textual/css/query.py b/contrib/python/textual/textual/css/query.py index 8250afb2d3d..660190cbf5f 100644 --- a/contrib/python/textual/textual/css/query.py +++ b/contrib/python/textual/textual/css/query.py @@ -242,7 +242,7 @@ class DOMQuery(Generic[QueryType]): if expect_type is not None: if not isinstance(first, expect_type): raise WrongType( - f"Query value is wrong type; expected {expect_type}, got {type(first)}" + f"Query value is the wrong type; expected type {expect_type.__name__!r}, found {first}" ) return first else: @@ -324,7 +324,7 @@ class DOMQuery(Generic[QueryType]): last = self.nodes[-1] if expect_type is not None and not isinstance(last, expect_type): raise WrongType( - f"Query value is wrong type; expected {expect_type}, got {type(last)}" + f"Query value is the wrong type; expected type {expect_type.__name__!r}, found {last}" ) return last diff --git a/contrib/python/textual/textual/dom.py b/contrib/python/textual/textual/dom.py index c91f6cfc0b3..f7f854efde8 100644 --- a/contrib/python/textual/textual/dom.py +++ b/contrib/python/textual/textual/dom.py @@ -40,8 +40,8 @@ from textual.css._error_tools import friendly_list from textual.css.constants import VALID_DISPLAY, VALID_VISIBILITY from textual.css.errors import DeclarationError, StyleValueError from textual.css.match import match -from textual.css.parse import parse_declarations, parse_selectors -from textual.css.query import InvalidQueryFormat, NoMatches, TooManyMatches +from textual.css.parse import is_id_selector, parse_declarations, parse_selectors +from textual.css.query import InvalidQueryFormat, NoMatches, TooManyMatches, WrongType from textual.css.styles import RenderStyles, Styles from textual.css.tokenize import IDENTIFIER from textual.css.tokenizer import TokenError @@ -49,7 +49,7 @@ from textual.message_pump import MessagePump from textual.reactive import Reactive, ReactiveError, _Mutated, _watch from textual.style import Style as VisualStyle from textual.timer import Timer -from textual.walk import walk_breadth_first, walk_depth_first +from textual.walk import walk_breadth_first, walk_breadth_search_id, walk_depth_first from textual.worker_manager import WorkerManager if TYPE_CHECKING: @@ -65,9 +65,6 @@ if TYPE_CHECKING: from textual.widget import Widget from textual.worker import Worker, WorkType, ResultType - # Unused & ignored imports are needed for the docs to link to these objects: - from textual.css.query import WrongType # type: ignore # noqa: F401 - from typing_extensions import Literal _re_identifier = re.compile(IDENTIFIER) @@ -1469,6 +1466,24 @@ class DOMNode(MessagePump): else: query_selector = selector.__name__ + if is_id_selector(query_selector): + 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 + if ( + node := walk_breadth_search_id( + base_node, query_selector[1:], with_root=False + ) + ) is not None: + if expect_type is not None and not isinstance(node, expect_type): + raise WrongType( + f"Node matching {query_selector!r} is the wrong type; expected type {expect_type.__name__!r}, found {node}" + ) + base_node._query_one_cache[cache_key] = node + return node + raise NoMatches(f"No nodes match {query_selector!r} on {base_node!r}") + try: selector_set = parse_selectors(query_selector) except TokenError: @@ -1488,12 +1503,14 @@ class DOMNode(MessagePump): if not match(selector_set, node): continue if expect_type is not None and not isinstance(node, expect_type): - continue + raise WrongType( + f"Node matching {query_selector!r} is the wrong type; expected type {expect_type.__name__!r}, found {node}" + ) if cache_key is not None: base_node._query_one_cache[cache_key] = node return node - raise NoMatches(f"No nodes match {selector!r} on {self!r}") + raise NoMatches(f"No nodes match {query_selector!r} on {base_node!r}") if TYPE_CHECKING: @@ -1561,11 +1578,11 @@ class DOMNode(MessagePump): if not match(selector_set, node): continue if expect_type is not None and not isinstance(node, expect_type): - continue + raise WrongType( + f"Node matching {query_selector!r} is the wrong type; expected type {expect_type.__name__!r}, found {node}" + ) for later_node in iter_children: if match(selector_set, later_node): - if expect_type is not None and not isinstance(node, expect_type): - continue raise TooManyMatches( "Call to query_one resulted in more than one matched node" ) @@ -1573,7 +1590,7 @@ class DOMNode(MessagePump): base_node._query_one_cache[cache_key] = node return node - raise NoMatches(f"No nodes match {selector!r} on {self!r}") + raise NoMatches(f"No nodes match {query_selector!r} on {base_node!r}") if TYPE_CHECKING: diff --git a/contrib/python/textual/textual/filter.py b/contrib/python/textual/textual/filter.py index 5a2984f381b..9b37fb7e804 100644 --- a/contrib/python/textual/textual/filter.py +++ b/contrib/python/textual/textual/filter.py @@ -241,12 +241,12 @@ class ANSIToTruecolor(LineFilter): """ terminal_theme = self._terminal_theme color = style.color - if color is not None and color.is_system_defined: + if color is not None and color.triplet is None: color = RichColor.from_rgb( *color.get_truecolor(terminal_theme, foreground=True) ) bgcolor = style.bgcolor - if bgcolor is not None and bgcolor.is_system_defined: + if bgcolor is not None and bgcolor.triplet is None: bgcolor = RichColor.from_rgb( *bgcolor.get_truecolor(terminal_theme, foreground=False) ) diff --git a/contrib/python/textual/textual/getters.py b/contrib/python/textual/textual/getters.py index f5b23590b0c..02d3dce9070 100644 --- a/contrib/python/textual/textual/getters.py +++ b/contrib/python/textual/textual/getters.py @@ -176,13 +176,11 @@ class child_by_id(Generic[QueryType]): """Get the widget matching the selector and/or type.""" if obj is None: return self - child = obj._nodes._get_by_id(self.child_id) + child = obj._get_dom_base()._nodes._get_by_id(self.child_id) if child is None: - raise NoMatches(f"No child found with id={id!r}") + raise NoMatches(f"No child found with id={self.child_id!r}") if not isinstance(child, self.expect_type): - if not isinstance(child, self.expect_type): - raise WrongType( - f"Child with id={id!r} is wrong type; expected {self.expect_type}, got" - f" {type(child)}" - ) + raise WrongType( + f"Child with id={self.child_id!r} is the wrong type; expected type {self.expect_type.__name__!r}, found {child}" + ) return child diff --git a/contrib/python/textual/textual/screen.py b/contrib/python/textual/textual/screen.py index 73369c6a466..51eb4e838de 100644 --- a/contrib/python/textual/textual/screen.py +++ b/contrib/python/textual/textual/screen.py @@ -1663,12 +1663,15 @@ class Screen(Generic[ScreenResultType], Widget): if end_region.y <= start_region.bottom or self._box_select: select_regions.append(Region.union(start_region, end_region)) else: - container_region = Region.from_union( - [ - start_widget.select_container.content_region, - end_widget.select_container.content_region, - ] - ) + try: + container_region = Region.from_union( + [ + start_widget.select_container.content_region, + end_widget.select_container.content_region, + ] + ) + except NoMatches: + return start_region = Region.from_corners( start_region.x, diff --git a/contrib/python/textual/textual/scrollbar.py b/contrib/python/textual/textual/scrollbar.py index e28d9db9419..b1cce697b68 100644 --- a/contrib/python/textual/textual/scrollbar.py +++ b/contrib/python/textual/textual/scrollbar.py @@ -357,12 +357,14 @@ class ScrollBar(Widget): def _on_mouse_capture(self, event: events.MouseCapture) -> None: if isinstance(self._parent, Widget): - self._parent._user_scroll_interrupt = True + self._parent.release_anchor() self.grabbed = event.mouse_position self.grabbed_position = self.position def _on_mouse_release(self, event: events.MouseRelease) -> None: self.grabbed = None + if self.vertical and isinstance(self.parent, Widget): + self.parent._check_anchor() event.stop() async def _on_mouse_move(self, event: events.MouseMove) -> None: diff --git a/contrib/python/textual/textual/walk.py b/contrib/python/textual/textual/walk.py index dcda856e49d..43b8d4b6e41 100644 --- a/contrib/python/textual/textual/walk.py +++ b/contrib/python/textual/textual/walk.py @@ -137,3 +137,33 @@ def walk_breadth_first( if isinstance(node, check_type): yield node extend(node._nodes) + + +def walk_breadth_search_id( + root: DOMNode, node_id: str, *, with_root: bool = True +) -> DOMNode | None: + """Special case to walk breadth first searching for a node with a given id. + + This is more efficient than [walk_breadth_first][textual.walk.walk_breadth_first] for this special case, as it can use an index. + + Args: + root: The root node (starting point). + node_id: Node id to search for. + with_root: Consider the root node? If the root has the node id, then return it. + + Returns: + A DOMNode if a node was found, otherwise `None`. + """ + + if with_root and root.id == node_id: + return root + + queue: deque[DOMNode] = deque() + queue.append(root) + + while queue: + node = queue.popleft() + if (found_node := node._nodes._get_by_id(node_id)) is not None: + return found_node + queue.extend(node._nodes) + return None diff --git a/contrib/python/textual/textual/widget.py b/contrib/python/textual/textual/widget.py index cf0ec8a964f..4c92931e561 100644 --- a/contrib/python/textual/textual/widget.py +++ b/contrib/python/textual/textual/widget.py @@ -491,9 +491,11 @@ class Widget(DOMNode): might result in a race condition. This can be fixed by adding `async with widget.lock:` around the method calls. """ - self._anchored: Widget | None = None - """An anchored child widget, or `None` if no child is anchored.""" - self._anchor_animate: bool = False + self._anchored: bool = False + """Has this widget been anchored?""" + self._anchor_released: bool = False + """Has the anchor been released?""" + """Flag to enable animation when scrolling anchored widgets.""" self._cover_widget: Widget | None = None """Widget to render over this widget (used by loading indicator).""" @@ -510,8 +512,6 @@ class Widget(DOMNode): """Used to cache :odd pseudoclass state.""" self._last_scroll_time = monotonic() """Time of last scroll.""" - self._user_scroll_interrupt: bool = False - """Has the user interrupted a scroll to end?""" self._extrema = Extrema() """Optional minimum and maximum values for width and height.""" @@ -612,8 +612,12 @@ class Widget(DOMNode): @property def is_anchored(self) -> bool: - """Is this widget anchored?""" - return isinstance(self._parent, Widget) and self._parent._anchored is self + """Is this widget anchored? + + See [anchor()][textual.widget.Widget.anchor] for an explanation of anchoring. + + """ + return self._anchored @property def is_mouse_over(self) -> bool: @@ -698,34 +702,37 @@ class Widget(DOMNode): self._cover_widget = None self.refresh(layout=True) - def anchor(self, *, animate: bool = False) -> None: - """Anchor the widget, which scrolls it into view (like [scroll_visible][textual.widget.Widget.scroll_visible]), - but also keeps it in view if the widget's size changes, or the size of its container changes. + def anchor(self, anchor: bool = True) -> None: + """Anchor a scrollable widget. - !!! note - - Anchored widgets will be un-anchored if the users scrolls the container. + An anchored widget will stay scrolled the bottom when new content is added, until + the user moves the scroll position. Args: - animate: `True` if the scroll should animate, or `False` if it shouldn't. + anchor: Anchor the widget if `True`, clear the anchor if `False`. + """ - if self._parent is not None and isinstance(self._parent, Widget): - self._parent._anchored = self - self._parent._anchor_animate = animate - self.check_idle() + self._anchored = anchor + if anchor: + self.scroll_end() + + def release_anchor(self) -> None: + """Release the [anchor][textual.widget.Widget]. - def clear_anchor(self) -> None: - """Stop anchoring this widget (a no-op if this widget is not anchored).""" + If a widget is anchored, releasing the anchor will allow the user to scroll as normal. + + """ + self.scroll_target_y = self.scroll_y + self._anchor_released = True + + def _check_anchor(self) -> None: + """Check if the scroll position is near enough to the bottom to restore anchor.""" if ( - self._parent is not None - and isinstance(self._parent, Widget) - and self._parent._anchored is self + self._anchored + and self._anchor_released + and self.scroll_y >= self.max_scroll_y ): - self._parent._anchored = None - - def _clear_anchor(self) -> None: - """Clear an anchored child.""" - self._anchored = None + self._anchor_released = False def _check_disabled(self) -> bool: """Check if the widget is disabled either explicitly by setting `disabled`, @@ -998,15 +1005,14 @@ class Widget(DOMNode): NoMatches: if no children could be found for this ID WrongType: if the wrong type was found. """ - child = self._nodes._get_by_id(id) + child = self._get_dom_base()._nodes._get_by_id(id) if child is None: raise NoMatches(f"No child found with id={id!r}") if expect_type is None: return child if not isinstance(child, expect_type): raise WrongType( - f"Child with id={id!r} is wrong type; expected {expect_type}, got" - f" {type(child)}" + f"Child with id={id!r} is the wrong type; expected type {expect_type.__name__!r}, found {child}" ) return child @@ -1042,8 +1048,7 @@ class Widget(DOMNode): widget = self.query_one(f"#{id}") if expect_type is not None and not isinstance(widget, expect_type): raise WrongType( - f"Descendant with id={id!r} is wrong type; expected {expect_type}," - f" got {type(widget)}" + f"Descendant with id={id!r} is the wrong type; expected type {expect_type.__name__!r}, found {widget}" ) return widget @@ -1739,6 +1744,8 @@ class Widget(DOMNode): def watch_scroll_y(self, old_value: float, new_value: float) -> None: self.vertical_scrollbar.position = new_value + if self._anchored and self._anchor_released: + self._check_anchor() if round(old_value) != round(new_value): self._refresh_scroll() @@ -2521,9 +2528,6 @@ class Widget(DOMNode): if on_complete is not None: self.call_next(on_complete) - if y is not None and maybe_scroll_y and y >= self.max_scroll_y: - self._user_scroll_interrupt = False - if animate: # TODO: configure animation speed if duration is None and speed is None: @@ -2794,16 +2798,11 @@ class Widget(DOMNode): """ - if self._user_scroll_interrupt and not force: - # Do not scroll to end if a user action has interrupted scrolling - return - if speed is None and duration is None: duration = 1.0 async def scroll_end_on_complete() -> None: """It's possible new content was added before we reached the end.""" - self.scroll_y = self.max_scroll_y if on_complete is not None: self.call_next(on_complete) @@ -2827,6 +2826,9 @@ class Widget(DOMNode): level=level, ) + if self._anchored and self._anchor_released: + self._anchor_released = False + if immediate: _lazily_scroll_end() else: @@ -4271,9 +4273,6 @@ class Widget(DOMNode): """ self._check_refresh() - if self.is_anchored: - self.scroll_visible(animate=self._anchor_animate, immediate=True) - def _check_refresh(self) -> None: """Check if a refresh was requested.""" if self._parent is not None and not self._closing: @@ -4495,54 +4494,54 @@ class Widget(DOMNode): def _on_mouse_scroll_down(self, event: events.MouseScrollDown) -> None: if event.ctrl or event.shift: if self.allow_horizontal_scroll: - self._clear_anchor() + self.release_anchor() if self._scroll_right_for_pointer(animate=False): event.stop() else: if self.allow_vertical_scroll: - self._clear_anchor() + self.release_anchor() if self._scroll_down_for_pointer(animate=False): event.stop() def _on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None: if event.ctrl or event.shift: if self.allow_horizontal_scroll: - self._clear_anchor() + self.release_anchor() if self._scroll_left_for_pointer(animate=False): event.stop() else: if self.allow_vertical_scroll: - self._clear_anchor() + self.release_anchor() if self._scroll_up_for_pointer(animate=False): event.stop() def _on_scroll_to(self, message: ScrollTo) -> None: if self._allow_scroll: - self._clear_anchor() + self.release_anchor() self.scroll_to(message.x, message.y, animate=message.animate, duration=0.1) message.stop() def _on_scroll_up(self, event: ScrollUp) -> None: if self.allow_vertical_scroll: - self._clear_anchor() + self.release_anchor() self.scroll_page_up() event.stop() def _on_scroll_down(self, event: ScrollDown) -> None: if self.allow_vertical_scroll: - self._clear_anchor() + self.release_anchor() self.scroll_page_down() event.stop() def _on_scroll_left(self, event: ScrollLeft) -> None: if self.allow_horizontal_scroll: - self._clear_anchor() + self.release_anchor() self.scroll_page_left() event.stop() def _on_scroll_right(self, event: ScrollRight) -> None: if self.allow_horizontal_scroll: - self._clear_anchor() + self.release_anchor() self.scroll_page_right() event.stop() @@ -4570,67 +4569,60 @@ class Widget(DOMNode): def action_scroll_home(self) -> None: if not self._allow_scroll: raise SkipAction() - self._user_scroll_interrupt = True - self._clear_anchor() + self.release_anchor() self.scroll_home(x_axis=self.scroll_y == 0) def action_scroll_end(self) -> None: if not self._allow_scroll: raise SkipAction() - self._clear_anchor() - self._user_scroll_interrupt = False self.scroll_end(x_axis=self.scroll_y == self.is_vertical_scroll_end) def action_scroll_left(self) -> None: if not self.allow_horizontal_scroll: raise SkipAction() - self._clear_anchor() + self.release_anchor() self.scroll_left() def action_scroll_right(self) -> None: if not self.allow_horizontal_scroll: raise SkipAction() - self._clear_anchor() + self.release_anchor() self.scroll_right() def action_scroll_up(self) -> None: if not self.allow_vertical_scroll: raise SkipAction() - self._user_scroll_interrupt = True - self._clear_anchor() + self.release_anchor() self.scroll_up() def action_scroll_down(self) -> None: if not self.allow_vertical_scroll: raise SkipAction() - self._user_scroll_interrupt = True - self._clear_anchor() + self.release_anchor() self.scroll_down() def action_page_down(self) -> None: if not self.allow_vertical_scroll: raise SkipAction() - self._user_scroll_interrupt = True - self._clear_anchor() + self.release_anchor() self.scroll_page_down() def action_page_up(self) -> None: if not self.allow_vertical_scroll: raise SkipAction() - self._user_scroll_interrupt = True - self._clear_anchor() + self.release_anchor() self.scroll_page_up() def action_page_left(self) -> None: if not self.allow_horizontal_scroll: raise SkipAction() - self._clear_anchor() + self.release_anchor() self.scroll_page_left() def action_page_right(self) -> None: if not self.allow_horizontal_scroll: raise SkipAction() - self._clear_anchor() + self.release_anchor() self.scroll_page_right() def notify( diff --git a/contrib/python/textual/textual/widgets/_footer.py b/contrib/python/textual/textual/widgets/_footer.py index ecc0bc24ac8..45b1c4ca154 100644 --- a/contrib/python/textual/textual/widgets/_footer.py +++ b/contrib/python/textual/textual/widgets/_footer.py @@ -254,14 +254,14 @@ class Footer(ScrollableContainer, can_focus=False, can_focus_children=False): def _on_mouse_scroll_down(self, event: events.MouseScrollDown) -> None: if self.allow_horizontal_scroll: - self._clear_anchor() + self.release_anchor() if self._scroll_right_for_pointer(animate=True): event.stop() event.prevent_default() def _on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None: if self.allow_horizontal_scroll: - self._clear_anchor() + self.release_anchor() if self._scroll_left_for_pointer(animate=True): event.stop() event.prevent_default() diff --git a/contrib/python/textual/textual/widgets/_markdown.py b/contrib/python/textual/textual/widgets/_markdown.py index fdc32c01442..a491e17458f 100644 --- a/contrib/python/textual/textual/widgets/_markdown.py +++ b/contrib/python/textual/textual/widgets/_markdown.py @@ -20,6 +20,7 @@ from textual._slug import TrackedSlugs from textual.app import ComposeResult from textual.await_complete import AwaitComplete from textual.containers import Horizontal, Vertical, VerticalScroll +from textual.css.query import NoMatches from textual.events import Mount from textual.message import Message from textual.reactive import reactive, var @@ -664,7 +665,10 @@ class MarkdownFence(MarkdownBlock): if self.app.current_theme.dark else self._markdown.code_light_theme ) - self.get_child_by_type(Static).update(self._block()) + try: + self.get_child_by_type(Static).update(self._block()) + except NoMatches: + pass def compose(self) -> ComposeResult: yield Static( @@ -823,6 +827,11 @@ class Markdown(Widget): """ return self.markdown + @property + def source(self) -> str: + """The markdown source.""" + return self._markdown or "" + async def _on_mount(self, _: Mount) -> None: if self._markdown is not None: await self.update(self._markdown) @@ -915,6 +924,91 @@ class Markdown(Widget): """ return None + def _parse_markdown( + self, tokens: Iterable[Token], table_of_contents: TableOfContentsType + ) -> Iterable[MarkdownBlock]: + """Create a stream of MarkdownBlock widgets from markdown. + + Args: + tokens: List of tokens. + table_of_contents: List to store table of contents. + + Yields: + Widgets for mounting. + """ + + stack: list[MarkdownBlock] = [] + stack_append = stack.append + block_id: int = 0 + + for token in tokens: + token_type = token.type + if token_type == "heading_open": + block_id += 1 + stack_append(HEADINGS[token.tag](self, id=f"block{block_id}")) + elif token_type == "hr": + yield MarkdownHorizontalRule(self) + elif token_type == "paragraph_open": + stack_append(MarkdownParagraph(self)) + elif token_type == "blockquote_open": + stack_append(MarkdownBlockQuote(self)) + elif token_type == "bullet_list_open": + stack_append(MarkdownBulletList(self)) + elif token_type == "ordered_list_open": + stack_append(MarkdownOrderedList(self)) + elif token_type == "list_item_open": + if token.info: + stack_append(MarkdownOrderedListItem(self, token.info)) + else: + item_count = sum( + 1 + for block in stack + if isinstance(block, MarkdownUnorderedListItem) + ) + stack_append( + MarkdownUnorderedListItem( + self, + self.BULLETS[item_count % len(self.BULLETS)], + ) + ) + elif token_type == "table_open": + stack_append(MarkdownTable(self)) + elif token_type == "tbody_open": + stack_append(MarkdownTBody(self)) + elif token_type == "thead_open": + stack_append(MarkdownTHead(self)) + elif token_type == "tr_open": + stack_append(MarkdownTR(self)) + elif token_type == "th_open": + stack_append(MarkdownTH(self)) + elif token_type == "td_open": + stack_append(MarkdownTD(self)) + elif token_type.endswith("_close"): + block = stack.pop() + if token.type == "heading_close": + heading = block._text.plain + level = int(token.tag[1:]) + table_of_contents.append((level, heading, block.id)) + if stack: + stack[-1]._blocks.append(block) + else: + yield block + elif token_type == "inline": + stack[-1].build_from_token(token) + elif token_type in ("fence", "code_block"): + fence = MarkdownFence(self, token.content.rstrip(), token.info) + if stack: + stack[-1]._blocks.append(fence) + else: + yield fence + else: + external = self.unhandled_token(token) + if external is not None: + if stack: + stack[-1]._blocks.append(external) + else: + yield external + def update(self, markdown: str) -> AwaitComplete: """Update the document with new Markdown. @@ -930,92 +1024,11 @@ class Markdown(Widget): else self._parser_factory() ) - table_of_contents = [] - - def parse_markdown(tokens) -> Iterable[MarkdownBlock]: - """Create a stream of MarkdownBlock widgets from markdown. - - Args: - tokens: List of tokens - - Yields: - Widgets for mounting. - """ - - stack: list[MarkdownBlock] = [] - stack_append = stack.append - block_id: int = 0 - - for token in tokens: - token_type = token.type - if token_type == "heading_open": - block_id += 1 - stack_append(HEADINGS[token.tag](self, id=f"block{block_id}")) - elif token_type == "hr": - yield MarkdownHorizontalRule(self) - elif token_type == "paragraph_open": - stack_append(MarkdownParagraph(self)) - elif token_type == "blockquote_open": - stack_append(MarkdownBlockQuote(self)) - elif token_type == "bullet_list_open": - stack_append(MarkdownBulletList(self)) - elif token_type == "ordered_list_open": - stack_append(MarkdownOrderedList(self)) - elif token_type == "list_item_open": - if token.info: - stack_append(MarkdownOrderedListItem(self, token.info)) - else: - item_count = sum( - 1 - for block in stack - if isinstance(block, MarkdownUnorderedListItem) - ) - stack_append( - MarkdownUnorderedListItem( - self, - self.BULLETS[item_count % len(self.BULLETS)], - ) - ) - elif token_type == "table_open": - stack_append(MarkdownTable(self)) - elif token_type == "tbody_open": - stack_append(MarkdownTBody(self)) - elif token_type == "thead_open": - stack_append(MarkdownTHead(self)) - elif token_type == "tr_open": - stack_append(MarkdownTR(self)) - elif token_type == "th_open": - stack_append(MarkdownTH(self)) - elif token_type == "td_open": - stack_append(MarkdownTD(self)) - elif token_type.endswith("_close"): - block = stack.pop() - if token.type == "heading_close": - heading = block._text.plain - level = int(token.tag[1:]) - table_of_contents.append((level, heading, block.id)) - if stack: - stack[-1]._blocks.append(block) - else: - yield block - elif token_type == "inline": - stack[-1].build_from_token(token) - elif token_type in ("fence", "code_block"): - fence = MarkdownFence(self, token.content.rstrip(), token.info) - if stack: - stack[-1]._blocks.append(fence) - else: - yield fence - else: - external = self.unhandled_token(token) - if external is not None: - if stack: - stack[-1]._blocks.append(external) - else: - yield external - + table_of_contents: TableOfContentsType = [] markdown_block = self.query("MarkdownBlock") + self._markdown = markdown + async def await_update() -> None: """Update in batches.""" BATCH_SIZE = 200 @@ -1044,7 +1057,7 @@ class Markdown(Widget): await self.mount_all(batch) removed = True - for block in parse_markdown(tokens): + for block in self._parse_markdown(tokens, table_of_contents): batch.append(block) if len(batch) == BATCH_SIZE: await mount_batch(batch) @@ -1064,6 +1077,53 @@ class Markdown(Widget): return AwaitComplete(await_update()) + def append(self, markdown: str) -> AwaitComplete: + """Append to markdown. + + Args: + markdown: A fragment of markdown to be appended. + + Returns: + An optionally awaitable object. Await this to ensure that the markdown has been append by the next line. + """ + parser = ( + MarkdownIt("gfm-like") + if self._parser_factory is None + else self._parser_factory() + ) + + table_of_contents: TableOfContentsType = [] + + self._markdown = updated_markdown = self.source + markdown + existing_blocks = list(self.children) + + async def await_append() -> None: + """Append new markdown widgets.""" + + tokens = await asyncio.get_running_loop().run_in_executor( + None, parser.parse, updated_markdown + ) + new_blocks = list(self._parse_markdown(tokens, table_of_contents)) + + last_index = len(existing_blocks) - 1 + + async with self.lock: + with self.app.batch_update(): + for block in existing_blocks[last_index:]: + await block.remove() + append_blocks = new_blocks[last_index:] + if append_blocks: + await self.mount_all(append_blocks) + + self._table_of_contents = table_of_contents + self.post_message( + Markdown.TableOfContentsUpdated( + self, self._table_of_contents + ).set_sender(self) + ) + + return AwaitComplete(await_append()) + class MarkdownTableOfContents(Widget, can_focus_children=True): """Displays a table of contents for a markdown document.""" diff --git a/contrib/python/textual/textual/widgets/_masked_input.py b/contrib/python/textual/textual/widgets/_masked_input.py index 47bf61e445c..8f4c4931ddb 100644 --- a/contrib/python/textual/textual/widgets/_masked_input.py +++ b/contrib/python/textual/textual/widgets/_masked_input.py @@ -461,8 +461,9 @@ class MaskedInput(Input, can_focus=True): classes: str | None = None, disabled: bool = False, tooltip: RenderableType | None = None, + compact: bool = False, ) -> None: - """Initialise the `Input` widget. + """Initialise the `MaskedInput` widget. Args: template: Template string. @@ -478,6 +479,7 @@ class MaskedInput(Input, can_focus=True): classes: Optional initial classes for the widget. disabled: Whether the input is disabled or not. tooltip: Optional tooltip. + compact: Enable compact style (without borders). """ self._template: _Template = None super().__init__( @@ -490,6 +492,7 @@ class MaskedInput(Input, can_focus=True): id=id, classes=classes, disabled=disabled, + compact=compact, ) self._template = _Template(self, template) diff --git a/contrib/python/textual/textual/widgets/_rich_log.py b/contrib/python/textual/textual/widgets/_rich_log.py index 47b04bd2115..c2d8c7facda 100644 --- a/contrib/python/textual/textual/widgets/_rich_log.py +++ b/contrib/python/textual/textual/widgets/_rich_log.py @@ -212,7 +212,6 @@ class RichLog(ScrollView, can_focus=True): ) return self - is_vertical_scroll_end = self.is_vertical_scroll_end renderable = self._make_renderable(content) auto_scroll = self.auto_scroll if scroll_end is None else scroll_end diff --git a/contrib/python/textual/ya.make b/contrib/python/textual/ya.make index 93542062304..156ff460aa7 100644 --- a/contrib/python/textual/ya.make +++ b/contrib/python/textual/ya.make @@ -2,7 +2,7 @@ PY3_LIBRARY() -VERSION(3.7.1) +VERSION(4.0.0) LICENSE(MIT) |
