summaryrefslogtreecommitdiffstats
path: root/contrib/python
diff options
context:
space:
mode:
authorrobot-piglet <[email protected]>2026-05-28 23:34:51 +0300
committerrobot-piglet <[email protected]>2026-05-29 11:50:30 +0300
commit339322825480794f27ea42cb153ece122ea2b225 (patch)
tree18b82528c26369faed2e356ac86a83a883cccd1d /contrib/python
parentd3c60c55dba8a3d57a5d9c4031db34b48aa343e3 (diff)
Intermediate changes
commit_hash:0bc4b818aff76116670772782f4aacc0e569d4be
Diffstat (limited to 'contrib/python')
-rw-r--r--contrib/python/mdit-py-plugins/.dist-info/METADATA2
-rw-r--r--contrib/python/mdit-py-plugins/mdit_py_plugins/__init__.py2
-rw-r--r--contrib/python/mdit-py-plugins/mdit_py_plugins/field_list/__init__.py5
-rw-r--r--contrib/python/mdit-py-plugins/ya.make2
-rw-r--r--contrib/python/textual/.dist-info/METADATA2
-rw-r--r--contrib/python/textual/textual/_compositor.py20
-rw-r--r--contrib/python/textual/textual/app.py34
-rw-r--r--contrib/python/textual/textual/geometry.py29
-rw-r--r--contrib/python/textual/textual/screen.py215
-rw-r--r--contrib/python/textual/textual/scrollbar.py2
-rw-r--r--contrib/python/textual/textual/selection.py348
-rw-r--r--contrib/python/textual/textual/visual.py7
-rw-r--r--contrib/python/textual/textual/walk.py15
-rw-r--r--contrib/python/textual/textual/widgets/_digits.py4
-rw-r--r--contrib/python/textual/textual/widgets/_markdown.py27
-rw-r--r--contrib/python/textual/ya.make2
16 files changed, 525 insertions, 191 deletions
diff --git a/contrib/python/mdit-py-plugins/.dist-info/METADATA b/contrib/python/mdit-py-plugins/.dist-info/METADATA
index 7b8d726a77c..b89c9b76bfb 100644
--- a/contrib/python/mdit-py-plugins/.dist-info/METADATA
+++ b/contrib/python/mdit-py-plugins/.dist-info/METADATA
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: mdit-py-plugins
-Version: 0.6.0
+Version: 0.6.1
Summary: Collection of plugins for markdown-it-py
Keywords: markdown,markdown-it,lexer,parser,development
Author-email: Chris Sewell <[email protected]>
diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/__init__.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/__init__.py
index 906d362f7de..43c4ab0058d 100644
--- a/contrib/python/mdit-py-plugins/mdit_py_plugins/__init__.py
+++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/__init__.py
@@ -1 +1 @@
-__version__ = "0.6.0"
+__version__ = "0.6.1"
diff --git a/contrib/python/mdit-py-plugins/mdit_py_plugins/field_list/__init__.py b/contrib/python/mdit-py-plugins/mdit_py_plugins/field_list/__init__.py
index 60745d74bba..c3ba4f9271c 100644
--- a/contrib/python/mdit-py-plugins/mdit_py_plugins/field_list/__init__.py
+++ b/contrib/python/mdit-py-plugins/mdit_py_plugins/field_list/__init__.py
@@ -164,8 +164,9 @@ def _fieldlist_rule(
while _line < endLine:
# if start_of_content < end_of_content, then non-empty line
if (state.bMarks[_line] + state.tShift[_line]) < state.eMarks[_line]:
- if state.tShift[_line] <= 0:
- # the line has no indent, so it's the end of the field
+ if state.tShift[_line] <= state.blkIndent:
+ # the line is not indented relative to the field marker,
+ # so it's the end of the field body
break
block_indent = (
state.tShift[_line]
diff --git a/contrib/python/mdit-py-plugins/ya.make b/contrib/python/mdit-py-plugins/ya.make
index aec3a075907..2c42a7f8da9 100644
--- a/contrib/python/mdit-py-plugins/ya.make
+++ b/contrib/python/mdit-py-plugins/ya.make
@@ -2,7 +2,7 @@
PY3_LIBRARY()
-VERSION(0.6.0)
+VERSION(0.6.1)
LICENSE(MIT)
diff --git a/contrib/python/textual/.dist-info/METADATA b/contrib/python/textual/.dist-info/METADATA
index 3adbb0452f9..381a4fceb5c 100644
--- a/contrib/python/textual/.dist-info/METADATA
+++ b/contrib/python/textual/.dist-info/METADATA
@@ -1,6 +1,6 @@
Metadata-Version: 2.4
Name: textual
-Version: 8.2.5
+Version: 8.2.6
Summary: Modern Text User Interface framework
License: MIT
License-File: LICENSE
diff --git a/contrib/python/textual/textual/_compositor.py b/contrib/python/textual/textual/_compositor.py
index 73fea51cc1b..ee4c231f240 100644
--- a/contrib/python/textual/textual/_compositor.py
+++ b/contrib/python/textual/textual/_compositor.py
@@ -926,6 +926,9 @@ class Compositor:
if y < 0:
return None, None
+ if x < 0:
+ return widget, Offset(0, y)
+
visible_screen_stack.set(widget.app._background_screens)
line = widget.render_line(y)
@@ -937,13 +940,16 @@ class Compositor:
from rich.cells import get_character_cell_size
+ offset: Offset | None = None
for segment in line:
end += segment.cell_length
style = segment.style
if style is not None and style._meta is not None:
meta = style.meta
if "offset" in meta:
- offset_x, offset_y = style.meta["offset"]
+ offset_x, offset_y = meta["offset"]
+ if offset_y is None:
+ continue
offset_x2 = offset_x + len(segment.text)
if x < end and x >= start:
@@ -955,14 +961,14 @@ class Compositor:
break
segment_cell_length += get_character_cell_size(character)
segment_offset += 1
- return widget, (
- None
- if offset_y is None
- else Offset(offset_x + segment_offset, offset_y)
- )
+
+ offset = Offset(offset_x + segment_offset, offset_y)
+ break
start = end
- return widget, (None if offset_y is None else Offset(offset_x2, offset_y))
+ if offset is None and offset_y is not None:
+ offset = Offset(offset_x2, offset_y)
+ return widget, offset
def find_widget(self, widget: Widget) -> MapGeometry:
"""Get information regarding the relative position of a widget in the Compositor.
diff --git a/contrib/python/textual/textual/app.py b/contrib/python/textual/textual/app.py
index 4840d98b7d6..a716ba35522 100644
--- a/contrib/python/textual/textual/app.py
+++ b/contrib/python/textual/textual/app.py
@@ -488,20 +488,32 @@ class App(Generic[ReturnType], DOMNode):
HORIZONTAL_BREAKPOINTS: ClassVar[list[tuple[int, str]]] | None = []
"""List of horizontal breakpoints for responsive classes.
- This allows for styles to be responsive to the dimensions of the terminal.
- For instance, you might want to show less information, or fewer columns on a narrow displays -- or more information when the terminal is sized wider than usual.
-
- A breakpoint consists of a tuple containing the minimum width where the class should applied, and the name of the class to set.
+ Setting this classvar on `App` or `Screen` will automatically apply TCSS classes to your Screen according to the terminal width.
+ You can then use these classes to adjust the UI to better fit your content to the current terminal dimensions.
- Note that only one class name is set, and you should avoid having more than one breakpoint set for the same size.
+ A breakpoint consists of a tuple containing the minimum width where the class should be applied, and the name of the class to set.
+ Only a single class name may be given, and you should avoid having more than one breakpoint for the same size.
- Example:
- ```python
- # Up to 80 cells wide, the app has the class "-normal"
- # 80 - 119 cells wide, the app has the class "-wide"
- # 120 cells or wider, the app has the class "-very-wide"
+ Set `HORIZONTAL_BREAKPOINTS` on your app or screen as follows:
+
+ ```python
+ class MyApp(App):
+ # Up to 80 cells wide, the screen has the class "-normal"
+ # 80 - 119 cells wide, the screen has the class "-wide"
+ # 120 cells or wider, the screen has the class "-very-wide"
HORIZONTAL_BREAKPOINTS = [(0, "-normal"), (80, "-wide"), (120, "-very-wide")]
- ```
+ ```
+
+ You can then appliy specfific rules for breakpoints with a rule that targets your screen with the given breakpoint:
+
+ ```
+ Screen.-wide {
+ # Rules for wide terminals here.
+ }
+ Screen.-very-wide {
+ # Rules for very wide terminals.
+ }
+ ```
"""
VERTICAL_BREAKPOINTS: ClassVar[list[tuple[int, str]]] | None = []
diff --git a/contrib/python/textual/textual/geometry.py b/contrib/python/textual/textual/geometry.py
index 7577d2cb43f..b8b065b8329 100644
--- a/contrib/python/textual/textual/geometry.py
+++ b/contrib/python/textual/textual/geometry.py
@@ -1337,7 +1337,9 @@ class Shape:
regions: Regions which will define the shape.
"""
self._regions = tuple(regions)
- self._bounds = Region.from_union(self._regions) if regions else NULL_REGION
+ self._bounds = (
+ Region.from_union(self._regions) if self._regions else NULL_REGION
+ )
def __bool__(self) -> bool:
return bool(self._bounds)
@@ -1348,6 +1350,17 @@ class Shape:
def __rich_repr__(self) -> rich.repr.Result:
yield self._regions
+ def draw(self, size: Size) -> str:
+ """Build a string with a 2D grid of results from contains_point.
+
+ This is a debugging aid (do not use in production).
+ """
+ width, height = size
+ map: list[list[str]] = []
+ for y in range(height):
+ map.append([".X"[self.contains_point(Offset(x, y))] for x in range(width)])
+ return "\n".join("".join(line) for line in map)
+
@property
def regions(self) -> tuple[Region, ...]:
"""The regions in the shape."""
@@ -1399,12 +1412,12 @@ class Shape:
"""
# Special case where start and end offsets are on the edges, and the shape
# becomes a single region
- if start_x == 0 and end_x == container.width:
+ if start_x == container.x and end_x == container.right:
yield Region(
- 0,
+ container.x,
start_y,
container.width,
- end_y - start_y,
+ end_y - start_y + 1,
)
# Simple case: all on one line
@@ -1422,14 +1435,14 @@ class Shape:
yield Region(
start_x,
start_y,
- container.width - start_x,
+ container.right - start_x,
1,
)
# middle
- if end.y - start.y > 2:
+ if end.y - start.y > 1:
# We need a middle region between the top and the bottom
yield Region(
- 0,
+ container.x,
start_y + 1,
container.width,
end_y - start_y - 1,
@@ -1438,7 +1451,7 @@ class Shape:
yield Region(
container.x,
end_y,
- end_x,
+ end_x - container.x,
1,
)
diff --git a/contrib/python/textual/textual/screen.py b/contrib/python/textual/textual/screen.py
index f5c05e72d25..af9070538e7 100644
--- a/contrib/python/textual/textual/screen.py
+++ b/contrib/python/textual/textual/screen.py
@@ -58,7 +58,7 @@ from textual.layout import DockArrangeResult
from textual.reactive import Reactive, var
from textual.renderables.background_screen import BackgroundScreen
from textual.renderables.blank import Blank
-from textual.selection import SELECT_ALL, Selection
+from textual.selection import SELECT_ALL, SelectEnd, Selection, SelectStart, SelectState
from textual.signal import Signal
from textual.timer import Timer
from textual.walk import walk_selectable_widgets
@@ -257,13 +257,8 @@ class Screen(Generic[ScreenResultType], Widget):
_selecting = var(False)
"""Indicates mouse selection is in progress."""
- _box_select = var(False)
- """Should text selection be limited to a box?"""
-
- _select_start: Reactive[tuple[Widget, Offset, Offset] | None] = Reactive(None)
- """Tuple of (widget, screen offset, text offset) where selection started."""
- _select_end: Reactive[tuple[Widget, Offset, Offset] | None] = Reactive(None)
- """Tuple of (widget, screen offset, text offset) where selection ends."""
+ _select_state: Reactive[SelectState | None] = Reactive(None)
+ """Current select state, if selecting."""
_mouse_down_offset: var[Offset | None] = var(None)
"""Last mouse down screen offset, or `None` if the mouse is up."""
@@ -759,8 +754,7 @@ class Screen(Generic[ScreenResultType], Widget):
def clear_selection(self) -> None:
"""Clear any selected text."""
self.selections = {}
- self._select_start = None
- self._select_end = None
+ self._select_state = None
def _select_all_in_widget(self, widget: Widget) -> None:
"""Select a widget and all its children.
@@ -1728,12 +1722,12 @@ class Screen(Generic[ScreenResultType], Widget):
widget: Container widgets to scroll.
direction: Lines to scroll.
"""
- if self._select_start is not None:
+ if self._select_state is not None:
# Update scroll position
widget.scroll_y += direction
widget.scroll_target_y = widget.scroll_y
# Update selection highlights which may have changed due to the scroll
- self._update_select(self.app.mouse_position)
+ self._update_select()
# Replace current timer
self._stop_auto_scroll()
@@ -1819,40 +1813,9 @@ class Screen(Generic[ScreenResultType], Widget):
# Nothing to auto scroll, so stop the timer
self._stop_auto_scroll()
- def _update_select(self, screen_offset: Offset) -> None:
- """Update select for a screen-space offset (typically the mouse position).
-
- This updates the `_select_end` reactrive, which will trigger the watch method `watch__select_end`.
-
- Args:
- screen_offset: Screen-space position (i.e. mouse position).
- """
- select_widget, select_offset = self.get_widget_and_offset_at(
- screen_offset.x, screen_offset.y
- )
- if (
- self._select_end is not None
- and select_offset is None
- and screen_offset.y > self._select_end[1].y
- ):
- end_widget = self._select_end[0]
- select_offset = end_widget.content_region.bottom_right_inclusive
- self._select_end = (
- end_widget,
- screen_offset,
- select_offset,
- )
-
- elif (
- select_widget is not None
- and select_widget.allow_select
- and select_offset is not None
- ):
- self._select_end = (
- select_widget,
- screen_offset,
- select_offset,
- )
+ def _update_select(self) -> None:
+ """Update select for a screen-space offset (typically the mouse position)."""
+ self._watch__select_state(self._select_state)
def _forward_event(self, event: events.Event) -> None:
if event.is_forwarded:
@@ -1866,34 +1829,25 @@ class Screen(Generic[ScreenResultType], Widget):
event.style = self.get_style_at(event.screen_x, event.screen_y)
self._handle_mouse_move(event)
- if self._selecting and self._select_start is not None:
+ if self._selecting and self._select_state is not None:
- self._box_select = event.shift
select_widget, select_offset = self.get_widget_and_offset_at(
event.x, event.y
)
- if (
- self._select_end is not None
- and select_offset is None
- and event.y > self._select_end[1].y
- ):
- end_widget = self._select_end[0]
- select_offset = end_widget.content_region.bottom_right_inclusive
- self._select_end = (
- end_widget,
- event.screen_offset,
- select_offset,
- )
+ if select_widget is not None:
+ if select_offset is not None:
+ content_widget = select_widget
+ content_offset = select_offset
+ assert isinstance(content_widget.parent, Widget)
+ container = content_widget.parent
+ else:
+ content_widget = None
+ container = select_widget
+ content_offset = None
- elif (
- select_widget is not None
- and select_widget.allow_select
- and select_offset is not None
- ):
- self._select_end = (
- select_widget,
+ self._select_state = self._select_state.update_end(
event.screen_offset,
- select_offset,
+ SelectEnd(container, content_widget, content_offset),
)
if select_widget is not None:
@@ -1926,10 +1880,9 @@ class Screen(Generic[ScreenResultType], Widget):
self.post_message(events.TextSelected())
elif isinstance(event, events.MouseDown) and not self.app.mouse_captured:
- self._box_select = event.shift
self._mouse_down_offset = event.screen_offset
select_widget, select_offset = self.get_widget_and_offset_at(
- event.screen_x, event.screen_y
+ event.x, event.y
)
if (
select_widget is not None
@@ -1937,16 +1890,29 @@ class Screen(Generic[ScreenResultType], Widget):
and self.screen.allow_select
and self.app.ALLOW_SELECT
):
- self._selecting = True
- if select_widget is not None and select_offset is not None:
- self.text_selection_started_signal.publish(self)
- self._select_start = (
- select_widget,
- event.screen_offset,
- select_offset,
- )
+ if select_offset is not None:
+ content_widget = select_widget
+ content_offset = select_offset
+ assert isinstance(content_widget.parent, Widget)
+ container = content_widget.parent
+ else:
+ content_widget = None
+ container = select_widget
+ content_offset = None
+
+ self._select_state = SelectState(
+ event.screen_offset,
+ start=SelectStart(
+ container,
+ event.screen_offset - container.region.offset,
+ container.region.offset,
+ container.scroll_offset,
+ content_widget=content_widget,
+ content_offset=content_offset,
+ ),
+ )
else:
- self._selecting = False
+ self._select_state = None
try:
if self.app.mouse_captured:
@@ -1995,6 +1961,7 @@ class Screen(Generic[ScreenResultType], Widget):
"""Get widgets between two widgets in select order.
Args:
+ selection_bounds: A shape defining the selection bounds.
container: A parent widgets.
start_widget: First widget.
end_widget: Second widget.
@@ -2013,93 +1980,63 @@ class Screen(Generic[ScreenResultType], Widget):
index1: int | None = None
try:
- index1 = widgets.index(start_widget) + 1
+ index1 = widgets.index(start_widget)
except ValueError:
pass
index2: int | None = None
try:
- index2 = widgets.index(end_widget)
+ index2 = widgets.index(end_widget) + 1
except ValueError:
pass
results = widgets[index1:index2]
return results
- def _watch__select_end(
- self, select_end: tuple[Widget, Offset, Offset] | None
- ) -> None:
- """When select_end changes, we need to compute which widgets and regions are selected.
+ def _watch__select_state(self, select_state: SelectState | None) -> None:
+ """Respond to user-initiated selection change.
Args:
- select_end: The end selection.
+ select_state: Current selection state.
"""
-
- if select_end is None or self._select_start is None:
- # Nothing to select
+ if select_state is None:
+ # Nothing selected so nothing todo
+ self._selecting = False
+ self.refresh()
return
+ else:
+ self._selecting = True
- start_widget, screen_start, start_offset = self._select_start
- end_widget, screen_end, end_offset = select_end
+ if select_state.end is None:
+ # Pointer hasn't yet moved
+ return
- if not start_widget.is_attached or not end_widget.is_attached:
- # Widgets may have been removed since selection started
+ if not select_state.is_attached_to_dom:
+ # Widgets may have been removed in the interim
+ self._select_state = None
return
- if start_widget is end_widget:
- # Simplest case, selection starts and ends on the same widget
- if end_offset.transpose < start_offset.transpose:
- start_offset, end_offset = end_offset, start_offset
+ # Simple case where select starts and ends on the same widgets
+ if select_state.is_single_content_widget:
+ start_index, end_offset = select_state.content_offsets
+ assert select_state.start.content_widget is not None
self.selections = {
- start_widget: Selection.from_offsets(
- start_offset,
+ select_state.start.content_widget: Selection.from_offsets(
+ start_index,
end_offset + (1, 0),
)
}
return
- # The start selection may have been scrolled since it was saved
- # We need to adjust to the new screen-space position
- select_start = (start_widget, start_widget.region.offset, start_offset)
- # Ensure select_start is < select_end in selection order
- if select_start[0]._selection_order > select_end[0]._selection_order:
- select_start, select_end = select_end, select_start
-
- start_widget, screen_start, start_offset = select_start
- end_widget, screen_end, end_offset = select_end
-
- if (screen_start + start_offset).transpose > (
- screen_end + end_offset
- ).transpose:
- start_widget, end_widget = end_widget, start_widget
-
- # Get a widget which contains both widgets
- container_widget = Widget.get_common_ancestor(
- start_widget, end_widget, default=self
- )
-
- # Get a selection bounds shape
- selection_bounds = Shape.selection_bounds(
- container_widget.region,
- select_start[1] + select_start[2],
- self.app.mouse_position,
- )
-
- # Get widgets bounded by the selection bounds
- select_widgets = self._collect_select_widgets(
- selection_bounds,
- container_widget,
- start_widget,
- end_widget,
- )
-
- # Build the selection
+ # Select all the widgets
select_all = SELECT_ALL
- self.selections = {
- start_widget: Selection(start_offset, None),
- **{widget: select_all for widget in select_widgets},
- end_widget: Selection(None, end_offset + (1, 0)),
+ selections = {
+ widget: select_all for widget in select_state._walk_selected_widgets()
}
+ select_state._apply_content_selections(selections)
+
+ # Update selections
+ self.selections = selections
def dismiss(self, result: ScreenResultType | None = None) -> AwaitComplete:
"""Dismiss the screen, optionally with a result.
diff --git a/contrib/python/textual/textual/scrollbar.py b/contrib/python/textual/textual/scrollbar.py
index 68fa22dd02a..ac87db36cd5 100644
--- a/contrib/python/textual/textual/scrollbar.py
+++ b/contrib/python/textual/textual/scrollbar.py
@@ -404,7 +404,7 @@ class ScrollBarCorner(Widget):
"""Widget which fills the gap between horizontal and vertical scrollbars,
should they both be present."""
- def render(self) -> RenderableType:
+ def render(self) -> Blank:
assert self.parent is not None
styles = self.parent.styles
color = styles.scrollbar_corner_color
diff --git a/contrib/python/textual/textual/selection.py b/contrib/python/textual/textual/selection.py
index 25da6cfb0fc..9b586f92e16 100644
--- a/contrib/python/textual/textual/selection.py
+++ b/contrib/python/textual/textual/selection.py
@@ -1,8 +1,12 @@
from __future__ import annotations
-from typing import NamedTuple
+from operator import attrgetter
+from typing import TYPE_CHECKING, Iterable, Iterator, NamedTuple
-from textual.geometry import Offset
+from textual.geometry import Offset, Shape
+
+if TYPE_CHECKING:
+ from textual.widget import Widget
class Selection(NamedTuple):
@@ -115,3 +119,343 @@ class Selection(NamedTuple):
SELECT_ALL = Selection(None, None)
+
+
+class SelectStart(NamedTuple):
+ """Describes the start of a select."""
+
+ container: Widget
+ """The container under the cursor."""
+ container_pointer_delta: Offset
+ """The delta between the initial container offset and pointer."""
+ container_initial_offset: Offset
+ """The initial offset of the container."""
+ container_initial_scroll_offset: Offset
+ """The initial scroll offset of the container."""
+ content_widget: Widget | None
+ """The content widget under the pointer (if any)."""
+ content_offset: Offset | None
+ """The content offset of the widget under the pointer (if appropriate)."""
+
+ @property
+ def pointer_start_offset(self) -> Offset:
+ """The pointer start offset adjusted for scroll."""
+ return (
+ self.container.region.offset
+ + self.container_pointer_delta
+ + (self.container.scroll_offset - self.container_initial_scroll_offset)
+ )
+
+
+class SelectEnd(NamedTuple):
+ """The end of a select."""
+
+ container: Widget
+ """The container widget under the pointer."""
+ content_widget: Widget | None
+ """The content widget under the pointer (if any)."""
+ content_offset: Offset | None
+ """The content offset of the widget under the pointer."""
+
+
+class SelectState(NamedTuple):
+ """An object which describes the current select state."""
+
+ screen_offset: Offset
+ """The current mouse position, in screen space."""
+ start: SelectStart
+ """Describes the select start."""
+ end: SelectEnd | None = None
+ """Describes the select end."""
+
+ def is_attached_to_dom(self) -> bool:
+ """Are the widgets involved attached to the DOM?"""
+ # This may return False if the widgets have been removed since selection started
+ if not self.start.container.is_attached:
+ return False
+ if self.end is not None and not self.end.container.is_attached:
+ return False
+ return True
+
+ @property
+ def is_single_content_widget(self) -> bool:
+ """Does the start and end fall on the same widget?"""
+ assert self.end is not None
+ return (
+ self.start.content_widget is not None
+ and self.start.content_widget is self.end.content_widget
+ and self.start.content_offset is not None
+ and self.end.content_offset is not None
+ )
+
+ @property
+ def content_offsets(self) -> tuple[Offset, Offset]:
+ """Get the content offset in select order."""
+ assert (
+ self.end is not None
+ ), "Unavailable until there is an end point to the selection"
+ start_offset = self.start.content_offset
+ end_offset = self.end.content_offset
+ assert start_offset is not None
+ assert end_offset is not None
+ if end_offset.transpose < start_offset.transpose:
+ start_offset, end_offset = end_offset, start_offset
+ return start_offset, end_offset
+
+ @property
+ def select_container(self) -> Widget:
+ """A widget that contains both ends of the select."""
+ from textual.screen import Screen
+ from textual.widget import Widget
+
+ widgets = [
+ (
+ self.start.content_widget
+ if self.start.content_widget is not None
+ else self.start.container
+ )
+ ]
+ if self.end is not None:
+ widgets.append(
+ self.end.content_widget
+ if self.end.content_widget is not None
+ else self.end.container
+ )
+
+ if len(widgets) == 2:
+ widget1, widget2 = widgets
+ if isinstance(widget1, Screen):
+ return widget1
+ if isinstance(widget2, Screen):
+ return widget2
+ try:
+ return Widget.get_common_ancestor(widget1, widget2)
+ except ValueError:
+ return widget1
+ else:
+ return widgets[0]
+
+ @property
+ def selection_bounds(self) -> Shape:
+ """A shape which overlays the area of selected text."""
+
+ selection_bounds = Shape.selection_bounds(
+ self.select_container.region,
+ self.start.pointer_start_offset,
+ self.screen_offset,
+ )
+ return selection_bounds
+
+ @property
+ def ordered_offsets(self) -> tuple[Offset, Offset]:
+ """Offsets used in selection bounds, in selection order."""
+ start_offset = self.start.pointer_start_offset
+ end_offset = self.screen_offset
+
+ if start_offset.transpose > end_offset.transpose:
+ start_offset, end_offset = end_offset, start_offset
+
+ return start_offset, end_offset
+
+ def update_end(self, pointer_offset: Offset, select_end: SelectEnd) -> SelectState:
+ """Update the state with the selction end.
+
+ Args:
+ pointer_offset: Current mosue position.
+ select_end: Selection end.
+
+ Returns:
+ SelectState: New select state.
+
+ """
+ return SelectState(pointer_offset, self.start, select_end)
+
+ def _apply_content_selections(self, selections: dict[Widget, Selection]):
+ assert (
+ self.end is not None
+ ), "Unavailable until there is an end point to the selection"
+ start_widget = self.start.content_widget
+ start_content_offset = self.start.content_offset
+ start_offset = self.start.pointer_start_offset
+
+ end_widget = self.end.content_widget
+ end_content_offset = self.end.content_offset
+ end_offset = self.screen_offset
+
+ if end_offset.transpose < start_offset.transpose:
+ start_widget, end_widget = end_widget, start_widget
+ start_content_offset, end_content_offset = (
+ end_content_offset,
+ start_content_offset,
+ )
+
+ if start_widget is not None and start_content_offset is not None:
+ selections[start_widget] = Selection(start_content_offset, None)
+ if end_widget is not None and end_content_offset is not None:
+ selections[end_widget] = Selection(None, end_content_offset)
+
+ def _walk_selected_widgets(self) -> list[Widget]:
+ assert (
+ self.end is not None
+ ), "Unavailable until there is an end point to the selection"
+
+ selection_bounds = self.selection_bounds
+ select_container = self.select_container
+
+ # Endpoints sorted by screen position.
+ ordered_start, ordered_end = self.ordered_offsets
+ start_y = ordered_start.y
+ end_y = ordered_end.y
+
+ # Identify the content widgets at each end of the selection, in
+ # selection order. Either may be `None` if the pointer was not over a
+ # content widget at that end.
+ if self.start.pointer_start_offset.transpose <= self.screen_offset.transpose:
+ first_content_widget = self.start.content_widget
+ last_content_widget = self.end.content_widget
+ else:
+ first_content_widget = self.end.content_widget
+ last_content_widget = self.start.content_widget
+
+ get_selection_order = attrgetter("_selection_order")
+ selected: list[Widget] = []
+
+ def walk_in_select_order(root: Widget) -> Iterable[Widget]:
+ """Walk descendants of `root` depth-first in selection order."""
+ stack: list[Iterator[Widget]] = [
+ iter(
+ sorted(
+ root.displayed_and_visible_children,
+ key=get_selection_order,
+ )
+ )
+ ]
+ while stack:
+ widget = next(stack[-1], None)
+ if widget is None:
+ stack.pop()
+ continue
+ yield widget
+ children = widget.displayed_and_visible_children
+ if children:
+ stack.append(
+ iter(
+ sorted(
+ children,
+ key=get_selection_order,
+ )
+ )
+ )
+
+ def collect_range(
+ container: Widget,
+ from_widget: Widget | None,
+ to_widget: Widget | None,
+ *,
+ from_y: int | None = None,
+ to_y: int | None = None,
+ ) -> None:
+ """Collect selectable descendants between two content widgets.
+
+ Walks `container` in selection order, including selectable
+ non-container descendants.
+
+ When the start or end pointer lands on a gap (no content widget),
+ `from_y` / `to_y` fall back to a vertical bound on the widget's
+ `content_region.y` so the selection grows continuously as the
+ pointer moves, rather than snapping to the whole container.
+
+ Args:
+ container: Top level widget, that is parent of `from_widget` and `to_widget.
+ from_widget: First widget in sslection order or first in selection order.
+ to_widget: Last widget in selection order or `None` for end of container.
+
+ from_y: Start `y` of selection, or `None` for top.
+ to_y: End `y` of selection, or `None` for end.
+ """
+ started = from_widget is None and from_y is None
+ for descendant in walk_in_select_order(container):
+ if descendant.is_container or not descendant.allow_select:
+ continue
+ widget_y = descendant.content_region.y
+ if not started:
+ if from_widget is not None:
+ if descendant is from_widget:
+ started = True
+ else:
+ continue
+ else:
+ # from_y bound is active.
+ assert from_y is not None
+ if widget_y >= from_y:
+ started = True
+ else:
+ continue
+ if to_widget is None and to_y is not None and widget_y > to_y:
+ return
+ selected.append(descendant)
+ if to_widget is not None and descendant is to_widget:
+ return
+
+ def visit(root: Widget) -> None:
+ """Walk children of `parent`, deciding inclusion per child.
+
+ Args:
+ root: Initial node to walk from.
+ """
+ for child in sorted(
+ root.displayed_and_visible_children,
+ key=get_selection_order,
+ ):
+ if child.is_container:
+ child_region = child.region
+ if not child_region:
+ continue
+ if not selection_bounds.overlaps(child_region):
+ continue
+
+ has_hidden_content = child.is_scrollable and (
+ child.max_scroll_y > 0 or child.max_scroll_x > 0
+ )
+
+ if has_hidden_content:
+ child_top = child_region.y
+ child_bottom = child_region.bottom
+ extends_above = start_y < child_top
+ extends_below = end_y >= child_bottom
+
+ if extends_above and extends_below:
+ # Selection passes through this container; select
+ # everything inside it.
+ collect_range(child, None, None)
+ continue
+ if extends_above:
+ # Selection enters this container from above;
+ # select from top down to the end content widget,
+ # or to the pointer y if the pointer is on a gap.
+ if last_content_widget is not None:
+ collect_range(child, None, last_content_widget)
+ else:
+ collect_range(child, None, None, to_y=end_y)
+ continue
+ if extends_below:
+ # Selection exits this container below; select
+ # from the start content widget (or the pointer y
+ # if on a gap) down to the end.
+ if first_content_widget is not None:
+ collect_range(child, first_content_widget, None)
+ else:
+ collect_range(child, None, None, from_y=start_y)
+ continue
+
+ # Both endpoints inside this child, or nothing scrolled
+ # out; fall back to the standard visual walk.
+ visit(child)
+ else:
+ if child.allow_select and selection_bounds.overlaps(
+ child.content_region
+ ):
+ selected.append(child)
+
+ visit(select_container)
+ return selected
diff --git a/contrib/python/textual/textual/visual.py b/contrib/python/textual/textual/visual.py
index b789fdbf06e..cb5523efbd8 100644
--- a/contrib/python/textual/textual/visual.py
+++ b/contrib/python/textual/textual/visual.py
@@ -235,8 +235,11 @@ class Visual(ABC):
selection_style,
),
)
- if widget.auto_links and not widget.is_container:
- # TODO: This is suprisingly expensive (why?)
+ if (
+ widget.auto_links
+ and not widget.is_container
+ and not widget.screen._selecting
+ ):
link_style = widget.link_style
strips = [strip._apply_link_style(link_style) for strip in strips]
diff --git a/contrib/python/textual/textual/walk.py b/contrib/python/textual/textual/walk.py
index 6a04f80df26..c56990cb751 100644
--- a/contrib/python/textual/textual/walk.py
+++ b/contrib/python/textual/textual/walk.py
@@ -174,7 +174,7 @@ def walk_breadth_search_id(
def walk_selectable_widgets(
- root: DOMNode, bounds: Shape, bounded: set[DOMNode]
+ root: Widget, bounds: Shape, containers: set[DOMNode]
) -> Iterable[Widget]:
"""Walk the tree depth first in select order (top to bottom, then left to right).
@@ -186,7 +186,7 @@ def walk_selectable_widgets(
Returns:
An iterable of DOMNodes.
"""
- stack: list[Iterator[Widget]] = [iter(root.children)]
+ stack: list[Iterator[Widget]] = [iter([root])]
pop = stack.pop
push = stack.append
@@ -205,8 +205,10 @@ def walk_selectable_widgets(
node.displayed_and_visible_children,
key=get_selection_order,
)
- if node in bounded:
- children = [child for child in children if bounds.overlaps(child.region)]
+ if node in containers:
+ children = [
+ child for child in children if bounds.overlaps(child.content_region)
+ ]
return children
children = get_children(root)
@@ -214,7 +216,8 @@ def walk_selectable_widgets(
while stack:
if (node := next(stack[-1], None)) is None:
pop()
- elif node.allow_select:
- yield node
+ else:
+ if not node.is_container and node.allow_select:
+ yield node
if children := get_children(node):
push(iter(children))
diff --git a/contrib/python/textual/textual/widgets/_digits.py b/contrib/python/textual/textual/widgets/_digits.py
index e3c6e13c31a..9c505f88282 100644
--- a/contrib/python/textual/textual/widgets/_digits.py
+++ b/contrib/python/textual/textual/widgets/_digits.py
@@ -78,7 +78,9 @@ class Digits(Widget):
"""Render digits."""
rich_style = self.rich_style
if self.text_selection:
- rich_style += self.selection_style
+ rich_style = self.screen.get_component_rich_style(
+ "screen--selection", partial=True
+ )
digits = DigitsRenderable(self._value, rich_style)
text_align = self.styles.text_align
align = "left" if text_align not in {"left", "center", "right"} else text_align
diff --git a/contrib/python/textual/textual/widgets/_markdown.py b/contrib/python/textual/textual/widgets/_markdown.py
index 4f6e9fff109..847f5cfa5a0 100644
--- a/contrib/python/textual/textual/widgets/_markdown.py
+++ b/contrib/python/textual/textual/widgets/_markdown.py
@@ -222,7 +222,11 @@ class MarkdownBlock(Static):
)
super().__init__(
- *args, name=token.type, classes=f"level-{token.level}", **kwargs
+ *args,
+ name=token.type,
+ classes=f"level-{token.level}",
+ expand=True,
+ **kwargs,
)
@property
@@ -667,9 +671,11 @@ class MarkdownTableContent(Widget):
header
)
for row_index, row in enumerate(self.rows, 1):
- for cell in row:
+ for cell_index, cell in enumerate(row, 1):
yield MarkdownTableCellContents(
- cell, classes=f"row{row_index} cell"
+ cell,
+ classes=f"row{row_index} cell",
+ name=f"cell{row_index}.{cell_index}",
).with_tooltip(cell.plain)
self.last_row = row_index
@@ -691,7 +697,10 @@ class MarkdownTableContent(Widget):
for row_index, row in enumerate(updated_rows, self.last_row):
for cell in row:
new_cells.append(
- Static(cell, classes=f"row{row_index} cell").with_tooltip(cell)
+ Static(
+ cell,
+ classes=f"row{row_index} cell",
+ ).with_tooltip(cell)
)
self.last_row = row_index
await self.mount_all(new_cells)
@@ -891,6 +900,8 @@ class MarkdownFence(MarkdownBlock):
ansi=self.app.native_ansi_color,
dark=self.app.current_theme.dark,
)
+ # No links required in code
+ self.auto_links = False
def notify_style_update(self) -> None:
"""Update highlight theme when App theme changes."""
@@ -924,13 +935,15 @@ class MarkdownFence(MarkdownBlock):
def _copy_context(self, block: MarkdownBlock) -> None:
if isinstance(block, MarkdownFence):
+ self.code = block.code
self.lexer = block.lexer
- self._token = block._token
+ self._highlighted_code = block._highlighted_code
+ super()._copy_context(block)
async def _update_from_block(self, block: MarkdownBlock):
if isinstance(block, MarkdownFence):
- self.set_content(block._highlighted_code)
self._copy_context(block)
+ self.set_content(block._highlighted_code)
else:
await super()._update_from_block(block)
@@ -940,7 +953,7 @@ class MarkdownFence(MarkdownBlock):
self.query_one("#code-content", Label).update(content)
def compose(self) -> ComposeResult:
- yield Label(self._highlighted_code, id="code-content")
+ yield Label(self._highlighted_code, id="code-content", expand=True)
NUMERALS = " ⅠⅡⅢⅣⅤⅥ"
diff --git a/contrib/python/textual/ya.make b/contrib/python/textual/ya.make
index 050a98ddae8..a92ca7dbbc3 100644
--- a/contrib/python/textual/ya.make
+++ b/contrib/python/textual/ya.make
@@ -2,7 +2,7 @@
PY3_LIBRARY()
-VERSION(8.2.5)
+VERSION(8.2.6)
LICENSE(MIT)