diff --git a/CHANGELOG.md b/CHANGELOG.md index 428b546f8c..105179dc02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,12 +11,15 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `Pilot.click`/`Pilot.hover` can't use `Screen` as a selector https://github.com/Textualize/textual/issues/3395 - App exception when a `Tree` is initialized/mounted with `disabled=True` https://github.com/Textualize/textual/issues/3407 +- Fix location of IME and emoji popups https://github.com/Textualize/textual/pull/3408 - Fixed application freeze when pasting an emoji into an application on Windows https://github.com/Textualize/textual/issues/3178 - Fixed duplicate option ID handling in the `OptionList` https://github.com/Textualize/textual/issues/3455 ### 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 - Reactive `cell_padding` (and respective parameter) to define horizontal cell padding in data table columns https://github.com/Textualize/textual/issues/3435 - Added `Input.clear` method https://github.com/Textualize/textual/pull/3430 - Added `TextArea.SelectionChanged` and `TextArea.Changed` messages https://github.com/Textualize/textual/pull/3442 diff --git a/src/textual/app.py b/src/textual/app.py index 11baaf7b7c..312daf7415 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 @@ -418,6 +419,12 @@ def __init__( 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, or None if the app is still running with no unhandled exceptions.""" @@ -2424,7 +2431,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) @@ -2434,7 +2445,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 c7603297ea..c21afb690e 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 diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index 2169ea9cc0..daeee49e94 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -410,6 +410,7 @@ 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 self.post_message(self.SelectionChanged(selection, self)) def find_matching_bracket( @@ -660,7 +661,14 @@ 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 _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. @@ -1043,6 +1051,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'.""" @@ -1257,6 +1266,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.""" 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) diff --git a/tests/text_area/test_selection.py b/tests/text_area/test_selection.py index d089aecc0f..bbc70e476e 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,41 @@ 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) + + +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) 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