From 8bc1c65cba95db3f44ffd983bcf45f727bb1bc41 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 22 Oct 2025 10:05:13 +0100 Subject: [PATCH 1/3] fix for selecting cells --- src/textual/_compositor.py | 16 ++++++++++++++-- src/textual/screen.py | 12 ++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 2346cb5e1c..70fea3d7da 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -926,6 +926,9 @@ def get_widget_and_offset_at( offset_y: int | None = None offset_x = 0 offset_x2 = 0 + cell_position = 0 + + from rich.cells import get_character_cell_size for segment in line: end += len(segment.text) @@ -940,13 +943,22 @@ def get_widget_and_offset_at( if x == end - 1: segment_offset = len(segment.text) else: - first, _ = segment.split_cells(x - start) - segment_offset = len(first.text) + segment_cell_length = 0 + cell_cut = x - cell_position + segment_offset = 0 + for character in segment.text: + if segment_cell_length >= cell_cut: + 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) ) + cell_position += cell_len(segment.text) start = end return widget, (None if offset_y is None else Offset(offset_x2, offset_y)) diff --git a/src/textual/screen.py b/src/textual/screen.py index c130af0a1a..e0c37ca13c 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -1652,14 +1652,22 @@ def _forward_event(self, event: events.Event) -> None: ): end_widget = self._select_end[0] select_offset = end_widget.content_region.bottom_right_inclusive - self._select_end = (end_widget, event.offset, select_offset) + self._select_end = ( + end_widget, + event.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, event.offset, select_offset) + self._select_end = ( + select_widget, + event.screen_offset, + select_offset, + ) elif isinstance(event, events.MouseEvent): if isinstance(event, events.MouseUp): From c3e72ed64750e0964679d1df2d3b5b77acbcacfb Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 22 Oct 2025 10:49:48 +0100 Subject: [PATCH 2/3] text selection test --- CHANGELOG.md | 1 + src/textual/screen.py | 1 - src/textual/selection.py | 4 ++-- tests/test_selection.py | 23 +++++++++++++++++++++++ 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6caa1e9249..1549114319 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Fixed - Fixed type hint aliasing for App under TYPE_CHECKING https://github.com/Textualize/textual/pull/6152 +- Fixed for text selection with double width characters https://github.com/Textualize/textual/pull/6186 ### Changed diff --git a/src/textual/screen.py b/src/textual/screen.py index e0c37ca13c..8d13ac67be 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -1738,7 +1738,6 @@ def _watch__select_end( Args: select_end: The end selection. """ - if select_end is None or self._select_start is None: # Nothing to select return diff --git a/src/textual/selection.py b/src/textual/selection.py index 464d218f85..0466fbec5a 100644 --- a/src/textual/selection.py +++ b/src/textual/selection.py @@ -56,12 +56,12 @@ def extract(self, text: str) -> str: return lines[start_line][start_offset:end_offset] selection: list[str] = [] - selected_lines = lines[start_line:end_line] + selected_lines = lines[start_line : end_line + 1] if len(selected_lines) >= 2: first_line, *mid_lines, last_line = selected_lines selection.append(first_line[start_offset:]) selection.extend(mid_lines) - selection.append(last_line[: end_offset + 1]) + selection.append(last_line[:end_offset]) else: return lines[start_line][start_offset:end_offset] return "\n".join(selection) diff --git a/tests/test_selection.py b/tests/test_selection.py index 13aaa5d33b..83fca7653d 100644 --- a/tests/test_selection.py +++ b/tests/test_selection.py @@ -1,7 +1,9 @@ import pytest +from textual.app import App, ComposeResult from textual.geometry import Offset from textual.selection import Selection +from textual.widgets import Static @pytest.mark.parametrize( @@ -20,3 +22,24 @@ def test_extract(text: str, selection: Selection, expected: str) -> None: """Test Selection.extract""" assert selection.extract(text) == expected + + +async def test_double_width(): + """Test that selection works with double width characters.""" + + TEXT = """πŸ˜‚β€οΈπŸ‘SelectπŸ˜ŠπŸ™πŸ˜\nmeπŸ”₯πŸ’―πŸ˜­πŸ˜‚β€οΈπŸ‘""" + + class TextSelectApp(App): + def compose(self) -> ComposeResult: + yield Static(TEXT) + + app = TextSelectApp() + async with app.run_test() as pilot: + await pilot.pause() + assert await pilot.mouse_down(offset=(2, 0)) + await pilot.pause() + assert await pilot.mouse_up(offset=(7, 1)) + selected_text = app.screen.get_selected_text() + expected = "β€οΈπŸ‘SelectπŸ˜ŠπŸ™πŸ˜\nmeπŸ”₯πŸ’―πŸ˜­" + + assert selected_text == expected From 84987180707615d6aa9024620eec19e7ea196e35 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 22 Oct 2025 11:37:52 +0100 Subject: [PATCH 3/3] simplify cell selection --- src/textual/_compositor.py | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 70fea3d7da..c4e240e2e9 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -926,12 +926,11 @@ def get_widget_and_offset_at( offset_y: int | None = None offset_x = 0 offset_x2 = 0 - cell_position = 0 from rich.cells import get_character_cell_size for segment in line: - end += len(segment.text) + end += segment.cell_length style = segment.style if style is not None and style._meta is not None: meta = style.meta @@ -940,25 +939,19 @@ def get_widget_and_offset_at( offset_x2 = offset_x + len(segment.text) if x < end and x >= start: - if x == end - 1: - segment_offset = len(segment.text) - else: - segment_cell_length = 0 - cell_cut = x - cell_position - segment_offset = 0 - for character in segment.text: - if segment_cell_length >= cell_cut: - break - segment_cell_length += get_character_cell_size( - character - ) - segment_offset += 1 + segment_cell_length = 0 + cell_cut = x - start + segment_offset = 0 + for character in segment.text: + if segment_cell_length >= cell_cut: + 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) ) - cell_position += cell_len(segment.text) start = end return widget, (None if offset_y is None else Offset(offset_x2, offset_y))