touchpad initial support

This commit is contained in:
Vaclav Tvrdik
2026-09-08 21:26:27 +02:00
parent eafe53b2fb
commit 68dbde957d
5 changed files with 269 additions and 32 deletions

View File

@@ -421,6 +421,125 @@ assert terminal.process.written == "", repr(terminal.process.written)
print("alternate scroll tests passed (3 lines per tick: arrows on alt screen, SGR when tracking, inert otherwise)")
# the trackpad never fires the mousemap on macOS (#1838): the poller harvests
# viewport displacement from the follow anchor into wheel lines, snaps back.
# ticks are stepped one at a time — the poller reschedules itself, so a
# blanket sublime.run_timeouts() would loop forever
terminal, stream = make_terminal(rows=5, cols=20)
Terminal._terminals[id(terminal)] = terminal
terminal.view.id = lambda: id(terminal)
terminal.view.text = "\n".join("row {}".format(i) for i in range(8))
terminal.offset = 3
ANCHOR = 3 * LINE_HEIGHT
terminal.viewport_y = ANCHOR
terminal.view.viewport = (0, ANCHOR)
stream.feed("\x1b[?1002h\x1b[?1006h")
del sublime._timeouts[:]
terminal._sync_wheel_flag()
assert terminal._trackpad_running
terminal._sync_wheel_flag()
assert len(sublime._timeouts) == 1, "one timer chain per terminal"
def tick():
sublime._timeouts.pop(0)()
def displace(pixels):
terminal.process.written = ""
terminal.view.viewport = (0, ANCHOR + pixels)
tick()
return terminal.process.written
# the first tick only arms the baseline; a gesture on it must stay silent
assert displace(50) == "", repr(terminal.process.written)
assert terminal.view.viewport == (0, ANCHOR + 50), "arming must not snap"
# 2.5 lines down: two wheel-down reports, snap back, the fraction kept
assert displace(50) == "\x1b[<65;1;3M" * 2
assert terminal.view.viewport == (0, ANCHOR), terminal.view.viewport
assert terminal._trackpad_acc == 0.5, terminal._trackpad_acc
# half a line more completes the kept fraction into one report
assert displace(10) == "\x1b[<65;1;3M"
# a resting tick clears the remainder, so it cannot fire a gesture later
assert displace(10) == ""
assert displace(0) == ""
assert terminal._trackpad_acc == 0, terminal._trackpad_acc
# upward displacement reports wheel-up
assert displace(-30) == "\x1b[<64;1;3M"
# a renderer re-anchor (fold reset, follow-scroll) is not a gesture
displace(0)
terminal.viewport_y = 0
terminal.view.viewport = (0, 0)
tick()
assert terminal.process.written == "", repr(terminal.process.written)
terminal.viewport_y = ANCHOR
terminal.view.viewport = (0, ANCHOR)
tick()
# a queued follow-scroll must not starve the poller: apps repaint while
# scrolling (less redraws per line, claude constantly), so this races every
# gesture and used to read as shaking
terminal._scroll_pending = True
assert displace(40) == "\x1b[<65;1;3M" * 2
terminal._scroll_pending = False
# nor may the follow-scroll itself discard un-harvested displacement while
# the anchor is unchanged: the poller owns the pin
terminal.view.viewport = (0, ANCHOR + 30)
scroll_to_cursor(terminal.view, terminal)
assert terminal.view.viewport == (0, ANCHOR + 30), terminal.view.viewport
terminal.process.written = ""
tick()
assert terminal.process.written == "\x1b[<65;1;3M", repr(terminal.process.written)
assert terminal.view.viewport == (0, ANCHOR), terminal.view.viewport
displace(0) # drop the half-line remainder
# a real anchor change still pins: that is the follow-scroll's job
terminal.view.text += "row 8\n"
scroll_to_cursor(terminal.view, terminal)
assert terminal.viewport_y == ANCHOR + LINE_HEIGHT, terminal.viewport_y
assert terminal.view.viewport == (0, ANCHOR + LINE_HEIGHT), terminal.view.viewport
terminal.view.text = terminal.view.text[:-len("row 8\n")]
terminal.viewport_y = ANCHOR
terminal.view.viewport = (0, ANCHOR)
tick() # settle back on the restored anchor
# a jump of a viewport or more (scrollbar, page scroll) disengages: the user
# browses the scrollback natively until they come back to the anchor
extent = terminal.view.viewport_extent()[1]
assert displace(-extent) == ""
assert not terminal._trackpad_engaged
assert terminal.view.viewport == (0, ANCHOR - extent), "browsing must not snap"
assert displace(-30) == "", "still browsing"
terminal.view.viewport = (0, ANCHOR)
tick() # back at the anchor: re-engage
assert terminal._trackpad_engaged
assert displace(20) == "\x1b[<65;1;3M"
# on the alt screen without tracking the same displacement turns into arrows
stream.feed("\x1b[?1002l\x1b[?1006l\x1b[?1049h")
terminal._sync_wheel_flag()
assert len(sublime._timeouts) == 1, "capture never lapsed, no second chain"
tick() # alt entry left the anchor alone, but spend a tick anyway
assert displace(-20) == "\x1b[A"
# capture released: the poller stops and unhooks itself
stream.feed("\x1b[?1049l")
terminal._sync_wheel_flag()
tick()
assert not terminal._trackpad_running
assert sublime._timeouts == [], sublime._timeouts
print("trackpad tests passed (displacement harvested, fractions kept, jumps browse, poller unhooks)")
# PgUp/PgDn with the configured modifier pages the view instead of reaching the
# app; every other combination is sent as before. 24 scrollback rows above an
# 8-row screen, viewport at the follow anchor
@@ -546,34 +665,56 @@ stream.feed("\x1b[?1049h")
command.sync_alt_fold(terminal)
assert terminal.view.folds == [(0, fold_end)], terminal.view.folds
assert terminal.viewport_y == 0, terminal.viewport_y
assert terminal.view.settings().get("scroll_past_end") is False, "alt screen must not be scrollable"
assert terminal.view.settings().get("scroll_past_end") is True, \
"the scroll range must stay: the trackpad poller reads it"
# a second render leaves the fold alone
command.sync_alt_fold(terminal)
assert terminal.view.folds == [(0, fold_end)], terminal.view.folds
# a disturbed fold is re-asserted on the next render
# a disturbed fold is re-asserted on the next render, keeping the anchor:
# apps repaint the boundary row every scroll, and a per-repair reset made the
# poller read the thrashing anchor as phantom down-lines (dead trackpad-up)
terminal.viewport_y = 60
terminal.view.folds = [(0, fold_end - 2)]
command.sync_alt_fold(terminal)
assert terminal.view.folds == [(0, fold_end)], terminal.view.folds
assert terminal.viewport_y == 60, terminal.viewport_y
stream.feed("\x1b[?1049l")
command.sync_alt_fold(terminal)
assert terminal.view.folds == [], terminal.view.folds
assert terminal.view.settings().get("scroll_past_end") is True, "primary screen scrolls past end again"
assert terminal.view.settings().get("scroll_past_end") is True, "left untouched"
# without scrollback there is nothing to fold, but the scroll lock still applies
# without scrollback there is nothing to fold
terminal, stream = make_terminal(rows=5, cols=20)
terminal.view = FakeView("r0\nr1\nr2\nr3\nr4\n")
command = make_command(terminal.view)
stream.feed("\x1b[?1049h")
command.sync_alt_fold(terminal)
assert terminal.view.folds == [], terminal.view.folds
assert terminal.view.settings().get("scroll_past_end") is False, "alt screen must not be scrollable"
print("alt fold tests passed (scrollback folded on entry, restored on exit)")
# a fresh view gets one blank scrollback row: sublime has no layout above row
# 0, so without it the trackpad poller has no upward slack — and on the alt
# screen no line ever retires to provide it (trackpad-up was dead in less)
terminal, stream = make_terminal(rows=5, cols=20)
Terminal._terminals[id(terminal)] = terminal
terminal.view.id = lambda: id(terminal)
command = make_command(terminal.view)
stream.feed("hello")
command.run(None)
assert terminal.offset == 1, terminal.offset
assert terminal.view.text.startswith("\nhello"), repr(terminal.view.text)
command.run(None) # only a fresh view is seeded
assert terminal.offset == 1, terminal.offset
del sublime._timeouts[:] # drop the follow-scrolls the renders queued
print("seed row tests passed (blank row above a fresh screen, seeded once)")
# a row whose first cells were never written: they read back as default cells
# (StaticDefaultDict), so they form their own uncoloured run and the coloured
# run that follows keeps the right column

