Skip to content

Commit 99ca89e

Browse files
authored
Merge pull request #5260 from Textualize/smooth-scroll
WIP Smooth scroll
2 parents 35ba217 + f04da02 commit 99ca89e

File tree

17 files changed

+210
-228
lines changed

17 files changed

+210
-228
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
4545
### Changed
4646

4747
- OptionList no longer supports `Separator`, a separator may be specified with `None`
48+
- Implemented smooth (pixel perfect) scrolling on supported terminals. Set `TEXTUAL_SMOOTH_SCROLL=0` to disable.
4849

4950
### Removed
5051

134 KB
Loading
294 KB
Loading
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
---
2+
draft: false
3+
date: 2025-02-16
4+
categories:
5+
- DevLog
6+
authors:
7+
- willmcgugan
8+
---
9+
10+
# Smoother scrolling in the terminal — a feature decades in the making
11+
12+
The great philosopher F. Bueller once said “Life moves pretty fast. If you don't stop and look around once in a while, you could miss it.”
13+
14+
Beuller was *not* taking about terminals, which tend not to move very fast at all.
15+
Until they do.
16+
From time to time terminals acquire new abilities after a long plateau.
17+
We are now seeing a kind of punctuated evolution in terminals which makes things possible that just weren't feasible a short time ago.
18+
19+
I want to talk about one such feature, which *I believe* has been decades[^1] in the making.
20+
Take a look at the following screen recording (taken from a TUI running in the terminal):
21+
22+
![A TUI Scrollbar](../images/smooth-scroll/no-smooth-scroll.gif)
23+
24+
<!-- more -->
25+
26+
Note how the mouse pointer moves relatively smoothly, but the scrollbar jumps with a jerky motion.
27+
28+
This happens because the terminal reports the mouse coordinates in cells (a *cell* is the dimensions of a single latin-alphabet character).
29+
In other words, the app knows only which cell is under the pointer.
30+
It isn't granular enough to know where the pointer is *within* a cell.
31+
32+
Until recently terminal apps couldn't do any better.
33+
More granular mouse reporting is possible in the terminal; write the required escape sequence and mouse coordinates are reported in pixels rather than cells.
34+
So why haven't TUIs been using this?
35+
36+
The problem is that we can't translate between pixel coordinates and cell coordinates without first knowing how many pixels are in a cell.
37+
And in order to know that, we need to know the width and height of the terminal in *pixels*.
38+
Unfortunately, that standard way to get the terminal size reports just cells.
39+
40+
At least they didn't before [this extension](https://gist.github.com/rockorager/e695fb2924d36b2bcf1fff4a3704bd83) which reports the size of the terminal in cell *and* pixel coordinates.
41+
Once we have both the mouse coordinates in pixels and the dimensions of the terminal in pixels, we can implement much smoother scrolling.
42+
43+
Let's see how this looks.
44+
45+
On the right we have smooth scrolling enabled, on the left is the default non-smooth scrolling:
46+
47+
48+
| Default scrolling | Smooth scrolling |
49+
| ---------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
50+
| ![A TUI Scrollbar](../images/smooth-scroll/no-smooth-scroll.gif) | ![A TUI Scrollbar with smooth scrolling](../images/smooth-scroll/smooth-scroll.gif) |
51+
52+
Notice how much smoother the motion of the table is, now that it tracks the mouse cursor more accurately.
53+
If you move the scrollbar quickly, you may not notice the difference.
54+
But if you move slowly like you are searching for something, it is a respectable quality of life improvement.
55+
56+
If you have one of the terminals which support this feature[^2], and at least [Textual](https://github.com/textualize/textual/) 2.0.0 you will be able to see this in action.
57+
58+
I think Textual may be the first library to implement this.
59+
Let me know, if you have encountered any non-Textual TUI app which implements this kind of smooth scrolling.
60+
61+
## Join us
62+
63+
Join our [Discord server](https://discord.gg/Enf6Z3qhVr) to discuss anything terminal related with the Textualize devs, or the community!
64+
65+
66+
[^1]: I'm not sure exactly when pixel mouse reporting was added to terminals. I'd be interested if anyone has a precised date.
67+
[^2]: Kitty, Ghostty, and a few others.

src/textual/_compositor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -820,7 +820,7 @@ def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]:
820820

821821
contains = Region.contains
822822
if len(self.layers_visible) > y >= 0:
823-
for widget, cropped_region, region in self.layers_visible[y]:
823+
for widget, cropped_region, region in self.layers_visible[int(y)]:
824824
if contains(cropped_region, x, y) and widget.visible:
825825
return widget, region
826826
raise errors.NoWidget(f"No widget under screen coordinate ({x}, {y})")

src/textual/_xterm_parser.py

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,11 @@ class XTermParser(Parser[Message]):
5050
_re_sgr_mouse = re.compile(r"\x1b\[<(\d+);(\d+);(\d+)([Mm])")
5151

5252
def __init__(self, debug: bool = False) -> None:
53-
self.last_x = 0
54-
self.last_y = 0
53+
self.last_x = 0.0
54+
self.last_y = 0.0
55+
self.mouse_pixels = False
56+
self.terminal_size: tuple[int, int] | None = None
57+
self.terminal_pixel_size: tuple[int, int] | None = None
5558
self._debug_log_file = open("keys.log", "at") if debug else None
5659
super().__init__()
5760
self.debug_log("---")
@@ -70,8 +73,18 @@ def parse_mouse_code(self, code: str) -> Message | None:
7073
if sgr_match:
7174
_buttons, _x, _y, state = sgr_match.groups()
7275
buttons = int(_buttons)
73-
x = int(_x) - 1
74-
y = int(_y) - 1
76+
x = float(int(_x) - 1)
77+
y = float(int(_y) - 1)
78+
if (
79+
self.mouse_pixels
80+
and self.terminal_pixel_size is not None
81+
and self.terminal_size is not None
82+
):
83+
x_ratio = self.terminal_pixel_size[0] / self.terminal_size[0]
84+
y_ratio = self.terminal_pixel_size[1] / self.terminal_size[1]
85+
x /= x_ratio
86+
y /= y_ratio
87+
7588
delta_x = x - self.last_x
7689
delta_y = y - self.last_y
7790
self.last_x = x
@@ -120,6 +133,9 @@ def parse(
120133
def on_token(token: Message) -> None:
121134
"""Hook to log events."""
122135
self.debug_log(str(token))
136+
if isinstance(token, events.Resize):
137+
self.terminal_size = token.size
138+
self.terminal_pixel_size = token.pixel_size
123139
token_callback(token)
124140

125141
def on_key_token(event: events.Key) -> None:
@@ -228,6 +244,10 @@ def send_escape() -> None:
228244
(int(width), int(height)),
229245
(int(pixel_width), int(pixel_height)),
230246
)
247+
248+
self.terminal_size = resize_event.size
249+
self.terminal_pixel_size = resize_event.pixel_size
250+
self.mouse_pixels = True
231251
on_token(resize_event)
232252
break
233253

@@ -267,8 +287,12 @@ def send_escape() -> None:
267287
setting_parameter = int(mode_report_match["setting_parameter"])
268288
if mode_id == "2026" and setting_parameter > 0:
269289
on_token(messages.TerminalSupportsSynchronizedOutput())
270-
elif mode_id == "2048" and not IS_ITERM:
271-
# TODO: remove "and not IS_ITERM" when https://gitlab.com/gnachman/iterm2/-/issues/11961 is fixed
290+
elif (
291+
mode_id == "2048"
292+
and constants.SMOOTH_SCROLL
293+
and not IS_ITERM
294+
):
295+
# TODO: iTerm is buggy in one or more of the protocols required here
272296
in_band_event = messages.TerminalSupportInBandWindowResize.from_setting_parameter(
273297
setting_parameter
274298
)

src/textual/app.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,9 @@ def __init__(
795795
self._clipboard: str = ""
796796
"""Contents of local clipboard."""
797797

798+
self.supports_smooth_scrolling: bool = True
799+
"""Does the terminal support smooth scrolling?"""
800+
798801
if self.ENABLE_COMMAND_PALETTE:
799802
for _key, binding in self._bindings:
800803
if binding.action in {"command_palette", "app.command_palette"}:
@@ -4632,6 +4635,7 @@ def _on_terminal_supports_in_band_window_resize(
46324635
"""There isn't much we can do with this information currently, so
46334636
we will just log it.
46344637
"""
4638+
self.supports_smooth_scrolling = True
46354639
self.log.debug(message)
46364640

46374641
def _on_idle(self) -> None:

src/textual/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,3 +157,7 @@ def _get_textual_animations() -> AnimationLevel:
157157
"""Textual theme to make default. More than one theme may be specified in a comma separated list.
158158
Textual will use the first theme that exists.
159159
"""
160+
161+
SMOOTH_SCROLL: Final[bool] = _get_environ_int("TEXTUAL_SMOOTH_SCROLL", 1) == 1
162+
"""Should smooth scrolling be enabled? set `TEXTUAL_SMOOTH_SCROLL=0` to disable smooth
163+
"""

src/textual/driver.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,10 @@ def process_message(self, message: messages.Message) -> None:
8989
else:
9090
offset_x, offset_y = self.cursor_origin
9191
if isinstance(message, events.MouseEvent):
92-
message.x -= offset_x
93-
message.y -= offset_y
94-
message.screen_x -= offset_x
95-
message.screen_y -= offset_y
92+
message._x -= offset_x
93+
message._y -= offset_y
94+
message._screen_x -= offset_x
95+
message._screen_y -= offset_y
9696

9797
if isinstance(message, events.MouseDown):
9898
if message.button:

src/textual/drivers/linux_driver.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ def __init__(
6262
# keep track of this.
6363
self._must_signal_resume = False
6464
self._in_band_window_resize = False
65+
self._mouse_pixels = False
6566

6667
# Put handlers for SIGTSTP and SIGCONT in place. These are necessary
6768
# to support the user pressing Ctrl+Z (or whatever the dev might
@@ -134,6 +135,13 @@ def _enable_mouse_support(self) -> None:
134135
# Note: E.g. lxterminal understands 1000h, but not the urxvt or sgr
135136
# extensions.
136137

138+
def _enable_mouse_pixels(self) -> None:
139+
"""Enable mouse reporting as pixels."""
140+
if not self._mouse:
141+
return
142+
self.write("\x1b[?1016h")
143+
self._mouse_pixels = True
144+
137145
def _enable_bracketed_paste(self) -> None:
138146
"""Enable bracketed paste mode."""
139147
self.write("\x1b[?2004h")
@@ -440,7 +448,7 @@ def process_selector_events(
440448
try:
441449
for event in feed(""):
442450
pass
443-
except ParseError:
451+
except (EOFError, ParseError):
444452
pass
445453

446454
def process_message(self, message: Message) -> None:
@@ -452,6 +460,7 @@ def process_message(self, message: Message) -> None:
452460
self._in_band_window_resize = message.supported
453461
elif message.enabled:
454462
self._in_band_window_resize = message.supported
463+
self._enable_mouse_pixels()
455464
# Send up-to-date message
456465
super().process_message(
457466
TerminalSupportInBandWindowResize(

0 commit comments

Comments
 (0)