diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index f35e22f1c4..546ab1fe0a 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -820,7 +820,7 @@ def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]: contains = Region.contains if len(self.layers_visible) > y >= 0: - for widget, cropped_region, region in self.layers_visible[y]: + for widget, cropped_region, region in self.layers_visible[int(y)]: if contains(cropped_region, x, y) and widget.visible: return widget, region raise errors.NoWidget(f"No widget under screen coordinate ({x}, {y})") diff --git a/src/textual/_xterm_parser.py b/src/textual/_xterm_parser.py index 6382413c77..5331c3742b 100644 --- a/src/textual/_xterm_parser.py +++ b/src/textual/_xterm_parser.py @@ -50,8 +50,11 @@ class XTermParser(Parser[Message]): _re_sgr_mouse = re.compile(r"\x1b\[<(\d+);(\d+);(\d+)([Mm])") def __init__(self, debug: bool = False) -> None: - self.last_x = 0 - self.last_y = 0 + self.last_x = 0.0 + self.last_y = 0.0 + self.mouse_pixels = False + self.terminal_size: tuple[int, int] | None = None + self.terminal_pixel_size: tuple[int, int] | None = None self._debug_log_file = open("keys.log", "at") if debug else None super().__init__() self.debug_log("---") @@ -70,8 +73,18 @@ def parse_mouse_code(self, code: str) -> Message | None: if sgr_match: _buttons, _x, _y, state = sgr_match.groups() buttons = int(_buttons) - x = int(_x) - 1 - y = int(_y) - 1 + x = float(int(_x) - 1) + y = float(int(_y) - 1) + if ( + self.mouse_pixels + and self.terminal_pixel_size is not None + and self.terminal_size is not None + ): + x_ratio = self.terminal_pixel_size[0] / self.terminal_size[0] + y_ratio = self.terminal_pixel_size[1] / self.terminal_size[1] + x /= x_ratio + y /= y_ratio + delta_x = x - self.last_x delta_y = y - self.last_y self.last_x = x @@ -120,6 +133,9 @@ def parse( def on_token(token: Message) -> None: """Hook to log events.""" self.debug_log(str(token)) + if isinstance(token, events.Resize): + self.terminal_size = token.size + self.terminal_pixel_size = token.pixel_size token_callback(token) def on_key_token(event: events.Key) -> None: @@ -228,6 +244,10 @@ def send_escape() -> None: (int(width), int(height)), (int(pixel_width), int(pixel_height)), ) + + self.terminal_size = resize_event.size + self.terminal_pixel_size = resize_event.pixel_size + self.mouse_pixels = True on_token(resize_event) break @@ -268,7 +288,6 @@ def send_escape() -> None: if mode_id == "2026" and setting_parameter > 0: on_token(messages.TerminalSupportsSynchronizedOutput()) elif mode_id == "2048" and not IS_ITERM: - # TODO: remove "and not IS_ITERM" when https://gitlab.com/gnachman/iterm2/-/issues/11961 is fixed in_band_event = messages.TerminalSupportInBandWindowResize.from_setting_parameter( setting_parameter ) diff --git a/src/textual/driver.py b/src/textual/driver.py index 1fa57c406b..6197893413 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -89,10 +89,10 @@ def process_message(self, message: messages.Message) -> None: else: offset_x, offset_y = self.cursor_origin if isinstance(message, events.MouseEvent): - message.x -= offset_x - message.y -= offset_y - message.screen_x -= offset_x - message.screen_y -= offset_y + message._x -= offset_x + message._y -= offset_y + message._screen_x -= offset_x + message._screen_y -= offset_y if isinstance(message, events.MouseDown): if message.button: diff --git a/src/textual/drivers/linux_driver.py b/src/textual/drivers/linux_driver.py index 7e04f4cf26..6a70eff81e 100644 --- a/src/textual/drivers/linux_driver.py +++ b/src/textual/drivers/linux_driver.py @@ -62,6 +62,7 @@ def __init__( # keep track of this. self._must_signal_resume = False self._in_band_window_resize = False + self._mouse_pixels = False # Put handlers for SIGTSTP and SIGCONT in place. These are necessary # to support the user pressing Ctrl+Z (or whatever the dev might @@ -134,6 +135,12 @@ def _enable_mouse_support(self) -> None: # Note: E.g. lxterminal understands 1000h, but not the urxvt or sgr # extensions. + def _enable_mouse_pixels(self) -> None: + if not self._mouse: + return + self.write("\x1b[?1016h") + self._mouse_pixels = True + def _enable_bracketed_paste(self) -> None: """Enable bracketed paste mode.""" self.write("\x1b[?2004h") @@ -440,7 +447,7 @@ def process_selector_events( try: for event in feed(""): pass - except ParseError: + except (EOFError, ParseError): pass def process_message(self, message: Message) -> None: @@ -452,6 +459,7 @@ def process_message(self, message: Message) -> None: self._in_band_window_resize = message.supported elif message.enabled: self._in_band_window_resize = message.supported + self._enable_mouse_pixels() # Send up-to-date message super().process_message( TerminalSupportInBandWindowResize( diff --git a/src/textual/events.py b/src/textual/events.py index fd4dea3edd..6e67ac6d0d 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -343,44 +343,44 @@ class MouseEvent(InputEvent, bubble=True): __slots__ = [ "widget", - "x", - "y", - "delta_x", - "delta_y", + "_x", + "_y", + "_delta_x", + "_delta_y", "button", "shift", "meta", "ctrl", - "screen_x", - "screen_y", + "_screen_x", + "_screen_y", "_style", ] def __init__( self, widget: Widget | None, - x: int, - y: int, - delta_x: int, - delta_y: int, + x: float, + y: float, + delta_x: float, + delta_y: float, button: int, shift: bool, meta: bool, ctrl: bool, - screen_x: int | None = None, - screen_y: int | None = None, + screen_x: float | None = None, + screen_y: float | None = None, style: Style | None = None, ) -> None: super().__init__() self.widget: Widget | None = widget """The widget under the mouse at the time of a click.""" - self.x = x + self._x = x """The relative x coordinate.""" - self.y = y + self._y = y """The relative y coordinate.""" - self.delta_x = delta_x + self._delta_x = delta_x """Change in x since the last message.""" - self.delta_y = delta_y + self._delta_y = delta_y """Change in y since the last message.""" self.button = button """Indexed of the pressed button.""" @@ -390,42 +390,66 @@ def __init__( """`True` if the meta key is pressed.""" self.ctrl = ctrl """`True` if the ctrl key is pressed.""" - self.screen_x = x if screen_x is None else screen_x + self._screen_x = x if screen_x is None else screen_x """The absolute x coordinate.""" - self.screen_y = y if screen_y is None else screen_y + self._screen_y = y if screen_y is None else screen_y """The absolute y coordinate.""" self._style = style or Style() + @property + def x(self) -> int: + return int(self._x) + + @property + def y(self) -> int: + return int(self._y) + + @property + def delta_x(self) -> int: + return int(self._delta_x) + + @property + def delta_y(self) -> int: + return int(self._delta_y) + + @property + def screen_x(self) -> int: + return int(self._screen_x) + + @property + def screen_y(self) -> int: + return int(self._screen_y) + @classmethod def from_event( cls: Type[MouseEventT], widget: Widget, event: MouseEvent ) -> MouseEventT: new_event = cls( widget, - event.x, - event.y, - event.delta_x, - event.delta_y, + event._x, + event._y, + event._delta_x, + event._delta_y, event.button, event.shift, event.meta, event.ctrl, - event.screen_x, - event.screen_y, + event._screen_x, + event._screen_y, event._style, ) return new_event def __rich_repr__(self) -> rich.repr.Result: yield self.widget - yield "x", self.x - yield "y", self.y - yield "delta_x", self.delta_x, 0 - yield "delta_y", self.delta_y, 0 + yield "x", self._x + yield "y", self._y + yield "delta_x", self._delta_x, 0 + yield "delta_y", self._delta_y, 0 if self.screen_x != self.x: - yield "screen_x", self.screen_x + yield "screen_x", self._screen_x if self.screen_y != self.y: - yield "screen_y", self.screen_y + yield "screen_y", self._screen_y yield "button", self.button, 0 yield "shift", self.shift, False yield "meta", self.meta, False @@ -492,16 +516,16 @@ def get_content_offset_capture(self, widget: Widget) -> Offset: def _apply_offset(self, x: int, y: int) -> MouseEvent: return self.__class__( self.widget, - x=self.x + x, - y=self.y + y, - delta_x=self.delta_x, - delta_y=self.delta_y, + x=self._x + x, + y=self._y + y, + delta_x=self._delta_x, + delta_y=self._delta_y, button=self.button, shift=self.shift, meta=self.meta, ctrl=self.ctrl, - screen_x=self.screen_x, - screen_y=self.screen_y, + screen_x=self._screen_x, + screen_y=self._screen_y, style=self.style, ) diff --git a/src/textual/screen.py b/src/textual/screen.py index 8ec9fd30f6..5caf8d40b0 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -1381,16 +1381,16 @@ def _translate_mouse_move_event( """ return events.MouseMove( widget, - event.x - region.x, - event.y - region.y, - event.delta_x, - event.delta_y, + event._x - region.x, + event._y - region.y, + event._delta_x, + event._delta_y, event.button, event.shift, event.meta, event.ctrl, - screen_x=event.screen_x, - screen_y=event.screen_y, + screen_x=event._screen_x, + screen_y=event._screen_y, style=event.style, ) diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py index f905d76673..716be3e5ed 100644 --- a/src/textual/scroll_view.py +++ b/src/textual/scroll_view.py @@ -33,13 +33,13 @@ def is_scrollable(self) -> bool: return True def watch_scroll_x(self, old_value: float, new_value: float) -> None: - if self.show_horizontal_scrollbar and round(old_value) != round(new_value): - self.horizontal_scrollbar.position = round(new_value) + if self.show_horizontal_scrollbar and old_value != new_value: + self.horizontal_scrollbar.position = new_value self.refresh() def watch_scroll_y(self, old_value: float, new_value: float) -> None: - if self.show_vertical_scrollbar and round(old_value) != round(new_value): - self.vertical_scrollbar.position = round(new_value) + if self.show_vertical_scrollbar and (old_value) != (new_value): + self.vertical_scrollbar.position = new_value self.refresh() def on_mount(self): diff --git a/src/textual/scrollbar.py b/src/textual/scrollbar.py index 78aa968d00..eedbdf0a50 100644 --- a/src/textual/scrollbar.py +++ b/src/textual/scrollbar.py @@ -259,7 +259,7 @@ def __init__( window_virtual_size: Reactive[int] = Reactive(100) window_size: Reactive[int] = Reactive(0) - position: Reactive[int] = Reactive(0) + position: Reactive[float] = Reactive(0) mouse_over: Reactive[bool] = Reactive(False) grabbed: Reactive[Offset | None] = Reactive(None) @@ -363,22 +363,17 @@ async def _on_mouse_move(self, event: events.MouseMove) -> None: y: float | None = None if self.vertical: virtual_size = self.window_virtual_size - y = round( - self.grabbed_position - + ( - (event.screen_y - self.grabbed.y) - * (virtual_size / self.window_size) - ) + y = self.grabbed_position + ( + (event._screen_y - self.grabbed.y) + * (virtual_size / self.window_size) ) else: virtual_size = self.window_virtual_size - x = round( - self.grabbed_position - + ( - (event.screen_x - self.grabbed.x) - * (virtual_size / self.window_size) - ) + x = self.grabbed_position + ( + (event._screen_x - self.grabbed.x) + * (virtual_size / self.window_size) ) + print(event) self.post_message(ScrollTo(x=x, y=y)) event.stop() diff --git a/src/textual/widget.py b/src/textual/widget.py index a543aee836..537060d479 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1662,12 +1662,12 @@ def watch_hover_style( def watch_scroll_x(self, old_value: float, new_value: float) -> None: self.horizontal_scrollbar.position = round(new_value) - if round(old_value) != round(new_value): + if (old_value) != (new_value): self._refresh_scroll() def watch_scroll_y(self, old_value: float, new_value: float) -> None: self.vertical_scrollbar.position = round(new_value) - if round(old_value) != round(new_value): + if (old_value) != (new_value): self._refresh_scroll() def validate_scroll_x(self, value: float) -> float: diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_click_expand.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_click_expand.svg new file mode 100644 index 0000000000..2a2661a62b --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_click_expand.svg @@ -0,0 +1,158 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SelectApp + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +15 +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ +▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + 6                                                                         + 7                                                                         + 8                                                                         + 9                                                                        ▆▆ + 10                                                                        + 11                                                                        + 12                                                                        + 13                                                                        + 14                                                                       ▇▇ + 15                                                                        +▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + + + + + + + +