Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix IME pop-up issues #3408

Merged
merged 12 commits into from
Oct 5, 2023
Merged
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,14 @@ 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

### 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:
rodrigogiraoserrao marked this conversation as resolved.
Show resolved Hide resolved
"""The offset of the cursor of this input in screen-space. (x, y)/(column, row)"""
x, y, _width, _height = self.content_region
darrenburns marked this conversation as resolved.
Show resolved Hide resolved
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.
Loading