fixes
This commit is contained in:
@@ -200,6 +200,14 @@ the same way, and scrolling down clamps to the anchor `bottom_y` returns, which
|
||||
it. A one-row scroll lands exactly on the tolerance boundary, so one Up closes the gate
|
||||
and one Down re-opens it.
|
||||
|
||||
**Pinning waits for rest.** Two pollers put the viewport back on the anchor: the renderer
|
||||
tick, for a viewport dragged into sublime's past-end room on a terminal too idle to draw
|
||||
a frame, and the trackpad poller, when a browsing gesture returns to the anchor. Both
|
||||
compare the viewport against the position they saw one tick earlier and act only when the
|
||||
two agree. macOS keeps scrolling after the fingers lift, and a hard
|
||||
`set_viewport_position` against that momentum used to yank the view back thirty times a
|
||||
second for as long as the glide lasted — a visible shake at the bottom of every scroll.
|
||||
|
||||
### How colour is actually drawn
|
||||
|
||||
`view.add_regions(key, regions, scope)` can only name a **scope**. It cannot take a
|
||||
@@ -269,6 +277,8 @@ keymap binding (context: setting.vterm_view)
|
||||
and Up/Down a row; stops here, never on the alt screen
|
||||
→ Terminal.send_key() adds application_mode (DECCKM), new_line_mode (LNM)
|
||||
→ keys.get_key_code() modified nav keys → CSI 1;<mod><final>
|
||||
enter/tab/backspace/space, modified, while the app
|
||||
asked for modifyOtherKeys → CSI 27;<mod>;<code>~
|
||||
ctrl → control byte; alt → ESC prefix
|
||||
plain → _KEY_MAP / _APP_MODE_KEY_MAP
|
||||
→ Terminal.send_string(normalized=False)
|
||||
@@ -446,7 +456,13 @@ how an app discovers what the terminal supports.
|
||||
- **XTVERSION** ✅ (`CSI > q`) — the terminal's name and version. Naming ourselves is what
|
||||
lets apps enable 2026 on a generic `TERM`.
|
||||
- **Kitty keyboard protocol** — a richer key encoding with release events. vterm answers
|
||||
the probe with "unsupported", so apps fall back to the legacy encodings it does send.
|
||||
the probe (`CSI ? u`) with zero flags, so apps fall back to something else — for pi that
|
||||
is modifyOtherKeys, which it then enables unprompted.
|
||||
- **modifyOtherKeys** ✅ (`CSI > 4 ; n m`) — xterm's pre-kitty answer to the same problem:
|
||||
keys whose modified form has no legacy encoding are sent as `CSI 27 ; <mod> ; <code> ~`.
|
||||
While an app has it on, vterm uses that form for modified enter, tab, backspace and space —
|
||||
Shift+Enter is `CSI 27;2;13~`, since a bare `ESC CR` is indistinguishable from Alt+Enter.
|
||||
Ctrl+letters keep their control bytes, so Ctrl+C stays `\x03`.
|
||||
|
||||
**Other terms**
|
||||
|
||||
|
||||
@@ -41,7 +41,9 @@ takes `cmd`, `cwd`, `env` and `title`:
|
||||
{ "caption": "vterm: pi", "command": "vterm_open", "args": {"cmd": ["pi"], "title": "pi"} }
|
||||
```
|
||||
|
||||
Keys: Shift+Enter sends `ESC CR` (newline in both agents). Shift+PgUp/PgDn page
|
||||
Keys: Shift+Enter sends `ESC CR`, or `CSI 27;2;13~` while the app has xterm
|
||||
modifyOtherKeys on — pi turns it on and reads a bare `ESC CR` as Alt+Enter,
|
||||
which submits instead of breaking the line. Shift+PgUp/PgDn page
|
||||
the view over the scrollback and Shift+Up/Down move it one line, instead of
|
||||
reaching the app, as xterm and kitty do — `scroll_modifier` moves that to Ctrl,
|
||||
or turns it off. Copy/paste use the normal Sublime keys, and pastes are
|
||||
@@ -122,7 +124,9 @@ terminal protocols — see `Architecture.md`.
|
||||
|
||||
Supported: alt screen (1049), synchronized output (2026), bracketed paste (2004),
|
||||
mouse tracking 1000/1002/1003 + SGR 1006 (wheel and left click are reported; no
|
||||
drag, no right or middle button), focus events (1004), cursor styles (DECSCUSR —
|
||||
drag, no right or middle button), focus events (1004),
|
||||
xterm modifyOtherKeys (`CSI > 4 ; n m`, for the modified keys with no
|
||||
unambiguous legacy encoding: enter, tab, backspace, space), cursor styles (DECSCUSR —
|
||||
block/bar and blink/steady via the view's caret settings; underline falls back
|
||||
to bar, 0 is the default blinking block), scroll regions, autowrap,
|
||||
50 000 lines of scrollback that ED 3 does not erase (agents repaint with it;
|
||||
|
||||
@@ -54,10 +54,18 @@ def run_case(char_by_char):
|
||||
# and nothing leaked to the screen
|
||||
assert screen.buffer[0][0].data == " ", (tag, screen.buffer[0][0])
|
||||
|
||||
# modifyOtherKeys must not corrupt SGR state
|
||||
# modifyOtherKeys: the level is tracked (pi enables it and reads shift+enter
|
||||
# no other way), and the sequence must not corrupt SGR state
|
||||
proc, screen, stream = make()
|
||||
assert screen.modify_other_keys == 0, tag
|
||||
feed(stream, "\x1b[>4;2mhello", char_by_char)
|
||||
assert screen.modify_other_keys == 2, (tag, screen.modify_other_keys)
|
||||
assert screen.buffer[0][0].data == "h" and not screen.buffer[0][0].underscore, tag
|
||||
feed(stream, "\x1b[>4;0m", char_by_char)
|
||||
assert screen.modify_other_keys == 0, (tag, screen.modify_other_keys)
|
||||
# another resource, and the bare form, leave it alone
|
||||
feed(stream, "\x1b[>4;2m\x1b[>1;2m\x1b[>m", char_by_char)
|
||||
assert screen.modify_other_keys == 2, (tag, screen.modify_other_keys)
|
||||
|
||||
# OSC 11 color query answered; OSC 0 sets title; OSC 133 dropped
|
||||
proc, screen, stream = make()
|
||||
|
||||
@@ -52,6 +52,15 @@ for char in string.ascii_letters + string.digits + string.punctuation:
|
||||
assert produced == char, "{!r} would send {!r}".format(char, produced)
|
||||
|
||||
assert get_key_code(key="space") == " ", "space must send a space"
|
||||
|
||||
# legacy encodings by default; while the app has modifyOtherKeys on, the keys
|
||||
# whose modified form is ambiguous take the CSI 27 form instead. pi reads a
|
||||
# bare ESC CR as alt+enter and submits on it
|
||||
assert get_key_code(key="enter", shift=True) == "\x1b\r"
|
||||
assert get_key_code(key="enter", shift=True, modify_other_keys=2) == "\x1b[27;2;13~"
|
||||
assert get_key_code(key="space", ctrl=True, modify_other_keys=2) == "\x1b[27;5;32~"
|
||||
assert get_key_code(key="enter", modify_other_keys=2) == "\r", "unmodified keys stay legacy"
|
||||
assert get_key_code(key="c", ctrl=True, modify_other_keys=2) == "\x03", "ctrl+c stays a control byte"
|
||||
load_sublime_json("Default (OSX).sublime-mousemap")
|
||||
|
||||
# scheme manager produces scopes and rules
|
||||
|
||||
@@ -519,10 +519,26 @@ 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
|
||||
tick() # back at the anchor, but still gliding: nothing may pin it yet
|
||||
assert not terminal._trackpad_engaged
|
||||
tick() # at rest on the anchor: re-engage
|
||||
assert terminal._trackpad_engaged
|
||||
assert displace(20) == "\x1b[<65;1;3M"
|
||||
|
||||
# momentum carrying past the anchor must glide unpinned — pinning it mid-glide
|
||||
# is the shake — and be corrected in one step once it stops, without a report
|
||||
assert displace(-extent) == ""
|
||||
assert not terminal._trackpad_engaged
|
||||
terminal.process.written = ""
|
||||
terminal.view.viewport = (0, ANCHOR + 40)
|
||||
tick()
|
||||
assert terminal.view.viewport == (0, ANCHOR + 40), "gliding must not snap"
|
||||
assert not terminal._trackpad_engaged
|
||||
tick()
|
||||
assert terminal._trackpad_engaged
|
||||
assert terminal.view.viewport == (0, ANCHOR), terminal.view.viewport
|
||||
assert terminal.process.written == "", repr(terminal.process.written)
|
||||
|
||||
# on the alt screen without tracking the same displacement turns into arrows
|
||||
stream.feed("\x1b[?1002l\x1b[?1006l\x1b[?1049h")
|
||||
terminal._sync_wheel_flag()
|
||||
@@ -537,7 +553,7 @@ tick()
|
||||
assert not terminal._trackpad_running
|
||||
assert sublime._timeouts == [], sublime._timeouts
|
||||
|
||||
print("trackpad tests passed (displacement harvested, fractions kept, jumps browse, poller unhooks)")
|
||||
print("trackpad tests passed (displacement harvested, fractions kept, jumps browse, momentum settles, poller unhooks)")
|
||||
|
||||
|
||||
# with the configured modifier PgUp/PgDn page the view and Up/Down move it one
|
||||
@@ -641,6 +657,25 @@ plugin_settings.set("scroll_modifier", "shift")
|
||||
print("view scroll tests passed (a screen or a row per press, clamped to the anchor, configurable modifier)")
|
||||
|
||||
|
||||
# shift+enter through the whole keypress path: ESC CR by default, and the CSI 27
|
||||
# form once the app turned modifyOtherKeys on. pi enables it (its kitty probe
|
||||
# gets no flags from us) and reads a bare ESC CR as alt+enter, which submits
|
||||
press(key="enter", shift=True)
|
||||
assert terminal.process.written == "\x1b\r", repr(terminal.process.written)
|
||||
stream.feed("\x1b[>4;2m")
|
||||
press(key="enter", shift=True)
|
||||
assert terminal.process.written == "\x1b[27;2;13~", repr(terminal.process.written)
|
||||
press(key="enter")
|
||||
assert terminal.process.written == "\r", "unmodified enter still submits"
|
||||
press(key="c", ctrl=True)
|
||||
assert terminal.process.written == "\x03", "ctrl+c stays a control byte"
|
||||
stream.feed("\x1b[>4;0m")
|
||||
press(key="enter", shift=True)
|
||||
assert terminal.process.written == "\x1b\r", repr(terminal.process.written)
|
||||
|
||||
print("modifyOtherKeys tests passed (shift+enter as CSI 27 while the app asks for it)")
|
||||
|
||||
|
||||
# a click in an unfocused group must focus the tab instead of reporting: we
|
||||
# replaced drag_select, which is what sublime focuses the group from
|
||||
class FakeWindow:
|
||||
|
||||
@@ -184,6 +184,7 @@ class VtScreen(pyte.Screen):
|
||||
super().reset()
|
||||
self.cursor = Cursor(0, 0)
|
||||
self.cursor_style = 0
|
||||
self.modify_other_keys = 0
|
||||
self.history.clear()
|
||||
self._clear_callback()
|
||||
|
||||
@@ -192,6 +193,15 @@ class VtScreen(pyte.Screen):
|
||||
# DECSCUSR: 0 default, 1/2 block, 3/4 underline, 5/6 bar; odd = blinking
|
||||
self.cursor_style = style
|
||||
|
||||
def set_modify_other_keys(self, resource=0, value=0):
|
||||
"""xterm CSI > 4 ; n m: send modified keys as CSI 27 ; mod ; code ~.
|
||||
|
||||
pi turns this on whenever the kitty probe answers with no flags, and
|
||||
reads shift+enter no other way.
|
||||
"""
|
||||
if resource == 4:
|
||||
self.modify_other_keys = value
|
||||
|
||||
def resize(self, lines=None, columns=None):
|
||||
"""Resize the screen, pushing scrolled-off lines into scrollback.
|
||||
|
||||
@@ -617,7 +627,8 @@ class VtScreen(pyte.Screen):
|
||||
|
||||
def report_kitty_keyboard(self):
|
||||
"""Answer the kitty keyboard protocol probe."""
|
||||
# 0 = unsupported, so apps fall back to legacy key encodings
|
||||
# answering at all means "supported"; 0 flags means none are enabled,
|
||||
# which is what steers pi onto its modifyOtherKeys fallback
|
||||
self.write_process_input("\x1b[?0u")
|
||||
|
||||
def handle_osc(self, code, payload, terminator):
|
||||
@@ -912,7 +923,9 @@ class VtStream(pyte.Stream):
|
||||
listener.report_xtversion()
|
||||
elif final == "c":
|
||||
listener.report_secondary_device_attributes(*params)
|
||||
# '>m' modifyOtherKeys, '>u' kitty push: ignore
|
||||
elif final == "m":
|
||||
listener.set_modify_other_keys(*params)
|
||||
# '>u' kitty push: ignore
|
||||
return
|
||||
if prefix:
|
||||
# kitty pop ('<u'), DECKPAM-style ('='): ignore
|
||||
|
||||
@@ -94,19 +94,26 @@ _SHIFT_KEY_MAP = {
|
||||
"enter": "\x1b\r",
|
||||
}
|
||||
|
||||
# keys whose modified form has no unambiguous legacy encoding: ESC CR reads as
|
||||
# alt+enter, ctrl+space as NUL. An app that turned modifyOtherKeys on gets them
|
||||
# as CSI 27 ; <mod> ; <code> ~ instead
|
||||
_OTHER_KEYS = {"enter": 13, "tab": 9, "backspace": 127, "space": 32}
|
||||
|
||||
|
||||
def _modifier_code(ctrl, alt, shift):
|
||||
"""xterm modifier parameter: 1 plus a bitmask of shift/alt/ctrl."""
|
||||
return 1 + (1 if shift else 0) + (2 if alt else 0) + (4 if ctrl else 0)
|
||||
|
||||
|
||||
def get_key_code(key, ctrl=False, alt=False, shift=False, application_mode=False, new_line_mode=False):
|
||||
def get_key_code(key, ctrl=False, alt=False, shift=False, application_mode=False, new_line_mode=False,
|
||||
modify_other_keys=0):
|
||||
"""Escape sequence for a key name and its modifiers.
|
||||
|
||||
Args:
|
||||
key: key name from the keymap, or a single literal character.
|
||||
application_mode: DECCKM is set, so cursor keys take their SS3 form.
|
||||
new_line_mode: LNM is set, so enter sends CR LF.
|
||||
modify_other_keys: the level the app asked for with CSI > 4 ; n m.
|
||||
|
||||
Returns:
|
||||
str: the bytes to write to the pty.
|
||||
@@ -116,6 +123,9 @@ def get_key_code(key, ctrl=False, alt=False, shift=False, application_mode=False
|
||||
if (ctrl or alt or shift) and key_lo in _MODIFIABLE_CSI:
|
||||
return _MODIFIABLE_CSI[key_lo].format(_modifier_code(ctrl, alt, shift))
|
||||
|
||||
if modify_other_keys and (ctrl or alt or shift) and key_lo in _OTHER_KEYS:
|
||||
return "\x1b[27;{};{}~".format(_modifier_code(ctrl, alt, shift), _OTHER_KEYS[key_lo])
|
||||
|
||||
if ctrl:
|
||||
base = key_lo
|
||||
if base in _CTRL_KEY_MAP:
|
||||
|
||||
@@ -126,6 +126,7 @@ def start_trackpad(terminal):
|
||||
terminal._trackpad_running = True
|
||||
terminal._trackpad_engaged = True
|
||||
terminal._trackpad_baseline = None
|
||||
terminal._trackpad_browse_y = None
|
||||
sublime.set_timeout(lambda: _trackpad_tick(terminal), TRACKPAD_POLL_MS)
|
||||
|
||||
|
||||
@@ -143,9 +144,16 @@ def _trackpad_tick(terminal):
|
||||
terminal._trackpad_baseline = (anchor, extent)
|
||||
terminal._trackpad_acc = 0.0
|
||||
elif not terminal._trackpad_engaged:
|
||||
if following(view, terminal):
|
||||
# re-engaging mid-glide harvests the momentum as a gesture and snaps
|
||||
# the view back on every tick, which reads as shaking. Wait for two
|
||||
# ticks to agree that it has come to rest, then correct the overshoot
|
||||
# in one step — it was browsing, not a scroll the app should hear about
|
||||
y = view.viewport_position()[1]
|
||||
if y == terminal._trackpad_browse_y and following(view, terminal):
|
||||
terminal._trackpad_engaged = True
|
||||
terminal._trackpad_acc = 0.0
|
||||
view.set_viewport_position((0, anchor), False)
|
||||
terminal._trackpad_browse_y = y
|
||||
else:
|
||||
dy = view.viewport_position()[1] - anchor
|
||||
if dy == 0:
|
||||
@@ -155,6 +163,7 @@ def _trackpad_tick(terminal):
|
||||
# scrollbar/minimap jump or page scroll, not a trackpad gesture:
|
||||
# let the user browse until they return to the anchor
|
||||
terminal._trackpad_engaged = False
|
||||
terminal._trackpad_browse_y = None
|
||||
else:
|
||||
terminal._trackpad_acc += dy / view.line_height()
|
||||
lines = int(terminal._trackpad_acc)
|
||||
|
||||
@@ -110,11 +110,13 @@ class Terminal:
|
||||
self._wheel_captured = False
|
||||
self._scroll_pending = False
|
||||
# trackpad poller (mouse.start_trackpad): reschedule guard, fractional
|
||||
# line remainder, harvesting vs. browsing, last seen (anchor, extent)
|
||||
# line remainder, harvesting vs. browsing, last seen (anchor, extent),
|
||||
# viewport of the previous browsing tick
|
||||
self._trackpad_running = False
|
||||
self._trackpad_acc = 0.0
|
||||
self._trackpad_engaged = True
|
||||
self._trackpad_baseline = None
|
||||
self._trackpad_browse_y = None
|
||||
self.pending_clear = False
|
||||
self.pending_clear_screen = False
|
||||
# a forced frame (set on activation); rendering stays on the renderer
|
||||
@@ -290,6 +292,7 @@ class Terminal:
|
||||
|
||||
def renderer():
|
||||
"""Own the emulator: parse, then ask sublime to redraw, once per tick."""
|
||||
settled_y = None
|
||||
while True:
|
||||
# a hidden tab is not drawn at all: the screen keeps the state
|
||||
# and one render on unhide catches the view up. The tick stays at
|
||||
@@ -307,6 +310,21 @@ class Terminal:
|
||||
# due first: _need_to_render consumes the cursor move it reports
|
||||
if self.is_render_due() and self._need_to_render():
|
||||
self.view.run_command("vterm_render")
|
||||
# a frame can move the viewport: a sample from
|
||||
# before it says nothing about coming to rest
|
||||
settled_y = None
|
||||
else:
|
||||
# the wheel can drag the screen off the top into
|
||||
# sublime's past-end room; a frame undoes that, and
|
||||
# an idle terminal draws none. Pinning while the
|
||||
# view is still gliding fights the trackpad's
|
||||
# momentum, which reads as shaking, so wait for two
|
||||
# ticks to agree that it has come to rest
|
||||
y = self.view.viewport_position()[1]
|
||||
if y == settled_y and self.viewport_y < y:
|
||||
from .render import request_scroll # module-level import would cycle
|
||||
request_scroll(self.view, self)
|
||||
settled_y = y
|
||||
self._sync_wheel_flag()
|
||||
if done[0] or not self.is_hosted():
|
||||
LOG.debug("[renderer] stops")
|
||||
@@ -348,6 +366,7 @@ class Terminal:
|
||||
"""Encode a key name plus modifiers for the app's current modes and send it."""
|
||||
kwargs["application_mode"] = APP_CURSOR_MODE in self.screen.mode
|
||||
kwargs["new_line_mode"] = mo.LNM in self.screen.mode
|
||||
kwargs["modify_other_keys"] = self.screen.modify_other_keys
|
||||
self.send_string(get_key_code(**kwargs), normalized=False)
|
||||
|
||||
def send_string(self, string, normalized=True):
|
||||
|
||||
Reference in New Issue
Block a user