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

WIP Smooth scroll #5260

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/textual/_compositor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})")
Expand Down
29 changes: 24 additions & 5 deletions src/textual/_xterm_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("---")
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
)
Expand Down
8 changes: 4 additions & 4 deletions src/textual/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 9 additions & 1 deletion src/textual/drivers/linux_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand All @@ -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(
Expand Down
96 changes: 60 additions & 36 deletions src/textual/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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
Expand Down Expand Up @@ -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,
)

Expand Down
12 changes: 6 additions & 6 deletions src/textual/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand Down
17 changes: 7 additions & 10 deletions src/textual/scroll_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -82,10 +82,8 @@ def _size_updated(
layout: Perform layout if required.

Returns:
True if anything changed, or False if nothing changed.
True if a resize event should be sent, otherwise False.
"""
if self._size != size or self._container_size != container_size:
self.refresh()
if (
self._size != size
or virtual_size != self.virtual_size
Expand All @@ -96,9 +94,8 @@ def _size_updated(
virtual_size = self.virtual_size
self._container_size = size - self.styles.gutter.totals
self._scroll_update(virtual_size)
return True
else:
return False

return self._size != size or self._container_size != container_size

def render(self) -> RenderableType:
"""Render the scrollable region (if `render_lines` is not implemented).
Expand Down
21 changes: 8 additions & 13 deletions src/textual/scrollbar.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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()

Expand Down
Loading
Loading