Skip to content

Commit

Permalink
Merge pull request #5260 from Textualize/smooth-scroll
Browse files Browse the repository at this point in the history
WIP Smooth scroll
  • Loading branch information
willmcgugan authored Feb 14, 2025
2 parents 35ba217 + f04da02 commit 99ca89e
Show file tree
Hide file tree
Showing 17 changed files with 210 additions and 228 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Changed

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

### Removed

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/blog/images/smooth-scroll/smooth-scroll.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
67 changes: 67 additions & 0 deletions docs/blog/posts/smooth-scrolling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
---
draft: false
date: 2025-02-16
categories:
- DevLog
authors:
- willmcgugan
---

# Smoother scrolling in the terminal — a feature decades in the making

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.”

Beuller was *not* taking about terminals, which tend not to move very fast at all.
Until they do.
From time to time terminals acquire new abilities after a long plateau.
We are now seeing a kind of punctuated evolution in terminals which makes things possible that just weren't feasible a short time ago.

I want to talk about one such feature, which *I believe* has been decades[^1] in the making.
Take a look at the following screen recording (taken from a TUI running in the terminal):

![A TUI Scrollbar](../images/smooth-scroll/no-smooth-scroll.gif)

<!-- more -->

Note how the mouse pointer moves relatively smoothly, but the scrollbar jumps with a jerky motion.

This happens because the terminal reports the mouse coordinates in cells (a *cell* is the dimensions of a single latin-alphabet character).
In other words, the app knows only which cell is under the pointer.
It isn't granular enough to know where the pointer is *within* a cell.

Until recently terminal apps couldn't do any better.
More granular mouse reporting is possible in the terminal; write the required escape sequence and mouse coordinates are reported in pixels rather than cells.
So why haven't TUIs been using this?

The problem is that we can't translate between pixel coordinates and cell coordinates without first knowing how many pixels are in a cell.
And in order to know that, we need to know the width and height of the terminal in *pixels*.
Unfortunately, that standard way to get the terminal size reports just cells.

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.
Once we have both the mouse coordinates in pixels and the dimensions of the terminal in pixels, we can implement much smoother scrolling.

Let's see how this looks.

On the right we have smooth scrolling enabled, on the left is the default non-smooth scrolling:


| Default scrolling | Smooth scrolling |
| ---------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| ![A TUI Scrollbar](../images/smooth-scroll/no-smooth-scroll.gif) | ![A TUI Scrollbar with smooth scrolling](../images/smooth-scroll/smooth-scroll.gif) |

Notice how much smoother the motion of the table is, now that it tracks the mouse cursor more accurately.
If you move the scrollbar quickly, you may not notice the difference.
But if you move slowly like you are searching for something, it is a respectable quality of life improvement.

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.

I think Textual may be the first library to implement this.
Let me know, if you have encountered any non-Textual TUI app which implements this kind of smooth scrolling.

## Join us

Join our [Discord server](https://discord.gg/Enf6Z3qhVr) to discuss anything terminal related with the Textualize devs, or the community!


[^1]: I'm not sure exactly when pixel mouse reporting was added to terminals. I'd be interested if anyone has a precised date.
[^2]: Kitty, Ghostty, and a few others.
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
36 changes: 30 additions & 6 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 @@ -267,8 +287,12 @@ def send_escape() -> None:
setting_parameter = int(mode_report_match["setting_parameter"])
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
elif (
mode_id == "2048"
and constants.SMOOTH_SCROLL
and not IS_ITERM
):
# TODO: iTerm is buggy in one or more of the protocols required here
in_band_event = messages.TerminalSupportInBandWindowResize.from_setting_parameter(
setting_parameter
)
Expand Down
4 changes: 4 additions & 0 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,9 @@ def __init__(
self._clipboard: str = ""
"""Contents of local clipboard."""

self.supports_smooth_scrolling: bool = True
"""Does the terminal support smooth scrolling?"""

if self.ENABLE_COMMAND_PALETTE:
for _key, binding in self._bindings:
if binding.action in {"command_palette", "app.command_palette"}:
Expand Down Expand Up @@ -4632,6 +4635,7 @@ def _on_terminal_supports_in_band_window_resize(
"""There isn't much we can do with this information currently, so
we will just log it.
"""
self.supports_smooth_scrolling = True
self.log.debug(message)

def _on_idle(self) -> None:
Expand Down
4 changes: 4 additions & 0 deletions src/textual/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,7 @@ def _get_textual_animations() -> AnimationLevel:
"""Textual theme to make default. More than one theme may be specified in a comma separated list.
Textual will use the first theme that exists.
"""

SMOOTH_SCROLL: Final[bool] = _get_environ_int("TEXTUAL_SMOOTH_SCROLL", 1) == 1
"""Should smooth scrolling be enabled? set `TEXTUAL_SMOOTH_SCROLL=0` to disable smooth
"""
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
11 changes: 10 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,13 @@ 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:
"""Enable mouse reporting as pixels."""
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 +448,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 +460,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
Loading

0 comments on commit 99ca89e

Please sign in to comment.