summaryrefslogtreecommitdiffstats
path: root/contrib/python
diff options
context:
space:
mode:
authorrobot-piglet <[email protected]>2026-05-14 15:31:53 +0300
committerrobot-piglet <[email protected]>2026-05-14 16:52:49 +0300
commitfcb28e782913fc14dcca6710596132b5927f5725 (patch)
tree1704aff543747884ac8c0d6d769f3a03cd375c82 /contrib/python
parentddc6055c2a69720add2f4f588920e6dba00c778c (diff)
Intermediate changes
commit_hash:14d8d6fa889ca5cee28e5d6f7f5b864fb442ed35
Diffstat (limited to 'contrib/python')
-rw-r--r--contrib/python/textual/.dist-info/METADATA2
-rw-r--r--contrib/python/textual/textual/__init__.py2
-rw-r--r--contrib/python/textual/textual/_compositor.py14
-rw-r--r--contrib/python/textual/textual/_xterm_parser.py4
-rw-r--r--contrib/python/textual/textual/app.py2
-rw-r--r--contrib/python/textual/textual/css/parse.py24
-rw-r--r--contrib/python/textual/textual/css/query.py4
-rw-r--r--contrib/python/textual/textual/dom.py41
-rw-r--r--contrib/python/textual/textual/filter.py4
-rw-r--r--contrib/python/textual/textual/getters.py12
-rw-r--r--contrib/python/textual/textual/screen.py15
-rw-r--r--contrib/python/textual/textual/scrollbar.py4
-rw-r--r--contrib/python/textual/textual/walk.py30
-rw-r--r--contrib/python/textual/textual/widget.py130
-rw-r--r--contrib/python/textual/textual/widgets/_footer.py4
-rw-r--r--contrib/python/textual/textual/widgets/_markdown.py232
-rw-r--r--contrib/python/textual/textual/widgets/_masked_input.py5
-rw-r--r--contrib/python/textual/textual/widgets/_rich_log.py1
-rw-r--r--contrib/python/textual/ya.make2
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)