Skip to content

Commit

Permalink
Fix IME pop-up issues (#3408)
Browse files Browse the repository at this point in the history
* Fixing IME alignment for Input widget. TextArea remains unfixed.

* Fix TextArea IME

* Prefix unused unpacked variables with underscore

* Updating IME preview location on scrolling in TextArea

* Add CHANGELOG entry for IME positioning fix

* Add CHANGELOG entry for new methods on Input and TextArea

* Test TextArea terminal cursor position update

* Tests for Input widget terminal cursor position updating

* Test for IME when content scrolled
  • Loading branch information
darrenburns authored Oct 5, 2023
1 parent 0a1fc69 commit 864d671
Show file tree
Hide file tree
Showing 8 changed files with 126 additions and 6 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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)
Expand All @@ -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()

Expand Down
2 changes: 1 addition & 1 deletion src/textual/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
12 changes: 11 additions & 1 deletion src/textual/widgets/_input.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
28 changes: 27 additions & 1 deletion src/textual/widgets/_text_area.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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'."""
Expand Down Expand Up @@ -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."""
Expand Down
28 changes: 28 additions & 0 deletions tests/input/test_input_terminal_cursor.py
Original file line number Diff line number Diff line change
@@ -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)
46 changes: 43 additions & 3 deletions tests/text_area/test_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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)
Empty file.

0 comments on commit 864d671

Please sign in to comment.