|
10 | 10 | import weakref |
11 | 11 | from typing import Callable, Dict, Iterable, List, NoReturn, Pattern, cast |
12 | 12 |
|
| 13 | +import wcwidth |
| 14 | + |
13 | 15 | from .clipboard import ClipboardData |
14 | 16 | from .filters import vi_mode |
15 | 17 | from .selection import PasteMode, SelectionState, SelectionType |
@@ -158,13 +160,49 @@ def selection(self) -> SelectionState | None: |
158 | 160 |
|
159 | 161 | @property |
160 | 162 | def current_char(self) -> str: |
161 | | - """Return character under cursor or an empty string.""" |
162 | | - return self._get_char_relative_to_cursor(0) or "" |
| 163 | + """ |
| 164 | + Return grapheme cluster at cursor position, or empty string at end. |
| 165 | +
|
| 166 | + Note: Returns a grapheme cluster which may contain multiple code points. |
| 167 | + If cursor is inside a grapheme cluster (e.g., on a combining character), |
| 168 | + returns the complete grapheme containing the cursor. |
| 169 | + """ |
| 170 | + if self.cursor_position >= len(self.text): |
| 171 | + return "" |
| 172 | + grapheme_start = wcwidth.grapheme_boundary_before( |
| 173 | + self.text, self.cursor_position + 1 |
| 174 | + ) |
| 175 | + for g in wcwidth.iter_graphemes(self.text[grapheme_start:]): |
| 176 | + return g |
| 177 | + return "" |
163 | 178 |
|
164 | 179 | @property |
165 | 180 | def char_before_cursor(self) -> str: |
166 | | - """Return character before the cursor or an empty string.""" |
167 | | - return self._get_char_relative_to_cursor(-1) or "" |
| 181 | + """ |
| 182 | + Return grapheme cluster before the cursor, or empty string at start. |
| 183 | +
|
| 184 | + Note: Returns a grapheme cluster which may contain multiple code points. |
| 185 | + If cursor is inside a grapheme cluster (e.g., on a combining character), |
| 186 | + returns the grapheme before the one containing the cursor. |
| 187 | + """ |
| 188 | + if self.cursor_position == 0: |
| 189 | + return "" |
| 190 | + |
| 191 | + text = self.text |
| 192 | + cursor = self.cursor_position |
| 193 | + |
| 194 | + # Find reference point: cursor position or start of containing grapheme. |
| 195 | + if cursor >= len(text): |
| 196 | + reference = len(text) |
| 197 | + else: |
| 198 | + grapheme_start = wcwidth.grapheme_boundary_before(text, cursor + 1) |
| 199 | + reference = grapheme_start if grapheme_start < cursor else cursor |
| 200 | + |
| 201 | + if reference == 0: |
| 202 | + return "" |
| 203 | + |
| 204 | + prev_start = wcwidth.grapheme_boundary_before(text, reference) |
| 205 | + return text[prev_start:reference] |
168 | 206 |
|
169 | 207 | @property |
170 | 208 | def text_before_cursor(self) -> str: |
@@ -251,15 +289,6 @@ def leading_whitespace_in_current_line(self) -> str: |
251 | 289 | length = len(current_line) - len(current_line.lstrip()) |
252 | 290 | return current_line[:length] |
253 | 291 |
|
254 | | - def _get_char_relative_to_cursor(self, offset: int = 0) -> str: |
255 | | - """ |
256 | | - Return character relative to cursor position, or empty string |
257 | | - """ |
258 | | - try: |
259 | | - return self.text[self.cursor_position + offset] |
260 | | - except IndexError: |
261 | | - return "" |
262 | | - |
263 | 292 | @property |
264 | 293 | def on_first_line(self) -> bool: |
265 | 294 | """ |
@@ -692,21 +721,44 @@ def find_previous_matching_line( |
692 | 721 |
|
693 | 722 | def get_cursor_left_position(self, count: int = 1) -> int: |
694 | 723 | """ |
695 | | - Relative position for cursor left. |
| 724 | + Relative position for cursor left (grapheme cluster aware). |
696 | 725 | """ |
697 | 726 | if count < 0: |
698 | 727 | return self.get_cursor_right_position(-count) |
699 | 728 |
|
700 | | - return -min(self.cursor_position_col, count) |
| 729 | + line_before = self.current_line_before_cursor |
| 730 | + if not line_before: |
| 731 | + return 0 |
| 732 | + |
| 733 | + pos = len(line_before) |
| 734 | + for _ in range(count): |
| 735 | + if pos <= 0: |
| 736 | + break |
| 737 | + new_pos = wcwidth.grapheme_boundary_before(line_before, pos) |
| 738 | + if new_pos == pos: |
| 739 | + break |
| 740 | + pos = new_pos |
| 741 | + |
| 742 | + return pos - len(line_before) |
701 | 743 |
|
702 | 744 | def get_cursor_right_position(self, count: int = 1) -> int: |
703 | 745 | """ |
704 | | - Relative position for cursor_right. |
| 746 | + Relative position for cursor right (grapheme cluster aware). |
705 | 747 | """ |
706 | 748 | if count < 0: |
707 | 749 | return self.get_cursor_left_position(-count) |
708 | 750 |
|
709 | | - return min(count, len(self.current_line_after_cursor)) |
| 751 | + line_after = self.current_line_after_cursor |
| 752 | + if not line_after: |
| 753 | + return 0 |
| 754 | + |
| 755 | + pos = 0 |
| 756 | + for i, grapheme in enumerate(wcwidth.iter_graphemes(line_after)): |
| 757 | + if i >= count: |
| 758 | + break |
| 759 | + pos += len(grapheme) |
| 760 | + |
| 761 | + return pos |
710 | 762 |
|
711 | 763 | def get_cursor_up_position( |
712 | 764 | self, count: int = 1, preferred_column: int | None = None |
|
0 commit comments