From d727627edd13b153b6b0ed002281020cf196b483 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 26 Sep 2023 16:54:00 +0100 Subject: [PATCH 1/9] Fixing IME alignment for Input widget. TextArea remains unfixed. --- src/textual/app.py | 8 ++++++++ src/textual/screen.py | 2 +- src/textual/widgets/_input.py | 12 +++++++++++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 48fd36188a..8ad739e3e1 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -53,6 +53,7 @@ import rich.repr from rich import terminal_theme from rich.console import Console, RenderableType +from rich.control import Control from rich.protocol import is_renderable from rich.segment import Segment, Segments from rich.traceback import Traceback @@ -417,6 +418,7 @@ def __init__( self._animator = Animator(self) self._animate = self._animator.bind(self) self.mouse_position = Offset(0, 0) + self.cursor_position = Offset(0, 0) self._exception: Exception | None = None """The unhandled exception which is leading to the app shutting down, @@ -2423,7 +2425,11 @@ def _display(self, screen: Screen, renderable: RenderableType | None) -> None: try: try: if isinstance(renderable, CompositorUpdate): + cursor_x, cursor_y = self.cursor_position terminal_sequence = renderable.render_segments(console) + terminal_sequence += Control.move_to( + cursor_x, cursor_y + ).segment.text else: segments = console.render(renderable) terminal_sequence = console._render_buffer(segments) @@ -2433,7 +2439,9 @@ def _display(self, screen: Screen, renderable: RenderableType | None) -> None: self._driver.write(terminal_sequence) finally: self._end_update() + self._driver.flush() + finally: self.post_display_hook() diff --git a/src/textual/screen.py b/src/textual/screen.py index 631dadb4ba..c5404bc05f 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -515,7 +515,7 @@ def _reset_focus( chosen = candidate break - # Go with the what was found. + # Go with what was found. self.set_focus(chosen) def _update_focus_styles( diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py index b92161e504..59c3c09c95 100644 --- a/src/textual/widgets/_input.py +++ b/src/textual/widgets/_input.py @@ -15,7 +15,7 @@ from .._segment_tools import line_crop from ..binding import Binding, BindingType from ..events import Blur, Focus, Mount -from ..geometry import Size +from ..geometry import Offset, Size from ..message import Message from ..reactive import reactive from ..suggester import Suggester, SuggestionReady @@ -254,6 +254,7 @@ def __init__( super().__init__(name=name, id=id, classes=classes, disabled=disabled) if value is not None: self.value = value + self.placeholder = placeholder self.highlighter = highlighter self.password = password @@ -327,6 +328,14 @@ def _watch_cursor_position(self) -> None: else: self.view_position = self.view_position + self.app.cursor_position = self.cursor_screen_offset + + @property + def cursor_screen_offset(self) -> Offset: + """The offset of the cursor of this input in screen-space. (x, y)/(column, row)""" + x, y, _width, _height = self.content_region + return Offset(x + self._cursor_offset - self.view_position, y) + async def _watch_value(self, value: str) -> None: self._suggestion = "" if self.suggester and value: @@ -425,6 +434,7 @@ def _on_focus(self, _: Focus) -> None: self.cursor_position = len(self.value) if self.cursor_blink: self.blink_timer.resume() + self.app.cursor_position = self.cursor_screen_offset async def _on_key(self, event: events.Key) -> None: self._cursor_visible = True From 2efdc9c67a35af7adfce7582672cae2fd433e664 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 27 Sep 2023 12:32:16 +0100 Subject: [PATCH 2/9] Fix TextArea IME --- src/textual/widgets/_text_area.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index e76c67b70c..bd679de83b 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -377,6 +377,8 @@ def _watch_selection(self, selection: Selection) -> None: if match_row in range(*self._visible_line_indices): self.refresh_lines(match_row) + self.app.cursor_position = self.cursor_screen_offset + def find_matching_bracket( self, bracket: str, search_from: Location ) -> Location | None: @@ -625,7 +627,8 @@ def _visible_line_indices(self) -> tuple[int, int]: Returns: A tuple (top, bottom) indicating the top and bottom visible line indices. """ - return self.scroll_offset.y, self.scroll_offset.y + self.size.height + _, scroll_offset_y = self.scroll_offset + return scroll_offset_y, scroll_offset_y + self.size.height def load_text(self, text: str) -> None: """Load text into the TextArea. @@ -1007,6 +1010,7 @@ def _on_blur(self, _: events.Blur) -> None: def _on_focus(self, _: events.Focus) -> None: self._restart_blink() + self.app.cursor_position = self.cursor_screen_offset def _toggle_cursor_blink_visible(self) -> None: """Toggle visibility of the cursor for the purposes of 'cursor blink'.""" @@ -1221,6 +1225,23 @@ def cursor_location(self, location: Location) -> None: """ self.move_cursor(location, select=not self.selection.is_empty) + @property + def cursor_screen_offset(self) -> Offset: + """The offset of the cursor relative to the screen.""" + cursor_row, cursor_column = self.cursor_location + scroll_x, scroll_y = self.scroll_offset + region_x, region_y, width, height = self.content_region + + offset_x = ( + region_x + + self.get_column_width(cursor_row, cursor_column) + - scroll_x + + self.gutter_width + ) + offset_y = region_y + cursor_row - scroll_y + + return Offset(offset_x, offset_y) + @property def cursor_at_first_line(self) -> bool: """True if and only if the cursor is on the first line.""" From f12d0237c81166660a53038addbde6640f3a1e63 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 27 Sep 2023 12:35:20 +0100 Subject: [PATCH 3/9] Prefix unused unpacked variables with underscore --- src/textual/widgets/_text_area.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index bd679de83b..cff0f99532 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -1230,7 +1230,7 @@ def cursor_screen_offset(self) -> Offset: """The offset of the cursor relative to the screen.""" cursor_row, cursor_column = self.cursor_location scroll_x, scroll_y = self.scroll_offset - region_x, region_y, width, height = self.content_region + region_x, region_y, _width, _height = self.content_region offset_x = ( region_x From cc539fcbd57fddc278edcd32d9f76c004d59ee8b Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 27 Sep 2023 12:42:54 +0100 Subject: [PATCH 4/9] Updating IME preview location on scrolling in TextArea --- src/textual/widgets/_text_area.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index cff0f99532..acb94b8012 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -630,6 +630,12 @@ def _visible_line_indices(self) -> tuple[int, int]: _, scroll_offset_y = self.scroll_offset return scroll_offset_y, scroll_offset_y + self.size.height + def _watch_scroll_x(self) -> None: + self.app.cursor_position = self.cursor_screen_offset + + def _watch_scroll_y(self) -> None: + self.app.cursor_position = self.cursor_screen_offset + def load_text(self, text: str) -> None: """Load text into the TextArea. From c3f253d5eb955b051a5f86d40c653a9661dff3ef Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 27 Sep 2023 13:08:03 +0100 Subject: [PATCH 5/9] Add CHANGELOG entry for IME positioning fix --- CHANGELOG.md | 1 + src/textual/app.py | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 330dac5bf7..47c5c015f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Fixed - `Pilot.click`/`Pilot.hover` can't use `Screen` as a selector https://github.com/Textualize/textual/issues/3395 +- Fix location of IME and emoji popups https://github.com/Textualize/textual/pull/3408 ### Added diff --git a/src/textual/app.py b/src/textual/app.py index 8ad739e3e1..fcee9ad8fb 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -418,7 +418,12 @@ def __init__( self._animator = Animator(self) self._animate = self._animator.bind(self) self.mouse_position = Offset(0, 0) + self.cursor_position = Offset(0, 0) + """The position of the terminal cursor in screen-space. + + This can be set by widgets and is useful for controlling the + positioning of OS IME and emoji popup menus.""" self._exception: Exception | None = None """The unhandled exception which is leading to the app shutting down, From 1acdd5174184195d4f2e6ce78e34749dbd3b72e2 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 27 Sep 2023 13:12:55 +0100 Subject: [PATCH 6/9] Add CHANGELOG entry for new methods on Input and TextArea --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3aacf820b4..8a15559865 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - `OutOfBounds` exception to be raised by `Pilot` https://github.com/Textualize/textual/pull/3360 +- `TextArea.cursor_screen_offset` property for getting the screen-relative position of the cursor https://github.com/Textualize/textual/pull/3408 +- `Input.cursor_screen_offset` property for getting the screen-relative position of the cursor https://github.com/Textualize/textual/pull/3408 ### Changed From 6dae86f5ad99a730839565ff88e823ab2b454d24 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 27 Sep 2023 14:04:15 +0100 Subject: [PATCH 7/9] Test TextArea terminal cursor position update --- tests/text_area/test_selection.py | 28 ++++++++++++++++++++++--- tests/text_area/test_text_area_theme.py | 0 2 files changed, 25 insertions(+), 3 deletions(-) delete mode 100644 tests/text_area/test_text_area_theme.py diff --git a/tests/text_area/test_selection.py b/tests/text_area/test_selection.py index d089aecc0f..dc983f7a15 100644 --- a/tests/text_area/test_selection.py +++ b/tests/text_area/test_selection.py @@ -18,10 +18,12 @@ def compose(self) -> ComposeResult: yield text_area -def test_default_selection(): +async def test_default_selection(): """The cursor starts at (0, 0) in the document.""" - text_area = TextArea() - assert text_area.selection == Selection.cursor((0, 0)) + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + assert text_area.selection == Selection.cursor((0, 0)) async def test_cursor_location_get(): @@ -294,3 +296,23 @@ async def test_select_line(index, content, expected_selection): text_area.select_line(index) assert text_area.selection == expected_selection + + +async def test_cursor_screen_offset_and_terminal_cursor_position_update(): + class TextAreaCursorScreenOffset(App): + def compose(self) -> ComposeResult: + yield TextArea("abc\ndef") + + app = TextAreaCursorScreenOffset() + async with app.run_test(): + text_area = app.query_one(TextArea) + + assert app.cursor_position == (3, 0) + + text_area.cursor_location = (1, 1) + + assert text_area.cursor_screen_offset == (4, 1) + + # Also ensure that this update has been reported back to the app + # for the benefit of IME/emoji popups. + assert app.cursor_position == (4, 1) diff --git a/tests/text_area/test_text_area_theme.py b/tests/text_area/test_text_area_theme.py deleted file mode 100644 index e69de29bb2..0000000000 From f196665d15717542d6a130b79d2d07110f93cc2d Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 27 Sep 2023 14:47:17 +0100 Subject: [PATCH 8/9] Tests for Input widget terminal cursor position updating --- tests/input/test_input_terminal_cursor.py | 28 +++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 tests/input/test_input_terminal_cursor.py diff --git a/tests/input/test_input_terminal_cursor.py b/tests/input/test_input_terminal_cursor.py new file mode 100644 index 0000000000..b956a29846 --- /dev/null +++ b/tests/input/test_input_terminal_cursor.py @@ -0,0 +1,28 @@ +from textual.app import App, ComposeResult +from textual.geometry import Offset +from textual.widgets import Input + + +class InputApp(App): + # Apply padding to ensure gutter accounted for. + CSS = "Input { padding: 4 8 }" + + def compose(self) -> ComposeResult: + yield Input("こんにちは!") + + +async def test_initial_terminal_cursor_position(): + app = InputApp() + async with app.run_test(): + # The input is focused so the terminal cursor position should update. + assert app.cursor_position == Offset(21, 5) + + +async def test_terminal_cursor_position_update_on_cursor_move(): + app = InputApp() + async with app.run_test(): + input_widget = app.query_one(Input) + input_widget.action_cursor_left() + input_widget.action_cursor_left() + # We went left over two double-width characters + assert app.cursor_position == Offset(17, 5) From 25c9d44fd5f0c9126c9c7c762db4636c555ca312 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 3 Oct 2023 17:34:44 +0100 Subject: [PATCH 9/9] Test for IME when content scrolled --- tests/text_area/test_selection.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/text_area/test_selection.py b/tests/text_area/test_selection.py index dc983f7a15..bbc70e476e 100644 --- a/tests/text_area/test_selection.py +++ b/tests/text_area/test_selection.py @@ -316,3 +316,21 @@ def compose(self) -> ComposeResult: # Also ensure that this update has been reported back to the app # for the benefit of IME/emoji popups. assert app.cursor_position == (4, 1) + + +async def test_cursor_screen_offset_and_terminal_cursor_position_scrolling(): + class TextAreaCursorScreenOffset(App): + def compose(self) -> ComposeResult: + yield TextArea("AB\nAB\nAB\nAB\nAB\nAB\n") + + app = TextAreaCursorScreenOffset() + async with app.run_test(size=(80, 2)) as pilot: + text_area = app.query_one(TextArea) + + assert app.cursor_position == (3, 0) + + text_area.cursor_location = (5, 0) + await pilot.pause() + + assert text_area.cursor_screen_offset == (3, 1) + assert app.cursor_position == (3, 1)