diff options
| author | robot-piglet <[email protected]> | 2026-05-28 23:34:51 +0300 |
|---|---|---|
| committer | robot-piglet <[email protected]> | 2026-05-29 11:50:30 +0300 |
| commit | 339322825480794f27ea42cb153ece122ea2b225 (patch) | |
| tree | 18b82528c26369faed2e356ac86a83a883cccd1d /contrib/python | |
| parent | d3c60c55dba8a3d57a5d9c4031db34b48aa343e3 (diff) | |
Intermediate changes
commit_hash:0bc4b818aff76116670772782f4aacc0e569d4be
Diffstat (limited to 'contrib/python')
| -rw-r--r-- | contrib/python/mdit-py-plugins/.dist-info/METADATA | 2 | ||||
| -rw-r--r-- | contrib/python/mdit-py-plugins/mdit_py_plugins/__init__.py | 2 | ||||
| -rw-r--r-- | contrib/python/mdit-py-plugins/mdit_py_plugins/field_list/__init__.py | 5 | ||||
| -rw-r--r-- | contrib/python/mdit-py-plugins/ya.make | 2 | ||||
| -rw-r--r-- | contrib/python/textual/.dist-info/METADATA | 2 | ||||
| -rw-r--r-- | contrib/python/textual/textual/_compositor.py | 20 | ||||
| -rw-r--r-- | contrib/python/textual/textual/app.py | 34 | ||||
| -rw-r--r-- | contrib/python/textual/textual/geometry.py | 29 | ||||
| -rw-r--r-- | contrib/python/textual/textual/screen.py | 215 | ||||
| -rw-r--r-- | contrib/python/textual/textual/scrollbar.py | 2 | ||||
| -rw-r--r-- | contrib/python/textual/textual/selection.py | 348 | ||||
| -rw-r--r-- | contrib/python/textual/textual/visual.py | 7 | ||||
| -rw-r--r-- | contrib/python/textual/textual/walk.py | 15 | ||||
| -rw-r--r-- | contrib/python/textual/textual/widgets/_digits.py | 4 | ||||
| -rw-r--r-- | contrib/python/textual/textual/widgets/_markdown.py | 27 | ||||
| -rw-r--r-- | contrib/python/textual/ya.make | 2 |
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) |