View File

@@ -152,8 +152,8 @@ class VtermKeypressCommand(sublime_plugin.TextCommand):
def page_scrolled(self, terminal, key=None, **mods):
"""Page the view one screen over the scrollback, before vterm_show_cursor,
whose scroll would pull the viewport straight back down. The alt screen is
folded and has no scroll range, so an app there keeps the key.
whose scroll would pull the viewport straight back down. The alt screen
has its scrollback folded away, so an app there keeps the key.
Returns:
bool: the view consumed the key; False sends it to the app.

View File

@@ -1,6 +1,10 @@
""" SGR mouse reporting while the app tracks the mouse; otherwise sublime keeps drag_select """
import math
import sublime
import sublime_plugin
from .render import following
from .terminal import Terminal
# SGR button numbers for the wheel; 64 is the "scroll" bit plus direction
@@ -11,6 +15,27 @@ WHEEL_DOWN = 65
# line per report and has no setting of its own
WHEEL_LINES = 3
# trackpad poll period: one frame at 60 Hz, so a snapped-back displacement
# never exceeds a frame's worth of motion
TRACKPAD_POLL_MS = 16
def send_scroll(terminal, up, lines, cell=None):
"""Report `lines` scroll lines to the app, or fake arrow keys on the alt screen.
One report per line, never one batched write: pi matches the SGR form
against a whole stdin chunk and drops anything longer."""
if terminal.screen.mouse_reporting_enabled():
if cell is None:
cell = (1, terminal.screen.lines // 2 + 1)
for _ in range(lines):
terminal.send_mouse(WHEEL_UP if up else WHEEL_DOWN, cell[0], cell[1], pressed=True)
elif terminal.screen.alternate_buffer_mode:
# alternate scroll: an alt-screen app that does not track the mouse
# gets arrow keys per wheel tick, as kitty/iTerm2/xterm do
for _ in range(lines):
terminal.send_key(key="up" if up else "down")
def _event_to_cell(view, terminal, event, clamp=False):
"""Map a mouse event to 1-based (col, row) on the live screen, or None.
@@ -72,7 +97,7 @@ class VtermScrollCommand(sublime_plugin.TextCommand):
"""Bound to scroll wheel in the mousemap while the app tracks the mouse or is on the alt screen."""
def run(self, edit, event=None, up=False):
"""Report the wheel to the app, or fake arrow keys on the alt screen.
"""Report one wheel notch to the app.
Only bound while the app owns the wheel; otherwise sublime scrolls the
view over the real scrollback text and this never runs.
@@ -80,20 +105,65 @@ class VtermScrollCommand(sublime_plugin.TextCommand):
terminal = Terminal.from_id(self.view.id())
if not terminal:
return
if terminal.screen.mouse_reporting_enabled():
cell = _event_to_cell(self.view, terminal, event, clamp=True) if event else None
if cell is None:
cell = (1, terminal.screen.lines // 2 + 1)
# one report per line, never one batched write: pi matches the SGR
# form against a whole stdin chunk and drops anything longer
for _ in range(WHEEL_LINES):
terminal.send_mouse(WHEEL_UP if up else WHEEL_DOWN, cell[0], cell[1], pressed=True)
elif terminal.screen.alternate_buffer_mode:
# alternate scroll: an alt-screen app that does not track the mouse
# gets arrow keys per wheel tick, as kitty/iTerm2/xterm do
for _ in range(WHEEL_LINES):
terminal.send_key(key="up" if up else "down")
cell = None
if terminal.screen.mouse_reporting_enabled() and event:
cell = _event_to_cell(self.view, terminal, event, clamp=True)
send_scroll(terminal, up, WHEEL_LINES, cell)
def want_event(self):
"""Ask sublime for the wheel coordinates."""
return True
def ensure_trackpad(terminal):
"""Start the poller that turns trackpad scrolling into wheel lines.
The trackpad never fires the mousemap scroll bindings on macOS
(sublime_text#1838); its only trace is viewport movement. While the app
owns the wheel the mousemap keeps the physical wheel off the viewport, so
any displacement from the follow anchor is trackpad scroll: harvest whole
lines from it, send them like wheel lines, and snap the viewport back."""
if terminal._trackpad_running:
return
terminal._trackpad_running = True
terminal._trackpad_acc = 0.0
terminal._trackpad_engaged = True
terminal._trackpad_baseline = None
sublime.set_timeout(lambda: _trackpad_tick(terminal), TRACKPAD_POLL_MS)
def _trackpad_tick(terminal):
"""One poll: harvest viewport displacement, reschedule while captured."""
view = terminal.view
if Terminal.from_id(view.id()) is not terminal or not terminal.alive \
or not terminal._wheel_captured:
terminal._trackpad_running = False
return
anchor = terminal.viewport_y
extent = view.viewport_extent()[1]
if (anchor, extent) != terminal._trackpad_baseline:
# the renderer moved the anchor (follow-scroll, fold, resize): re-arm
# around the new position instead of reading the move as a gesture
terminal._trackpad_baseline = (anchor, extent)
terminal._trackpad_acc = 0.0
else:
dy = view.viewport_position()[1] - anchor
if not terminal._trackpad_engaged:
if following(view, terminal):
terminal._trackpad_engaged = True
terminal._trackpad_acc = 0.0
elif dy == 0:
# at rest a stale fraction must not fire on the next gesture
terminal._trackpad_acc = 0.0
elif abs(dy) >= extent:
# scrollbar/minimap jump or page scroll, not a trackpad gesture:
# let the user browse until they return to the anchor
terminal._trackpad_engaged = False
else:
terminal._trackpad_acc += dy / view.line_height()
lines = math.trunc(terminal._trackpad_acc)
terminal._trackpad_acc -= lines
if lines:
send_scroll(terminal, up=lines < 0, lines=abs(lines))
view.set_viewport_position((0, anchor), False)
sublime.set_timeout(lambda: _trackpad_tick(terminal), TRACKPAD_POLL_MS)

View File

@@ -172,6 +172,14 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
terminal.pending_clear_screen = False
self.clear_view(edit, terminal, wipe_screen=True)
if view.size() == 0 and terminal.offset == 0:
# one blank scrollback row above a fresh screen: sublime has no
# layout above row 0, and the trackpad poller needs upward slack —
# without it, trackpad-up is dead until a line retires, which on
# the alt screen is never
view.insert(edit, 0, "\n")
terminal.offset = 1
self.update_lines(edit, terminal)
self.sync_alt_fold(terminal)
@@ -200,25 +208,27 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
def sync_alt_fold(self, terminal):
"""Hide the scrollback while the app owns the alt screen: fold it away
(the view text is its only copy) and drop past-end scrolling so the
view has no scroll range. The fold is re-checked every render — the
screen's first row is replaced on every region scroll, and an edit at
the fold boundary can silently disturb a fold."""
(the view text is its only copy). The scroll range stays: the trackpad
poller pins the viewport and needs the slack to read the trackpad.
The fold is re-checked every render — the screen's first row is
replaced on every region scroll, and an edit at the fold boundary can
silently disturb a fold."""
view = self.view
alt = terminal.screen.alternate_buffer_mode
if not alt and not terminal.applied_alt_screen:
return
if alt != terminal.applied_alt_screen:
terminal.applied_alt_screen = alt
view.settings().set("scroll_past_end", not alt)
end = view.text_point(terminal.offset, 0) - 1 if alt and terminal.offset else 0
want = [(0, end)] if end > 0 else []
if [(r.begin(), r.end()) for r in view.folded_regions()] == want:
had = [(r.begin(), r.end()) for r in view.folded_regions()]
if had == want:
return
view.unfold(sublime.Region(0, view.size()))
if want and view.fold(sublime.Region(0, end)):
# a fold collapses the layout; a stale viewport anchor would stop
# the follow-scroll gate from re-engaging
if want and view.fold(sublime.Region(0, end)) and not had:
# a fresh fold collapses the layout; a stale viewport anchor would
# stop the follow-scroll gate from re-engaging. re-asserting a
# disturbed fold keeps the anchor: apps repaint the boundary row
# every scroll, and resetting per repair thrashes the poller
terminal.viewport_y = 0
def sync_cursor_style(self, terminal):
@@ -535,6 +545,11 @@ def scroll_to_cursor(view, terminal):
"""Follow the output: pin the viewport to the bottom of the live screen."""
terminal._scroll_pending = False
y = bottom_y(view, terminal)
if y == terminal.viewport_y and terminal._trackpad_running and terminal._trackpad_engaged:
# the anchor is unchanged and the trackpad poller owns the pin:
# re-pinning here would discard displacement it has not harvested yet,
# and apps repaint while scrolling, so this races every gesture
return
terminal.viewport_y = y
view.set_viewport_position((0, y), False)

View File

@@ -109,6 +109,12 @@ class Terminal:
self._send_lock = threading.Lock()
self._wheel_captured = False
self._scroll_pending = False
# trackpad poller (mouse.ensure_trackpad): reschedule guard, fractional
# line remainder, harvesting vs. browsing, last seen (anchor, extent)
self._trackpad_running = False
self._trackpad_acc = 0.0
self._trackpad_engaged = True
self._trackpad_baseline = None
self.pending_clear = False
self.pending_clear_screen = False
# a forced frame (set on activation); rendering stays on the renderer
@@ -392,6 +398,11 @@ class Terminal:
if captured != self._wheel_captured:
self._wheel_captured = captured
self.view.settings().set("vterm_view.wheel_captured", captured)
if captured and not self._trackpad_running:
# checked every renderer tick, so a poller that just stopped on a
# capture flap is restarted within a frame or two
from .mouse import ensure_trackpad # module-level import would cycle
ensure_trackpad(self)
def send_focus(self, focused):
"""Report focus gain or loss, when the app asked for it (?1004)."""