diff --git a/tests/test_render.py b/tests/test_render.py index 1cb966d..9f20a06 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -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 diff --git a/vterm/commands.py b/vterm/commands.py index 62ea63d..204f707 100644 --- a/vterm/commands.py +++ b/vterm/commands.py @@ -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. diff --git a/vterm/mouse.py b/vterm/mouse.py index 71daa1f..d0a833b 100644 --- a/vterm/mouse.py +++ b/vterm/mouse.py @@ -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) diff --git a/vterm/render.py b/vterm/render.py index 6c631d8..f758f96 100644 --- a/vterm/render.py +++ b/vterm/render.py @@ -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) + terminal.applied_alt_screen = 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) diff --git a/vterm/terminal.py b/vterm/terminal.py index 450b8a5..ff3830d 100644 --- a/vterm/terminal.py +++ b/vterm/terminal.py @@ -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)."""