Compare commits
8 Commits
9656faa385
...
v1.1.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2abbc503cf | ||
|
|
93253aa564 | ||
|
|
2715ecddbb | ||
|
|
a556dab638 | ||
|
|
e478f41f7c | ||
|
|
7b536a7efa | ||
|
|
40dac967e2 | ||
|
|
8f5564f4aa |
@@ -11,7 +11,7 @@
|
|||||||
| `vterm/render.py` | Screen → view text, colour overlays, caret, folding, trimming. |
|
| `vterm/render.py` | Screen → view text, colour overlays, caret, folding, trimming. |
|
||||||
| `vterm/scheme.py` | `SchemeManager`: generates one theme's colour scheme, the scopes the renderer paints with. `get_scheme_manager` caches one instance per theme name, each terminal frozen to the instance it was opened or rethemed with. |
|
| `vterm/scheme.py` | `SchemeManager`: generates one theme's colour scheme, the scopes the renderer paints with. `get_scheme_manager` caches one instance per theme name, each terminal frozen to the instance it was opened or rethemed with. |
|
||||||
| `vterm/themes.py` | The built-in palettes (`default`, `homebrew`, `pro`, `slate`, `red-alert`). |
|
| `vterm/themes.py` | The built-in palettes (`default`, `homebrew`, `pro`, `slate`, `red-alert`). |
|
||||||
| `vterm/commands.py` | The Sublime commands: open, keypress, copy/paste, theme picker, clear, cleanup, close. |
|
| `vterm/commands.py` | The Sublime commands: open, keypress, copy/paste, theme picker, clear, cleanup. |
|
||||||
| `vterm/listeners.py` | `VtermCoreEventListener`: focus, IME capture, copy/paste rerouting, the mousemap context. |
|
| `vterm/listeners.py` | `VtermCoreEventListener`: focus, IME capture, copy/paste rerouting, the mousemap context. |
|
||||||
| `vterm/mouse.py` | Wheel and click handling. |
|
| `vterm/mouse.py` | Wheel and click handling. |
|
||||||
| `vterm/keys.py` | Key name + modifiers → escape sequence tables. |
|
| `vterm/keys.py` | Key name + modifiers → escape sequence tables. |
|
||||||
@@ -21,14 +21,15 @@
|
|||||||
|
|
||||||
| File | Role |
|
| File | Role |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `vterm.sublime-settings` | Defaults for the eleven plugin settings. |
|
| `vterm.sublime-settings` | Defaults for the twelve plugin settings. |
|
||||||
| `vterm.sublime-commands` | Four palette entries: open, open terminal in project folder, clear screen, select theme. |
|
| `vterm.sublime-commands` | Four palette entries: open, open terminal in project folder, clear screen, select theme. |
|
||||||
| `vterm.sublime-syntax` | An intentionally empty hidden syntax, so terminal text is never tokenized. |
|
| `vterm.sublime-syntax` | An intentionally empty hidden syntax, so terminal text is never tokenized. |
|
||||||
| `Default.sublime-keymap` | 245 generated bindings. **Do not edit by hand** — regenerate with `tools/gen_keymap.py`. |
|
| `Default.sublime-keymap` | 245 generated bindings. **Do not edit by hand** — regenerate with `tools/gen_keymap.py`. |
|
||||||
| `Default (OSX).sublime-mousemap` | Two wheel bindings and the middle button. |
|
| `Default (OSX).sublime-mousemap` | Two wheel bindings and the middle button. |
|
||||||
| `dependencies.json` | Package Control libraries. |
|
| `dependencies.json` | Package Control libraries. |
|
||||||
|
|
||||||
Generated at runtime, not in the repo: `Packages/User/vterm/vterm.hidden-color-scheme`.
|
Generated at runtime, not in the repo: `Packages/User/vterm/vterm-<theme>.hidden-color-scheme`,
|
||||||
|
one per theme (`vterm-system` for "no theme").
|
||||||
|
|
||||||
## Classes
|
## Classes
|
||||||
|
|
||||||
@@ -50,12 +51,13 @@ Generated at runtime, not in the repo: `Packages/User/vterm/vterm.hidden-color-s
|
|||||||
into the next one. Parity is enforced by a differential test (`run_fast_path_cases`).
|
into the next one. Parity is enforced by a differential test (`run_fast_path_cases`).
|
||||||
- **`Cursor`** (`emulator.py`) — replaces pyte's so the default attribute set can be a
|
- **`Cursor`** (`emulator.py`) — replaces pyte's so the default attribute set can be a
|
||||||
`Char` carrying the extra `linefeed` field.
|
`Char` carrying the extra `linefeed` field.
|
||||||
- **`SchemeManager`** (`scheme.py`) — module singleton `scheme_manager`. Owns the
|
- **`SchemeManager`** (`scheme.py`) — one per theme name, cached by `get_scheme_manager`.
|
||||||
generated scheme file and the mapping from colour combination to scope name.
|
Owns the generated scheme file and the mapping from colour combination to scope name.
|
||||||
- **`VtermRenderCommand`** (`render.py`) — Sublime keeps **one command instance per
|
- **`VtermRenderCommand`** (`render.py`) — the per-frame logic. Sublime rebuilds every
|
||||||
view**, so its `colored_lines`, `_row_cache`, `_key_counter`, the scrollback chunk
|
`TextCommand` instance whenever any plugin loads, so the view's render state
|
||||||
bookkeeping (`_archived_chunks`, `_open_rows`, `_open_chunk`, `_trimmed`) and
|
(`RenderState`: `colored_lines`, `row_cache`, `key_counter`, the scrollback chunk
|
||||||
`_lastrow` are that view's render state.
|
bookkeeping `archived_chunks`, `open_rows`, `open_chunk`, `trimmed`) lives on
|
||||||
|
`terminal.render_state`, bound as `self.state` at the top of each `run()`.
|
||||||
|
|
||||||
### Commands
|
### Commands
|
||||||
|
|
||||||
@@ -67,17 +69,15 @@ Generated at runtime, not in the repo: `Packages/User/vterm/vterm.hidden-color-s
|
|||||||
| `vterm_keypress` | `VtermKeypressCommand` | 244 keymap bindings |
|
| `vterm_keypress` | `VtermKeypressCommand` | 244 keymap bindings |
|
||||||
| `vterm_render` | `VtermRenderCommand` | renderer thread |
|
| `vterm_render` | `VtermRenderCommand` | renderer thread |
|
||||||
| `vterm_show_cursor` | `VtermShowCursorCommand` | internal |
|
| `vterm_show_cursor` | `VtermShowCursorCommand` | internal |
|
||||||
| `vterm_copy` / `vterm_paste` / `vterm_paste_text` | `commands.py` | `on_text_command`, IME capture |
|
| `vterm_copy` / `vterm_paste_text` | `commands.py` | `on_text_command`, IME capture |
|
||||||
| `vterm_paste_selection` | `commands.py` | mousemap, middle button |
|
| `vterm_paste_selection` | `commands.py` | mousemap, middle button |
|
||||||
| `vterm_select_theme` | `VtermSelectThemeCommand` | palette |
|
| `vterm_select_theme` | `VtermSelectThemeCommand` | palette |
|
||||||
| `vterm_clear_screen` | `VtermClearScreenCommand` | palette, `super+shift+k` |
|
| `vterm_clear_screen` | `VtermClearScreenCommand` | palette, `super+shift+k` |
|
||||||
| `vterm_cleanup` | `VtermCleanupCommand` | renderer, on process exit |
|
| `vterm_cleanup` | `VtermCleanupCommand` | renderer, on process exit |
|
||||||
| `vterm_close` | `VtermCloseCommand` | nothing — available for a user keybinding |
|
|
||||||
| `vterm_clear_undo_stack` / `vterm_noop` | `commands.py` | internal |
|
| `vterm_clear_undo_stack` / `vterm_noop` | `commands.py` | internal |
|
||||||
| `vterm_scroll` / `vterm_mouse_click` | `mouse.py` | mousemap, `drag_select` interception |
|
| `vterm_scroll` / `vterm_mouse_press` / `vterm_mouse_release` | `mouse.py` | mousemap |
|
||||||
|
|
||||||
Two application-wide `EventListener`s: `VtermCoreEventListener` (`listeners.py`) and
|
One application-wide `EventListener`: `VtermCoreEventListener` (`listeners.py`).
|
||||||
`VtermMouseEventListener` (`mouse.py`).
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -100,7 +100,8 @@ are marked ✅.
|
|||||||
truecolor, `ESC[0m` reset. This is what TUIs emit most.
|
truecolor, `ESC[0m` reset. This is what TUIs emit most.
|
||||||
- **OSC** ✅ — Operating System Command, `ESC ]`, ending at BEL or ST. Carries strings
|
- **OSC** ✅ — Operating System Command, `ESC ]`, ending at BEL or ST. Carries strings
|
||||||
rather than numbers: `OSC 0/2` set the window title, `OSC 10/11` query the default
|
rather than numbers: `OSC 0/2` set the window title, `OSC 10/11` query the default
|
||||||
foreground/background, `OSC 52` reads or writes the system clipboard.
|
foreground/background, `OSC 52` reads or writes the system clipboard (vterm
|
||||||
|
ignores it: untrusted output must not plant a paste).
|
||||||
- **DCS / APC** — Device Control String (`ESC P`) and Application Program Command
|
- **DCS / APC** — Device Control String (`ESC P`) and Application Program Command
|
||||||
(`ESC _`). Long payloads: sixel images, kitty graphics. vterm parses their framing and
|
(`ESC _`). Long payloads: sixel images, kitty graphics. vterm parses their framing and
|
||||||
discards the payload, so an image never leaks into the text.
|
discards the payload, so an image never leaks into the text.
|
||||||
|
|||||||
@@ -11,6 +11,13 @@
|
|||||||
"args": {"up": false},
|
"args": {"up": false},
|
||||||
"context": [{"key": "vterm_view.wheel_captured", "operand": true}]
|
"context": [{"key": "vterm_view.wheel_captured", "operand": true}]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// press reports the click, release reports the drag
|
||||||
|
"button": "button1",
|
||||||
|
"press_command": "vterm_mouse_press",
|
||||||
|
"command": "vterm_mouse_release",
|
||||||
|
"context": [{"key": "vterm_view.mouse_reporting", "operand": true}]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// press_command: sublime's own button3 binding is a column drag_select,
|
// press_command: sublime's own button3 binding is a column drag_select,
|
||||||
// which would clear the selection before a release command could read it
|
// which would clear the selection before a release command could read it
|
||||||
|
|||||||
27
README.md
27
README.md
@@ -79,15 +79,16 @@ or turns it off. Copy/paste use the normal Sublime keys, and pastes are
|
|||||||
bracketed when the app asks for it. Copying re-joins soft-wrapped lines. The
|
bracketed when the app asks for it. Copying re-joins soft-wrapped lines. The
|
||||||
middle button pastes the current selection straight into the app, X11-style,
|
middle button pastes the current selection straight into the app, X11-style,
|
||||||
without going near the clipboard; it does nothing when nothing is selected. The
|
without going near the clipboard; it does nothing when nothing is selected. The
|
||||||
wheel scrolls the view normally; while the app tracks the mouse the wheel and
|
wheel scrolls the view normally; while the app tracks the mouse the wheel, clicks
|
||||||
left click go to it instead, and on the alternate screen without tracking each
|
and drags go to it instead (Claude Code's own mouse selection and copy work as in
|
||||||
wheel tick sends arrow keys (alternate scroll, as kitty/iTerm2 do — this is
|
kitty), and on the alternate screen without tracking each wheel tick sends arrow
|
||||||
what scrolls Claude Code's fullscreen UI before it enables mouse reporting).
|
keys (alternate scroll, as kitty/iTerm2 do — this is what scrolls Claude Code's
|
||||||
|
fullscreen UI before it enables mouse reporting).
|
||||||
Sublime raises no wheel event at all for the trackpad (sublime_text#1838): set
|
Sublime raises no wheel event at all for the trackpad (sublime_text#1838): set
|
||||||
a utility such as [LinearMouse](https://linearmouse.app/) to discrete scrolling
|
a utility such as [LinearMouse](https://linearmouse.app/) to discrete scrolling
|
||||||
for Sublime Text and it arrives as wheel notches like any other. A notch sends the
|
for Sublime Text and it arrives as wheel notches like any other. A notch sends the
|
||||||
app three scroll lines, as iTerm2 does; `wheel_lines` lowers that when a swipe
|
app one scroll line; `wheel_lines` raises that (iTerm2 sends 3) when the wheel
|
||||||
scrolls too far. While an app owns the
|
feels slow. While an app owns the
|
||||||
alternate screen the scrollback above it is folded away, kitty-style, and
|
alternate screen the scrollback above it is folded away, kitty-style, and
|
||||||
unfolds when the app leaves it.
|
unfolds when the app leaves it.
|
||||||
|
|
||||||
@@ -125,7 +126,8 @@ terminal protocols — see `Architecture.md`.
|
|||||||
multi-digit OSC. It answers DA1 (Claude Code's query sentinel, which has no
|
multi-digit OSC. It answers DA1 (Claude Code's query sentinel, which has no
|
||||||
timeout) and DA2, CPR, XTVERSION + DECRQM 2026 (this is what unlocks Claude
|
timeout) and DA2, CPR, XTVERSION + DECRQM 2026 (this is what unlocks Claude
|
||||||
Code's flicker-free synchronized output on a generic `TERM`), the kitty keyboard
|
Code's flicker-free synchronized output on a generic `TERM`), the kitty keyboard
|
||||||
probe, and OSC 10/11 colour queries; OSC 52 goes to the Sublime clipboard.
|
probe, and OSC 10/11 colour queries; OSC 52 (clipboard write) is ignored,
|
||||||
|
since any output could plant a command to paste.
|
||||||
Combining characters are merged as NFC, because Sublime draws decomposed
|
Combining characters are merged as NFC, because Sublime draws decomposed
|
||||||
sequences badly.
|
sequences badly.
|
||||||
- `vterm/terminal.py` — pty process, reader and renderer threads at ~33 Hz.
|
- `vterm/terminal.py` — pty process, reader and renderer threads at ~33 Hz.
|
||||||
@@ -149,15 +151,16 @@ terminal protocols — see `Architecture.md`.
|
|||||||
into the view: an app that does not echo (vim in normal mode) would otherwise
|
into the view: an app that does not echo (vim in normal mode) would otherwise
|
||||||
leave the character behind. `on_modified` in `vterm/listeners.py` remains the
|
leave the character behind. `on_modified` in `vterm/listeners.py` remains the
|
||||||
fallback for IME and composed input.
|
fallback for IME and composed input.
|
||||||
- `vterm/mouse.py` — SGR mouse reporting (wheel, click) when the app enables
|
- `vterm/mouse.py` — SGR mouse reporting (wheel, click, drag) when the app enables
|
||||||
tracking; native selection and scrolling otherwise. The trackpad is not
|
tracking; native selection and scrolling otherwise. The trackpad is not
|
||||||
handled: it reaches no mousemap on macOS and is converted to wheel notches
|
handled: it reaches no mousemap on macOS and is converted to wheel notches
|
||||||
outside the plugin.
|
outside the plugin.
|
||||||
|
|
||||||
Supported: alt screen (1049), synchronized output (2026), bracketed paste (2004),
|
Supported: alt screen (1049), synchronized output (2026), bracketed paste (2004),
|
||||||
mouse tracking 1000/1002/1003 + SGR 1006 (wheel and left click are reported; no
|
mouse tracking 1000/1002/1003 + SGR 1006 (wheel and left button are reported; a
|
||||||
drag, no right button; the middle button pastes the selection instead of being
|
drag arrives as one motion at release, since Sublime raises none; no right
|
||||||
reported), focus events (1004),
|
button; the middle button pastes the selection instead of being reported),
|
||||||
|
focus events (1004),
|
||||||
xterm modifyOtherKeys (`CSI > 4 ; n m`, for the modified keys with no
|
xterm modifyOtherKeys (`CSI > 4 ; n m`, for the modified keys with no
|
||||||
unambiguous legacy encoding: enter, tab, backspace, space), cursor styles (DECSCUSR —
|
unambiguous legacy encoding: enter, tab, backspace, space), cursor styles (DECSCUSR —
|
||||||
block/bar and blink/steady via the view's caret settings; underline falls back
|
block/bar and blink/steady via the view's caret settings; underline falls back
|
||||||
@@ -207,6 +210,6 @@ python3 benchmarks/profiling.py # feed-path hotspots, per-char micro-b
|
|||||||
`harness_live.py` is the useful one: it drives a real `pi` or `claude` through the
|
`harness_live.py` is the useful one: it drives a real `pi` or `claude` through the
|
||||||
emulator with no Sublime involved, and prints the queries answered plus the
|
emulator with no Sublime involved, and prints the queries answered plus the
|
||||||
resulting screen. Tests import pyte/ptyprocess/wcwidth from Sublime's
|
resulting screen. Tests import pyte/ptyprocess/wcwidth from Sublime's
|
||||||
`Lib/python38` (pure Python, so any Python 3 runs them).
|
`Lib/python3*` (pure Python, so any Python 3 runs them).
|
||||||
|
|
||||||
Code style is in `CODE.md`, working agreements in `CLAUDE.md`.
|
Code style is in `CODE.md`, working agreements in `CLAUDE.md`.
|
||||||
|
|||||||
@@ -296,11 +296,12 @@ def drive(feed, need_to_render, render, data, tick):
|
|||||||
def run_vterm(data, tick):
|
def run_vterm(data, tick):
|
||||||
from vterm.emulator import VtScreen, VtStream
|
from vterm.emulator import VtScreen, VtStream
|
||||||
from vterm.render import VtermRenderCommand
|
from vterm.render import VtermRenderCommand
|
||||||
|
from vterm.scheme import get_scheme_manager
|
||||||
from vterm.terminal import Terminal
|
from vterm.terminal import Terminal
|
||||||
|
|
||||||
counter = collections.Counter()
|
counter = collections.Counter()
|
||||||
view = CountingView(counter)
|
view = CountingView(counter)
|
||||||
terminal = Terminal(view)
|
terminal = Terminal(view, get_scheme_manager("default"))
|
||||||
terminal.scrollback_size = SCROLLBACK
|
terminal.scrollback_size = SCROLLBACK
|
||||||
terminal.process = FakeProcess()
|
terminal.process = FakeProcess()
|
||||||
terminal.screen = VtScreen(COLUMNS, ROWS, process=terminal.process, history=SCROLLBACK)
|
terminal.screen = VtScreen(COLUMNS, ROWS, process=terminal.process, history=SCROLLBACK)
|
||||||
|
|||||||
6
main.py
6
main.py
@@ -12,22 +12,20 @@ from .vterm.commands import (
|
|||||||
VtermClearScreenCommand,
|
VtermClearScreenCommand,
|
||||||
VtermClearUndoStackCommand,
|
VtermClearUndoStackCommand,
|
||||||
VtermCleanupCommand,
|
VtermCleanupCommand,
|
||||||
VtermCloseCommand,
|
|
||||||
VtermCopyCommand,
|
VtermCopyCommand,
|
||||||
VtermInitializeViewCommand,
|
VtermInitializeViewCommand,
|
||||||
VtermKeypressCommand,
|
VtermKeypressCommand,
|
||||||
VtermNoopCommand,
|
VtermNoopCommand,
|
||||||
VtermOpenCommand,
|
VtermOpenCommand,
|
||||||
VtermOpenTerminalProjectFolderCommand,
|
VtermOpenTerminalProjectFolderCommand,
|
||||||
VtermPasteCommand,
|
|
||||||
VtermPasteSelectionCommand,
|
VtermPasteSelectionCommand,
|
||||||
VtermPasteTextCommand,
|
VtermPasteTextCommand,
|
||||||
VtermSelectThemeCommand,
|
VtermSelectThemeCommand,
|
||||||
)
|
)
|
||||||
from .vterm.listeners import VtermCoreEventListener
|
from .vterm.listeners import VtermCoreEventListener
|
||||||
from .vterm.mouse import (
|
from .vterm.mouse import (
|
||||||
VtermMouseClickCommand,
|
VtermMousePressCommand,
|
||||||
VtermMouseEventListener,
|
VtermMouseReleaseCommand,
|
||||||
VtermScrollCommand,
|
VtermScrollCommand,
|
||||||
)
|
)
|
||||||
from .vterm.scheme import build_startup_schemes
|
from .vterm.scheme import build_startup_schemes
|
||||||
|
|||||||
@@ -3,14 +3,16 @@
|
|||||||
They run as plain scripts (see README), so each imports this first to get the
|
They run as plain scripts (see README), so each imports this first to get the
|
||||||
sublime stubs and the Package Control libraries on the path.
|
sublime stubs and the Package Control libraries on the path.
|
||||||
"""
|
"""
|
||||||
|
import glob
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
ST_LIB = os.path.expanduser(
|
# Package Control puts the libraries under the plugin host's own directory
|
||||||
"~/Library/Application Support/Sublime Text/Lib/python38")
|
ST_LIBS = sorted(glob.glob(os.path.expanduser(
|
||||||
|
"~/Library/Application Support/Sublime Text/Lib/python3*")))
|
||||||
|
|
||||||
for path in (ROOT, ST_LIB, os.path.join(ROOT, "tests", "stubs")):
|
for path in [ROOT] + ST_LIBS + [os.path.join(ROOT, "tests", "stubs")]:
|
||||||
if path not in sys.path:
|
if path not in sys.path:
|
||||||
sys.path.insert(0, path)
|
sys.path.insert(0, path)
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import _bootstrap # sets sys.path for the imports below
|
|||||||
from _bootstrap import FakeProcess
|
from _bootstrap import FakeProcess
|
||||||
|
|
||||||
import sublime
|
import sublime
|
||||||
from vterm.commands import VtermPasteSelectionCommand, VtermSelectThemeCommand, selection_text
|
from vterm.commands import VtermPasteSelectionCommand, VtermPasteTextCommand, VtermSelectThemeCommand, selection_text
|
||||||
from vterm.emulator import VtScreen
|
from vterm.emulator import VtScreen
|
||||||
from vterm.render import CONTINUATION
|
from vterm.render import CONTINUATION
|
||||||
from vterm.scheme import get_scheme_manager
|
from vterm.scheme import get_scheme_manager
|
||||||
@@ -25,6 +25,7 @@ class FakeTerminalView:
|
|||||||
self._id = FakeTerminalView._next_id
|
self._id = FakeTerminalView._next_id
|
||||||
FakeTerminalView._next_id += 1
|
FakeTerminalView._next_id += 1
|
||||||
self._settings = sublime.Settings()
|
self._settings = sublime.Settings()
|
||||||
|
self._settings.set("vterm_view", True)
|
||||||
|
|
||||||
def id(self):
|
def id(self):
|
||||||
return self._id
|
return self._id
|
||||||
@@ -36,7 +37,24 @@ class FakeTerminalView:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def style(self):
|
def style(self):
|
||||||
return {}
|
# a terminal view reports its own theme's colours, not the editor's
|
||||||
|
return {"foreground": "#f2f2f2", "background": "#000000"}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeEditorView:
|
||||||
|
"""An ordinary file view, the one the "No theme" palette is sampled from."""
|
||||||
|
|
||||||
|
def settings(self):
|
||||||
|
return sublime.Settings()
|
||||||
|
|
||||||
|
def is_valid(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def style(self):
|
||||||
|
return {"foreground": "#111111", "background": "#eeeeee"}
|
||||||
|
|
||||||
|
|
||||||
|
editor = FakeEditorView()
|
||||||
|
|
||||||
|
|
||||||
class FakeWindow:
|
class FakeWindow:
|
||||||
@@ -46,6 +64,9 @@ class FakeWindow:
|
|||||||
def active_view(self):
|
def active_view(self):
|
||||||
return self._view
|
return self._view
|
||||||
|
|
||||||
|
def views(self):
|
||||||
|
return [self._view, editor]
|
||||||
|
|
||||||
def show_quick_panel(self, items, on_done, selected_index=-1, on_highlight=None):
|
def show_quick_panel(self, items, on_done, selected_index=-1, on_highlight=None):
|
||||||
panel.update(items=items, on_done=on_done,
|
panel.update(items=items, on_done=on_done,
|
||||||
selected_index=selected_index, on_highlight=on_highlight)
|
selected_index=selected_index, on_highlight=on_highlight)
|
||||||
@@ -105,6 +126,12 @@ command = open_picker(fake_terminal("slate"))
|
|||||||
panel["on_done"](0)
|
panel["on_done"](0)
|
||||||
assert settings.get("theme") is None, settings.get("theme")
|
assert settings.get("theme") is None, settings.get("theme")
|
||||||
|
|
||||||
|
# ... and samples the editor's colours, not the themed terminal's own: that
|
||||||
|
# would retint every "no theme" terminal with the theme being left
|
||||||
|
system = get_scheme_manager(None)
|
||||||
|
assert (system.foreground, system.background) == ("#111111", "#eeeeee"), \
|
||||||
|
(system.foreground, system.background)
|
||||||
|
|
||||||
print("theme picker tests passed ({} themes offered)".format(len(THEMES)))
|
print("theme picker tests passed ({} themes offered)".format(len(THEMES)))
|
||||||
|
|
||||||
|
|
||||||
@@ -218,3 +245,17 @@ view = FakeView("abcdef", [sublime.Region(2, 2)])
|
|||||||
assert paste(view) == [], view.commands
|
assert paste(view) == [], view.commands
|
||||||
|
|
||||||
print("selection tests passed (sentinel stripping, paste, empty no-op)")
|
print("selection tests passed (sentinel stripping, paste, empty no-op)")
|
||||||
|
|
||||||
|
|
||||||
|
# a paste is a trust boundary: C0 controls bar tab/LF/CR go, so a pasted
|
||||||
|
# ESC[201~ cannot close the bracket and type the rest into the app
|
||||||
|
terminal = fake_terminal("default")
|
||||||
|
terminal.process = terminal.screen._process
|
||||||
|
terminal.screen.set_mode(2004, private=True)
|
||||||
|
terminal.view.run_command = lambda name, args=None: None
|
||||||
|
command = VtermPasteTextCommand.__new__(VtermPasteTextCommand)
|
||||||
|
command.view = terminal.view
|
||||||
|
command.run(None, "a\x1b[201~b\x00\tc\nd")
|
||||||
|
assert terminal.process.written == "\x1b[200~a[201~b\tc\rd\x1b[201~", repr(terminal.process.written)
|
||||||
|
|
||||||
|
print("paste tests passed (controls stripped, bracket kept whole)")
|
||||||
|
|||||||
@@ -83,13 +83,11 @@ def run_case(char_by_char):
|
|||||||
assert proc.written == "\x1b]11;rgb:1a1a/2b2b/3c3c\x1b\\", (tag, proc.written)
|
assert proc.written == "\x1b]11;rgb:1a1a/2b2b/3c3c\x1b\\", (tag, proc.written)
|
||||||
assert screen.buffer[0][0].data == "y", tag
|
assert screen.buffer[0][0].data == "y", tag
|
||||||
|
|
||||||
# OSC 52 clipboard (base64 "hi")
|
# OSC 52 (clipboard write) is dropped, not drawn: untrusted output must
|
||||||
got = []
|
# not plant a paste
|
||||||
proc = FakeProcess()
|
proc, screen, stream = make()
|
||||||
screen = VtScreen(80, 24, process=proc, clipboard_callback=got.append)
|
feed(stream, "\x1b]52;c;aGk=\x07z", char_by_char)
|
||||||
stream = VtStream(screen)
|
assert screen.buffer[0][0].data == "z" and proc.written == "", tag
|
||||||
feed(stream, "\x1b]52;c;aGk=\x07", char_by_char)
|
|
||||||
assert got == ["hi"], (tag, got)
|
|
||||||
|
|
||||||
# bracketed paste and sync modes tracked
|
# bracketed paste and sync modes tracked
|
||||||
proc, screen, stream = make()
|
proc, screen, stream = make()
|
||||||
@@ -119,6 +117,21 @@ def run_case(char_by_char):
|
|||||||
assert screen.buffer[0][0].fg == "ff0000" and screen.buffer[0][0].bold, tag
|
assert screen.buffer[0][0].fg == "ff0000" and screen.buffer[0][0].bold, tag
|
||||||
assert screen.buffer[0][1].fg == "ff0000", (tag, screen.buffer[0][1].fg)
|
assert screen.buffer[0][1].fg == "ff0000", (tag, screen.buffer[0][1].fg)
|
||||||
|
|
||||||
|
# every grey lands inside the palette; 248 used to map to slot 256 and
|
||||||
|
# abort the whole SGR
|
||||||
|
from vterm.emulator import quantize_256
|
||||||
|
assert all(0 <= quantize_256(v, v, v) <= 255 for v in range(256))
|
||||||
|
proc, screen, stream = make()
|
||||||
|
feed(stream, "\x1b[38;2;248;248;248mX", char_by_char)
|
||||||
|
assert screen.buffer[0][0].fg == "ffffff", (tag, screen.buffer[0][0].fg)
|
||||||
|
|
||||||
|
# a zero-width non-combining char (VS16, ZWJ) or a stray control byte is
|
||||||
|
# dropped; the text after it must survive
|
||||||
|
proc, screen, stream = make()
|
||||||
|
feed(stream, "⚠️ warn \U0001F468\U0001F4BB ab\x01cd", char_by_char)
|
||||||
|
row = "".join(screen.buffer[0][x].data for x in range(20)).rstrip()
|
||||||
|
assert row == "⚠ warn \U0001F468\U0001F4BB abcd", (tag, repr(row))
|
||||||
|
|
||||||
# SGR 5/25 (blink): ignored, not a crash — Char has no blink field
|
# SGR 5/25 (blink): ignored, not a crash — Char has no blink field
|
||||||
proc, screen, stream = make()
|
proc, screen, stream = make()
|
||||||
feed(stream, "\x1b[5;31mX\x1b[25mY", char_by_char)
|
feed(stream, "\x1b[5;31mX\x1b[25mY", char_by_char)
|
||||||
@@ -141,6 +154,11 @@ def run_case(char_by_char):
|
|||||||
assert len(screen.history) == 1, (tag, len(screen.history))
|
assert len(screen.history) == 1, (tag, len(screen.history))
|
||||||
assert screen.history[0][0].data == "a", tag
|
assert screen.history[0][0].data == "a", tag
|
||||||
|
|
||||||
|
# an explicit full-screen scroll region still feeds scrollback
|
||||||
|
proc, screen, stream = make(10, 3)
|
||||||
|
feed(stream, "\x1b[1;3ra\r\nb\r\nc\r\nd", char_by_char)
|
||||||
|
assert len(screen.history) == 1, (tag, len(screen.history))
|
||||||
|
|
||||||
# ED 3 clears history and fires callback
|
# ED 3 clears history and fires callback
|
||||||
cleared = []
|
cleared = []
|
||||||
proc = FakeProcess()
|
proc = FakeProcess()
|
||||||
@@ -310,7 +328,7 @@ GAUNTLET = (
|
|||||||
"\x1b[2;5r\x1b[Sscrolled\x1b[T\x1b[r"
|
"\x1b[2;5r\x1b[Sscrolled\x1b[T\x1b[r"
|
||||||
"\x1b[38:5:100mcolon\x1b[m\r\n"
|
"\x1b[38:5:100mcolon\x1b[m\r\n"
|
||||||
"\x1b[2\x073m bel mid-CSI \x1b[3\x18X can cancels\r\n"
|
"\x1b[2\x073m bel mid-CSI \x1b[3\x18X can cancels\r\n"
|
||||||
"wide: 漢字 \x1b[7m漢\x1b[m\r\n"
|
"wide: 漢字 \x1b[7m漢\x1b[m ⚠️ after\r\n"
|
||||||
"\x1b[m\x1b[;5m\x1b[5;m\x1b[25m params\r\n"
|
"\x1b[m\x1b[;5m\x1b[5;m\x1b[25m params\r\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -436,8 +454,67 @@ def run_clear_cases():
|
|||||||
print("all clear-screen tests passed")
|
print("all clear-screen tests passed")
|
||||||
|
|
||||||
|
|
||||||
|
def run_parser_edge_cases():
|
||||||
|
"""Sequences the stock collector misread: intermediates, `:` subparams, REP, marks."""
|
||||||
|
for char_by_char in (False, True):
|
||||||
|
tag = "char-by-char" if char_by_char else "chunked"
|
||||||
|
|
||||||
|
# DECSTR (`reset` sends it): `!` is an intermediate, the `p` must not be drawn
|
||||||
|
proc, screen, stream = make(20, 4)
|
||||||
|
feed(stream, "\x1b[!pX", char_by_char)
|
||||||
|
assert screen.display[0].rstrip() == "X", (tag, repr(screen.display[0]))
|
||||||
|
|
||||||
|
# `4:3` is curly underline, not SGR 43 (yellow background)
|
||||||
|
proc, screen, stream = make(20, 4)
|
||||||
|
feed(stream, "\x1b[4:3mX\x1b[38:2:1:2:3mY", char_by_char)
|
||||||
|
cell = screen.buffer[0][0]
|
||||||
|
assert (cell.bg, cell.underscore) == ("default", True), (tag, cell)
|
||||||
|
assert screen.buffer[0][1].data == "Y", (tag, screen.buffer[0][1])
|
||||||
|
|
||||||
|
# REP repeats the last printable; ncurses emits it for runs
|
||||||
|
proc, screen, stream = make(20, 4)
|
||||||
|
feed(stream, "a\x1b[3b\x1b[31mb\x1b[b", char_by_char)
|
||||||
|
assert screen.display[0].rstrip() == "aaaabb", (tag, repr(screen.display[0]))
|
||||||
|
assert screen.buffer[0][5].fg == "red", (tag, screen.buffer[0][5])
|
||||||
|
|
||||||
|
# a Thai vowel sign has combining class 0 but is still a mark: merged, not dropped
|
||||||
|
proc, screen, stream = make(20, 4)
|
||||||
|
feed(stream, "\u0e01\u0e31x", char_by_char)
|
||||||
|
assert screen.buffer[0][0].data == "\u0e01\u0e31", (tag, screen.buffer[0][0])
|
||||||
|
assert screen.buffer[0][1].data == "x", (tag, screen.buffer[0][1])
|
||||||
|
|
||||||
|
# DECRQM: the modes we honour answer set/reset, never "unrecognized"
|
||||||
|
proc, screen, stream = make()
|
||||||
|
feed(stream, "\x1b[?2031$p\x1b[?1h\x1b[?1$p\x1b[4$p\x1b[20$p", char_by_char)
|
||||||
|
assert proc.written == "\x1b[?2031;2$y\x1b[?1;1$y\x1b[4;2$y\x1b[20;2$y", (tag, proc.written)
|
||||||
|
|
||||||
|
# RIS on the alternate screen lands back on a cleared primary screen
|
||||||
|
proc, screen, stream = make(20, 4)
|
||||||
|
feed(stream, "primary\x1b[?1049halt\x1bc", char_by_char)
|
||||||
|
assert not screen.alternate_buffer_mode and not screen.savepoints, tag
|
||||||
|
assert screen.display[0].strip() == "", (tag, repr(screen.display[0]))
|
||||||
|
feed(stream, "\r\n" * 4, char_by_char)
|
||||||
|
assert len(screen.history) == 1, (tag, len(screen.history))
|
||||||
|
|
||||||
|
# SCOSC/SCORC, and CPR relative to the region under DECOM
|
||||||
|
proc, screen, stream = make(20, 4)
|
||||||
|
feed(stream, "ab\x1b[s\x1b[3;3H\x1b[uX\x1b[2;3r\x1b[?6h\x1b[6n", char_by_char)
|
||||||
|
assert screen.display[0].rstrip() == "abX", (tag, repr(screen.display[0]))
|
||||||
|
assert proc.written == "\x1b[1;1R", (tag, proc.written)
|
||||||
|
|
||||||
|
# a string with no terminator ends at the next ESC, as in xterm; a stray
|
||||||
|
# ESC P from a binary must not swallow the rest of the session
|
||||||
|
proc, screen, stream = make(20, 4)
|
||||||
|
feed(stream, "\x1bPgarbage\x1bcX\x1b]0;title\x1b[1mY", char_by_char)
|
||||||
|
assert screen.display[0].rstrip() == "XY", (tag, repr(screen.display[0]))
|
||||||
|
assert screen.title == "title" and screen.buffer[0][1].bold, (tag, screen.title)
|
||||||
|
|
||||||
|
print("all parser edge-case tests passed")
|
||||||
|
|
||||||
|
|
||||||
run_case(char_by_char=False)
|
run_case(char_by_char=False)
|
||||||
run_case(char_by_char=True)
|
run_case(char_by_char=True)
|
||||||
|
run_parser_edge_cases()
|
||||||
run_clear_cases()
|
run_clear_cases()
|
||||||
run_resize_cases()
|
run_resize_cases()
|
||||||
run_private_csi_cases()
|
run_private_csi_cases()
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ from _bootstrap import FakeProcess
|
|||||||
import sublime
|
import sublime
|
||||||
from vterm.commands import VtermKeypressCommand
|
from vterm.commands import VtermKeypressCommand
|
||||||
from vterm.emulator import VtScreen, VtStream
|
from vterm.emulator import VtScreen, VtStream
|
||||||
from vterm.mouse import VtermMouseClickCommand, VtermScrollCommand
|
from vterm.mouse import VtermMousePressCommand, VtermMouseReleaseCommand, VtermScrollCommand
|
||||||
from vterm.render import (
|
from vterm.render import (
|
||||||
CHUNK,
|
CHUNK,
|
||||||
|
RenderState,
|
||||||
VtermRenderCommand,
|
VtermRenderCommand,
|
||||||
focus_cursor,
|
focus_cursor,
|
||||||
following,
|
following,
|
||||||
@@ -171,6 +172,8 @@ def make_command(terminal):
|
|||||||
# run() itself sets _scheme, but these tests call its helpers directly
|
# run() itself sets _scheme, but these tests call its helpers directly
|
||||||
command = VtermRenderCommand(terminal.view)
|
command = VtermRenderCommand(terminal.view)
|
||||||
command._scheme = terminal.scheme
|
command._scheme = terminal.scheme
|
||||||
|
terminal.render_state = RenderState()
|
||||||
|
command.state = terminal.render_state
|
||||||
return command
|
return command
|
||||||
|
|
||||||
|
|
||||||
@@ -188,17 +191,30 @@ command.trim_history(None, terminal)
|
|||||||
assert terminal.view.text == before, \
|
assert terminal.view.text == before, \
|
||||||
"alt screen view was trimmed: {!r}".format(terminal.view.text)
|
"alt screen view was trimmed: {!r}".format(terminal.view.text)
|
||||||
|
|
||||||
# the configured cap still applies on the primary screen
|
# the configured cap still applies on the primary screen: that many rows of
|
||||||
|
# scrollback above the screen
|
||||||
terminal, stream = make_terminal(rows=5, scrollback=20)
|
terminal, stream = make_terminal(rows=5, scrollback=20)
|
||||||
terminal.offset = 40
|
terminal.offset = 40
|
||||||
terminal.view = FakeView("\n".join("row {}".format(i) for i in range(40)))
|
terminal.view = FakeView("\n".join("row {}".format(i) for i in range(40)))
|
||||||
command = make_command(terminal)
|
command = make_command(terminal)
|
||||||
command.trim_history(None, terminal)
|
command.trim_history(None, terminal)
|
||||||
rows_left = terminal.view.text.count("\n") + 1
|
rows_left = terminal.view.text.count("\n") + 1
|
||||||
assert rows_left <= 20, rows_left
|
assert rows_left <= 25, rows_left
|
||||||
assert "row 39" in terminal.view.text, "newest rows must survive"
|
assert "row 39" in terminal.view.text, "newest rows must survive"
|
||||||
assert "row 0" not in terminal.view.text, "oldest rows must be dropped"
|
assert "row 0" not in terminal.view.text, "oldest rows must be dropped"
|
||||||
|
|
||||||
|
# a cap below the screen height (0: no scrollback) must never cut the screen
|
||||||
|
terminal, stream = make_terminal(rows=10, cols=20, scrollback=0)
|
||||||
|
command = make_command(terminal)
|
||||||
|
for i in range(3):
|
||||||
|
stream.feed("\r\n".join("row{}".format(i * 10 + j) for j in range(10)) + "\r\n")
|
||||||
|
command.update_lines(None, terminal)
|
||||||
|
command.trim_trailing_spaces(None, terminal)
|
||||||
|
command.trim_history(None, terminal)
|
||||||
|
terminal.screen.dirty.clear()
|
||||||
|
assert terminal.offset >= 0, terminal.offset
|
||||||
|
assert "row29" in terminal.view.text, terminal.view.text
|
||||||
|
|
||||||
# below the cap nothing is dropped
|
# below the cap nothing is dropped
|
||||||
terminal, stream = make_terminal(rows=5, scrollback=5000)
|
terminal, stream = make_terminal(rows=5, scrollback=5000)
|
||||||
terminal.offset = 3
|
terminal.offset = 3
|
||||||
@@ -282,7 +298,7 @@ assert terminal.view.text.startswith("RED..red..green"), repr(terminal.view.text
|
|||||||
assert len(painted) == 2, painted
|
assert len(painted) == 2, painted
|
||||||
|
|
||||||
# keys are unique per view and recorded so decolorize_line can erase them
|
# keys are unique per view and recorded so decolorize_line can erase them
|
||||||
assert sorted(terminal.view.regions) == sorted(command.colored_lines[0]), \
|
assert sorted(terminal.view.regions) == sorted(command.state.colored_lines[0]), \
|
||||||
"colored_lines must track exactly the keys that were added"
|
"colored_lines must track exactly the keys that were added"
|
||||||
command.decolorize_line(0)
|
command.decolorize_line(0)
|
||||||
assert terminal.view.regions == {}, "decolorize must erase every key it added"
|
assert terminal.view.regions == {}, "decolorize must erase every key it added"
|
||||||
@@ -290,6 +306,29 @@ assert terminal.view.regions == {}, "decolorize must erase every key it added"
|
|||||||
print("colorize tests passed (arithmetic offsets, scope batching, key bookkeeping)")
|
print("colorize tests passed (arithmetic offsets, scope batching, key bookkeeping)")
|
||||||
|
|
||||||
|
|
||||||
|
# sublime rebuilds every TextCommand instance whenever any plugin loads; the
|
||||||
|
# bookkeeping lives on the terminal so a rebuilt command still erases what the
|
||||||
|
# old one painted, and its keys never collide with keys still in use
|
||||||
|
terminal, stream = make_terminal(rows=3, cols=20)
|
||||||
|
terminal.view = FakeView()
|
||||||
|
command = make_command(terminal)
|
||||||
|
stream.feed("\x1b[31mred line\x1b[0m\r\nplain")
|
||||||
|
command.update_lines(None, terminal)
|
||||||
|
terminal.screen.dirty.clear()
|
||||||
|
assert terminal.view.regions, "the red row must be painted"
|
||||||
|
# a reload: new command, same terminal
|
||||||
|
reloaded = VtermRenderCommand(terminal.view)
|
||||||
|
reloaded._scheme = terminal.scheme
|
||||||
|
reloaded.state = terminal.render_state
|
||||||
|
stream.feed("\x1b[H\x1b[2Kgone")
|
||||||
|
reloaded.update_lines(None, terminal)
|
||||||
|
assert terminal.view.regions == {}, \
|
||||||
|
"a rebuilt command must erase the old overlay: {}".format(terminal.view.regions)
|
||||||
|
assert reloaded.next_key() != "vterm#1", "keys must continue, not restart"
|
||||||
|
|
||||||
|
print("reload tests passed (render state survives a rebuilt command)")
|
||||||
|
|
||||||
|
|
||||||
# rows that leave the screen have their colours merged onto shared per-(scope,
|
# rows that leave the screen have their colours merged onto shared per-(scope,
|
||||||
# chunk) keys once the chunk closes; per-row keys would otherwise accumulate for
|
# chunk) keys once the chunk closes; per-row keys would otherwise accumulate for
|
||||||
# the whole session, and merging every frame would re-send the whole chunk
|
# the whole session, and merging every frame would re-send the whole chunk
|
||||||
@@ -318,10 +357,16 @@ command = make_command(terminal)
|
|||||||
stream.feed("".join("\x1b[31mr{}\x1b[0m\r\n".format(i) for i in range(300)))
|
stream.feed("".join("\x1b[31mr{}\x1b[0m\r\n".format(i) for i in range(300)))
|
||||||
command.update_lines(None, terminal)
|
command.update_lines(None, terminal)
|
||||||
terminal.screen.dirty.clear()
|
terminal.screen.dirty.clear()
|
||||||
assert set(command._archived_chunks) == {0}, command._archived_chunks
|
# rows new to the view went in as one insert, coloured straight onto their
|
||||||
|
# chunk keys; only row 0 and the two coloured screen rows have a key of their own
|
||||||
|
assert set(command.state.archived_chunks) == {0, 1}, command.state.archived_chunks
|
||||||
|
assert len(terminal.view.regions["vterm@1@vterm.red.default"][1]) == terminal.offset - CHUNK
|
||||||
|
row_keys = [k for k in terminal.view.regions if k.startswith("vterm#")]
|
||||||
|
assert len(row_keys) == 3, row_keys
|
||||||
|
assert terminal.view.text.split("\n")[:3] == ["r0", "r1", "r2"], terminal.view.text[:20]
|
||||||
command.trim_history(None, terminal)
|
command.trim_history(None, terminal)
|
||||||
assert "vterm@0@vterm.red.default" not in terminal.view.regions, "trimmed chunk key left behind"
|
assert "vterm@0@vterm.red.default" not in terminal.view.regions, "trimmed chunk key left behind"
|
||||||
assert 0 not in command._archived_chunks
|
assert 0 not in command.state.archived_chunks
|
||||||
assert "vterm@1@vterm.red.default" in terminal.view.regions, "boundary chunk must survive"
|
assert "vterm@1@vterm.red.default" in terminal.view.regions, "boundary chunk must survive"
|
||||||
|
|
||||||
print("scrollback archive tests passed (chunk keys bounded, trim erases whole chunks)")
|
print("scrollback archive tests passed (chunk keys bounded, trim erases whole chunks)")
|
||||||
@@ -416,6 +461,8 @@ Terminal._terminals[id(terminal)] = terminal
|
|||||||
terminal.view.id = lambda: id(terminal)
|
terminal.view.id = lambda: id(terminal)
|
||||||
scroll = VtermScrollCommand.__new__(VtermScrollCommand)
|
scroll = VtermScrollCommand.__new__(VtermScrollCommand)
|
||||||
scroll.view = terminal.view
|
scroll.view = terminal.view
|
||||||
|
# three per notch, so "one report per line" is visible
|
||||||
|
sublime.load_settings(SETTINGS_FILE).set("wheel_lines", 3)
|
||||||
|
|
||||||
stream.feed("\x1b[?1049h")
|
stream.feed("\x1b[?1049h")
|
||||||
scroll.run(None, up=True)
|
scroll.run(None, up=True)
|
||||||
@@ -600,19 +647,39 @@ terminal.view.window_to_text = lambda xy: 0
|
|||||||
window = FakeWindow(active=object())
|
window = FakeWindow(active=object())
|
||||||
terminal.view.window = lambda: window
|
terminal.view.window = lambda: window
|
||||||
stream.feed("\x1b[?1002h\x1b[?1006h")
|
stream.feed("\x1b[?1002h\x1b[?1006h")
|
||||||
click = VtermMouseClickCommand.__new__(VtermMouseClickCommand)
|
mouse_press = VtermMousePressCommand.__new__(VtermMousePressCommand)
|
||||||
click.view = terminal.view
|
mouse_press.view = terminal.view
|
||||||
|
mouse_release = VtermMouseReleaseCommand.__new__(VtermMouseReleaseCommand)
|
||||||
|
mouse_release.view = terminal.view
|
||||||
event = {"x": 0, "y": 0, "button": 1}
|
event = {"x": 0, "y": 0, "button": 1}
|
||||||
|
|
||||||
click.run(None, event)
|
mouse_press.run(None, event)
|
||||||
assert window.active_view() is terminal.view, "click did not focus the tab"
|
assert window.active_view() is terminal.view, "click did not focus the tab"
|
||||||
|
mouse_release.run(None, event)
|
||||||
assert terminal.process.written == "", repr(terminal.process.written)
|
assert terminal.process.written == "", repr(terminal.process.written)
|
||||||
|
|
||||||
# once focused, the same click reports as before
|
# once focused, press and release at one cell report a click
|
||||||
click.run(None, event)
|
mouse_press.run(None, event)
|
||||||
|
mouse_release.run(None, event)
|
||||||
assert terminal.process.written == "\x1b[<0;1;1M\x1b[<0;1;1m", repr(terminal.process.written)
|
assert terminal.process.written == "\x1b[<0;1;1M\x1b[<0;1;1m", repr(terminal.process.written)
|
||||||
|
|
||||||
print("click focus tests passed (first click focuses only, then reports)")
|
# a release elsewhere reports one drag motion first
|
||||||
|
terminal.process.writes.clear()
|
||||||
|
terminal.view.text = "r0 \nr1 \nr2 \nr3 \nr4 \n"
|
||||||
|
mouse_press.run(None, event)
|
||||||
|
terminal.view.window_to_text = lambda xy: terminal.view.text_point(2, 4)
|
||||||
|
mouse_release.run(None, event)
|
||||||
|
assert terminal.process.written == "\x1b[<0;1;1M\x1b[<32;5;3M\x1b[<0;5;3m", repr(terminal.process.written)
|
||||||
|
|
||||||
|
# the column is counted in cells: a wide char before the point takes two
|
||||||
|
terminal.process.writes.clear()
|
||||||
|
terminal.view.text = "\u65e5\u672cx \nr1 \nr2 \nr3 \nr4 \n"
|
||||||
|
terminal.view.window_to_text = lambda xy: terminal.view.text_point(0, 2)
|
||||||
|
mouse_press.run(None, event)
|
||||||
|
mouse_release.run(None, event)
|
||||||
|
assert terminal.process.written == "\x1b[<0;5;1M\x1b[<0;5;1m", repr(terminal.process.written)
|
||||||
|
|
||||||
|
print("click tests passed (first click focuses only; press, drag and release report; wide chars)")
|
||||||
|
|
||||||
|
|
||||||
# entering the alt screen folds the scrollback away (kitty hides it); leaving
|
# entering the alt screen folds the scrollback away (kitty hides it); leaving
|
||||||
@@ -698,4 +765,12 @@ line[0] = Char("r", "red", "default", False, False, False, False, True)
|
|||||||
runs = [r for r in segment_buffer_line(line) if r[1] != r[2]]
|
runs = [r for r in segment_buffer_line(line) if r[1] != r[2]]
|
||||||
assert runs == [("r", 0, 1, "reverse_default", "red", "")], runs
|
assert runs == [("r", 0, 1, "reverse_default", "red", "")], runs
|
||||||
|
|
||||||
print("segmenter tests passed (leading gap, wide char, orphan filler, reverse)")
|
# a cell can hold two code points (a mark NFC could not compose): the spans
|
||||||
|
# that follow are offsets into the row text, so they move by two
|
||||||
|
line = StaticDefaultDict(Char(" "))
|
||||||
|
line[0] = Char("q\u0301", "default", "default")
|
||||||
|
line[1] = Char("x", "blue", "default")
|
||||||
|
runs = [r for r in segment_buffer_line(line) if r[1] != r[2]]
|
||||||
|
assert runs == [("q\u0301", 0, 2, "default", "default", ""), ("x", 2, 3, "blue", "default", "")], runs
|
||||||
|
|
||||||
|
print("segmenter tests passed (leading gap, wide char, orphan filler, reverse, two-code-point cell)")
|
||||||
|
|||||||
@@ -33,12 +33,12 @@
|
|||||||
|
|
||||||
// External terminal for "vterm: Open Terminal in Project Folder".
|
// External terminal for "vterm: Open Terminal in Project Folder".
|
||||||
// %CWD% in parameters is replaced with the project folder.
|
// %CWD% in parameters is replaced with the project folder.
|
||||||
// "terminal": "/usr/bin/open",
|
"terminal": "/usr/bin/open",
|
||||||
// "parameters": ["-a", "Terminal", "%CWD%"],
|
"parameters": ["-a", "Terminal", "%CWD%"],
|
||||||
|
|
||||||
// kitty instead of Apple Terminal:
|
// kitty instead of Apple Terminal:
|
||||||
"terminal": "/opt/homebrew/bin/kitty",
|
// "terminal": "/usr/bin/open",
|
||||||
"parameters": ["-d", "%CWD%"],
|
// "parameters": ["-a", "Kitty", "%CWD%"],
|
||||||
|
|
||||||
"debug": false
|
"debug": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ LOG = logging.getLogger(__name__)
|
|||||||
SCROLL_KEYS = ("pageup", "pagedown", "up", "down")
|
SCROLL_KEYS = ("pageup", "pagedown", "up", "down")
|
||||||
|
|
||||||
|
|
||||||
|
def editor_view(window):
|
||||||
|
"""A view to sample the editor's colours from: never a terminal, whose
|
||||||
|
colours are its own theme's."""
|
||||||
|
return next((v for v in window.views() if not v.settings().get("vterm_view")), None)
|
||||||
|
|
||||||
|
|
||||||
class VtermOpenCommand(sublime_plugin.WindowCommand):
|
class VtermOpenCommand(sublime_plugin.WindowCommand):
|
||||||
"""Open a terminal in a new tab. `vterm_open`, the only entry point users bind."""
|
"""Open a terminal in a new tab. `vterm_open`, the only entry point users bind."""
|
||||||
|
|
||||||
@@ -43,7 +49,7 @@ class VtermOpenCommand(sublime_plugin.WindowCommand):
|
|||||||
cwd = os.path.expanduser("~")
|
cwd = os.path.expanduser("~")
|
||||||
cwd = sublime.expand_variables(cwd, window.extract_variables())
|
cwd = sublime.expand_variables(cwd, window.extract_variables())
|
||||||
|
|
||||||
scheme = get_scheme_manager(settings().get("theme"), host_view=window.active_view())
|
scheme = get_scheme_manager(settings().get("theme"), host_view=editor_view(window))
|
||||||
|
|
||||||
view = window.new_file()
|
view = window.new_file()
|
||||||
view.run_command("vterm_initialize_view", {"color_scheme": scheme.path})
|
view.run_command("vterm_initialize_view", {"color_scheme": scheme.path})
|
||||||
@@ -54,6 +60,7 @@ class VtermOpenCommand(sublime_plugin.WindowCommand):
|
|||||||
except Exception:
|
except Exception:
|
||||||
LOG.exception("failed to start terminal: cmd=%s", cmd)
|
LOG.exception("failed to start terminal: cmd=%s", cmd)
|
||||||
view.close()
|
view.close()
|
||||||
|
sublime.error_message("vterm: could not start {}".format(" ".join(cmd)))
|
||||||
return
|
return
|
||||||
window.focus_view(view)
|
window.focus_view(view)
|
||||||
|
|
||||||
@@ -183,14 +190,6 @@ class VtermCopyCommand(sublime_plugin.TextCommand):
|
|||||||
sublime.set_clipboard(selection_text(self.view))
|
sublime.set_clipboard(selection_text(self.view))
|
||||||
|
|
||||||
|
|
||||||
class VtermPasteCommand(sublime_plugin.TextCommand):
|
|
||||||
"""Pastes the clipboard into the app; replaces sublime's own paste."""
|
|
||||||
|
|
||||||
def run(self, edit):
|
|
||||||
"""Hand the clipboard to vterm_paste_text."""
|
|
||||||
self.view.run_command("vterm_paste_text", {"text": sublime.get_clipboard()})
|
|
||||||
|
|
||||||
|
|
||||||
class VtermPasteTextCommand(sublime_plugin.TextCommand):
|
class VtermPasteTextCommand(sublime_plugin.TextCommand):
|
||||||
"""Sends arbitrary text to the app, bracketed when it asked for that."""
|
"""Sends arbitrary text to the app, bracketed when it asked for that."""
|
||||||
|
|
||||||
@@ -258,7 +257,7 @@ class VtermSelectThemeCommand(sublime_plugin.WindowCommand):
|
|||||||
|
|
||||||
def _apply(self, name):
|
def _apply(self, name):
|
||||||
"""Retheme the focused terminal alone."""
|
"""Retheme the focused terminal alone."""
|
||||||
scheme = get_scheme_manager(name, host_view=self.terminal.view)
|
scheme = get_scheme_manager(name, host_view=editor_view(self.window))
|
||||||
self.terminal.retheme(scheme)
|
self.terminal.retheme(scheme)
|
||||||
|
|
||||||
def _preview(self, index):
|
def _preview(self, index):
|
||||||
@@ -328,20 +327,6 @@ class VtermCleanupCommand(sublime_plugin.TextCommand):
|
|||||||
view.sel().clear()
|
view.sel().clear()
|
||||||
|
|
||||||
|
|
||||||
class VtermCloseCommand(sublime_plugin.TextCommand):
|
|
||||||
"""Kills the process and closes the tab. Registered but bound nowhere; for user keybindings."""
|
|
||||||
|
|
||||||
def run(self, _):
|
|
||||||
"""Kill the process, then close the view."""
|
|
||||||
view = self.view
|
|
||||||
if not view.settings().get("vterm_view"):
|
|
||||||
return
|
|
||||||
terminal = Terminal.from_id(view.id())
|
|
||||||
if terminal:
|
|
||||||
terminal.kill()
|
|
||||||
view.close()
|
|
||||||
|
|
||||||
|
|
||||||
class VtermClearUndoStackCommand(sublime_plugin.TextCommand):
|
class VtermClearUndoStackCommand(sublime_plugin.TextCommand):
|
||||||
"""Drops the undo history, so rendered output can never be undone into view."""
|
"""Drops the undo history, so rendered output can never be undone into view."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
""" Terminal emulation: the screen and a parser for prefixed CSI, DECRQM and multi-digit OSC """
|
""" Terminal emulation: the screen and a parser for prefixed CSI, DECRQM and multi-digit OSC """
|
||||||
import base64
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
@@ -41,7 +40,7 @@ FG_AIXTERM = {90 + i: ANSI_COLORS[8 + i] for i in range(8)}
|
|||||||
BG_AIXTERM = {100 + i: ANSI_COLORS[8 + i] for i in range(8)}
|
BG_AIXTERM = {100 + i: ANSI_COLORS[8 + i] for i in range(8)}
|
||||||
|
|
||||||
# DECRQM answers, as wire numbers (pyte stores modes shifted << 5)
|
# DECRQM answers, as wire numbers (pyte stores modes shifted << 5)
|
||||||
REPORTABLE_MODES = (mo.DECAWM >> 5, mo.DECTCEM >> 5, 1000, 1002, 1003, 1004, 1006, 1049, 2004, 2026)
|
REPORTABLE_MODES = (1, mo.DECAWM >> 5, mo.DECTCEM >> 5, 1000, 1002, 1003, 1004, 1006, 1049, 2004, 2026, 2031)
|
||||||
|
|
||||||
# DEC private modes, pre-shifted into pyte's internal representation.
|
# DEC private modes, pre-shifted into pyte's internal representation.
|
||||||
# 1: DECCKM, application cursor keys (arrows send SS3)
|
# 1: DECCKM, application cursor keys (arrows send SS3)
|
||||||
@@ -83,7 +82,8 @@ def quantize_256(r, g, b):
|
|||||||
if r == g == b:
|
if r == g == b:
|
||||||
if r < 8:
|
if r < 8:
|
||||||
return 16
|
return 16
|
||||||
if r > 248:
|
# 238 is the top of the grey ramp (255); above the midpoint, white
|
||||||
|
if r > 246:
|
||||||
return 231
|
return 231
|
||||||
return 232 + (r - 8) // 10
|
return 232 + (r - 8) // 10
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@ class Cursor:
|
|||||||
|
|
||||||
class VtScreen(pyte.Screen):
|
class VtScreen(pyte.Screen):
|
||||||
"""Screen with alternate buffer, scrollback, synchronized output,
|
"""Screen with alternate buffer, scrollback, synchronized output,
|
||||||
mouse/focus/paste modes, cursor styles, query replies and OSC handling.
|
mouse/focus/paste modes, cursor styles, query replies and OSC titles/colours.
|
||||||
|
|
||||||
Mutated only by the renderer thread, so it carries no lock.
|
Mutated only by the renderer thread, so it carries no lock.
|
||||||
"""
|
"""
|
||||||
@@ -136,7 +136,6 @@ class VtScreen(pyte.Screen):
|
|||||||
history=5000,
|
history=5000,
|
||||||
erase_scrollback=True,
|
erase_scrollback=True,
|
||||||
clear_callback=lambda: None,
|
clear_callback=lambda: None,
|
||||||
clipboard_callback=lambda text: None,
|
|
||||||
fg_color="ffffff",
|
fg_color="ffffff",
|
||||||
bg_color="000000",
|
bg_color="000000",
|
||||||
is_dark=True,
|
is_dark=True,
|
||||||
@@ -145,7 +144,6 @@ class VtScreen(pyte.Screen):
|
|||||||
self._process = process
|
self._process = process
|
||||||
self._erase_scrollback = erase_scrollback
|
self._erase_scrollback = erase_scrollback
|
||||||
self._clear_callback = clear_callback
|
self._clear_callback = clear_callback
|
||||||
self._clipboard_callback = clipboard_callback
|
|
||||||
self.primary_buffer = {}
|
self.primary_buffer = {}
|
||||||
self._default_char = Char(data=" ", fg="default", bg="default", reverse=False)
|
self._default_char = Char(data=" ", fg="default", bg="default", reverse=False)
|
||||||
# SGR transitions repeat endlessly in TUI output; memoized per (attrs, params)
|
# SGR transitions repeat endlessly in TUI output; memoized per (attrs, params)
|
||||||
@@ -155,6 +153,8 @@ class VtScreen(pyte.Screen):
|
|||||||
self._char_caches = {}
|
self._char_caches = {}
|
||||||
self._char_cache = {}
|
self._char_cache = {}
|
||||||
self._cache_attrs = None
|
self._cache_attrs = None
|
||||||
|
# last printable drawn, for REP
|
||||||
|
self._last_char = ""
|
||||||
self.history = deque(maxlen=history)
|
self.history = deque(maxlen=history)
|
||||||
self.alternate_buffer_mode = False
|
self.alternate_buffer_mode = False
|
||||||
self._sync_frame_start = 0
|
self._sync_frame_start = 0
|
||||||
@@ -167,11 +167,16 @@ class VtScreen(pyte.Screen):
|
|||||||
|
|
||||||
def reset(self):
|
def reset(self):
|
||||||
"""RIS: reset the screen along with the cursor, scrollback and caches"""
|
"""RIS: reset the screen along with the cursor, scrollback and caches"""
|
||||||
|
if self.alternate_buffer_mode:
|
||||||
|
# the primary screen is what a reset clears; leave the alt one behind
|
||||||
|
self.reset_mode(1049, private=True)
|
||||||
self._default_char = Char(data=" ", fg="default", bg="default", reverse=False)
|
self._default_char = Char(data=" ", fg="default", bg="default", reverse=False)
|
||||||
self._sgr_cache.clear()
|
self._sgr_cache.clear()
|
||||||
self._char_caches.clear()
|
self._char_caches.clear()
|
||||||
self._cache_attrs = None
|
self._cache_attrs = None
|
||||||
|
self._last_char = ""
|
||||||
super().reset()
|
super().reset()
|
||||||
|
self.savepoints = []
|
||||||
self.cursor = Cursor(0, 0)
|
self.cursor = Cursor(0, 0)
|
||||||
self.cursor_style = 0
|
self.cursor_style = 0
|
||||||
self.modify_other_keys = 0
|
self.modify_other_keys = 0
|
||||||
@@ -378,6 +383,7 @@ class VtScreen(pyte.Screen):
|
|||||||
dirty.add(cursor.y)
|
dirty.add(cursor.y)
|
||||||
cursor.x = x + take
|
cursor.x = x + take
|
||||||
pos = end
|
pos = end
|
||||||
|
self._last_char = data[-1]
|
||||||
return
|
return
|
||||||
|
|
||||||
line = buffer[cursor.y]
|
line = buffer[cursor.y]
|
||||||
@@ -402,6 +408,7 @@ class VtScreen(pyte.Screen):
|
|||||||
if cell is None:
|
if cell is None:
|
||||||
cell = cache[char] = attrs._replace(data=char)
|
cell = cache[char] = attrs._replace(data=char)
|
||||||
line[cursor.x] = cell
|
line[cursor.x] = cell
|
||||||
|
self._last_char = char
|
||||||
if char_width == 2 and cursor.x + 1 < columns:
|
if char_width == 2 and cursor.x + 1 < columns:
|
||||||
filler = cache.get("")
|
filler = cache.get("")
|
||||||
if filler is None:
|
if filler is None:
|
||||||
@@ -409,7 +416,10 @@ class VtScreen(pyte.Screen):
|
|||||||
line[cursor.x + 1] = filler
|
line[cursor.x + 1] = filler
|
||||||
# a combining mark belongs to the cell before it, which may be the
|
# a combining mark belongs to the cell before it, which may be the
|
||||||
# last column of the previous row
|
# last column of the previous row
|
||||||
elif char_width == 0 and unicodedata.combining(char):
|
# a mark: by category, not combining(), which is 0 for Thai/Indic
|
||||||
|
# vowel signs. Variation selectors are marks too but stay dropped:
|
||||||
|
# merged, VS16 would make the cell width 2 and swallow its neighbour
|
||||||
|
elif char_width == 0 and unicodedata.category(char)[0] == "M" and not "\ufe00" <= char <= "\ufe0f":
|
||||||
# sublime renders decomposed combining chars poorly; merge as NFC
|
# sublime renders decomposed combining chars poorly; merge as NFC
|
||||||
pos = None
|
pos = None
|
||||||
last = None
|
last = None
|
||||||
@@ -432,7 +442,8 @@ class VtScreen(pyte.Screen):
|
|||||||
self.buffer[pos[0]][pos[1]] = last._replace(data=normalized)
|
self.buffer[pos[0]][pos[1]] = last._replace(data=normalized)
|
||||||
self.dirty.add(pos[0])
|
self.dirty.add(pos[0])
|
||||||
else:
|
else:
|
||||||
break
|
# other zero-width (VS16, ZWJ) or control: drop it, keep the rest
|
||||||
|
continue
|
||||||
|
|
||||||
if char_width > 0:
|
if char_width > 0:
|
||||||
x = cursor.x + char_width
|
x = cursor.x + char_width
|
||||||
@@ -440,9 +451,16 @@ class VtScreen(pyte.Screen):
|
|||||||
|
|
||||||
self.dirty.add(cursor.y)
|
self.dirty.add(cursor.y)
|
||||||
|
|
||||||
|
def repeat_character(self, count=None):
|
||||||
|
"""REP: draw the last printable again `count` times."""
|
||||||
|
if self._last_char:
|
||||||
|
self.draw(self._last_char * (count or 1))
|
||||||
|
|
||||||
def index(self):
|
def index(self):
|
||||||
"""Line feed; at the bottom of the primary screen the top row goes to scrollback."""
|
"""Line feed; at the bottom of the primary screen the top row goes to scrollback."""
|
||||||
if not self.alternate_buffer_mode and self.cursor.y == self.lines - 1 and self.margins is None:
|
# a region spanning the whole screen (CSI 1;<lines> r) is no region
|
||||||
|
full = self.margins is None or self.margins == (0, self.lines - 1)
|
||||||
|
if not self.alternate_buffer_mode and self.cursor.y == self.lines - 1 and full:
|
||||||
self.push_lines_into_history(1)
|
self.push_lines_into_history(1)
|
||||||
super().index()
|
super().index()
|
||||||
|
|
||||||
@@ -614,6 +632,8 @@ class VtScreen(pyte.Screen):
|
|||||||
elif mode == 6:
|
elif mode == 6:
|
||||||
x = min(self.cursor.x, self.columns - 1) + 1
|
x = min(self.cursor.x, self.columns - 1) + 1
|
||||||
y = self.cursor.y + 1
|
y = self.cursor.y + 1
|
||||||
|
if mo.DECOM in self.mode and self.margins:
|
||||||
|
y -= self.margins.top
|
||||||
marker = "?" if private else ""
|
marker = "?" if private else ""
|
||||||
self.write_process_input("\x1b[{}{};{}R".format(marker, y, x))
|
self.write_process_input("\x1b[{}{};{}R".format(marker, y, x))
|
||||||
elif mode == 996 and private:
|
elif mode == 996 and private:
|
||||||
@@ -631,7 +651,10 @@ class VtScreen(pyte.Screen):
|
|||||||
def report_mode_status(self, mode, private):
|
def report_mode_status(self, mode, private):
|
||||||
"""DECRQM: answer 1 set, 2 reset, 0 for modes we do not implement."""
|
"""DECRQM: answer 1 set, 2 reset, 0 for modes we do not implement."""
|
||||||
if not private:
|
if not private:
|
||||||
self.write_process_input("\x1b[{};0$y".format(mode))
|
value = 0
|
||||||
|
if mode in (mo.IRM, mo.LNM):
|
||||||
|
value = 1 if mode in self.mode else 2
|
||||||
|
self.write_process_input("\x1b[{};{}$y".format(mode, value))
|
||||||
return
|
return
|
||||||
if mode in REPORTABLE_MODES:
|
if mode in REPORTABLE_MODES:
|
||||||
value = 1 if (mode << 5) in self.mode else 2
|
value = 1 if (mode << 5) in self.mode else 2
|
||||||
@@ -646,7 +669,7 @@ class VtScreen(pyte.Screen):
|
|||||||
self.write_process_input("\x1b[?0u")
|
self.write_process_input("\x1b[?0u")
|
||||||
|
|
||||||
def handle_osc(self, code, payload, terminator):
|
def handle_osc(self, code, payload, terminator):
|
||||||
"""Dispatch an OSC: 0/2 title, 1 icon, 10/11 colour query, 52 clipboard."""
|
"""Dispatch an OSC: 0/2 title, 1 icon, 10/11 colour query."""
|
||||||
if code in (0, 2):
|
if code in (0, 2):
|
||||||
self.set_title(payload)
|
self.set_title(payload)
|
||||||
elif code == 1:
|
elif code == 1:
|
||||||
@@ -657,14 +680,8 @@ class VtScreen(pyte.Screen):
|
|||||||
rgb = "rgb:{0}{0}/{1}{1}/{2}{2}".format(color[0:2], color[2:4], color[4:6])
|
rgb = "rgb:{0}{0}/{1}{1}/{2}{2}".format(color[0:2], color[2:4], color[4:6])
|
||||||
# answer with the terminator asked with: meli only ends an OSC at ST
|
# answer with the terminator asked with: meli only ends an OSC at ST
|
||||||
self.write_process_input("\x1b]{};{}{}".format(code, rgb, terminator))
|
self.write_process_input("\x1b]{};{}{}".format(code, rgb, terminator))
|
||||||
elif code == 52:
|
# other OSC codes are intentionally dropped, 52 (clipboard write) among
|
||||||
parts = payload.split(";", 1)
|
# them: any output, cat'd or remote, could plant a command to paste
|
||||||
if len(parts) == 2 and parts[1] and parts[1] != "?":
|
|
||||||
try:
|
|
||||||
self._clipboard_callback(base64.b64decode(parts[1]).decode("utf-8", "replace"))
|
|
||||||
except ValueError:
|
|
||||||
LOG.warning("osc52: undecodable payload", exc_info=True)
|
|
||||||
# other OSC codes are intentionally dropped
|
|
||||||
|
|
||||||
|
|
||||||
# a complete plain CSI: params then a final byte. No private marker, prefix or
|
# a complete plain CSI: params then a final byte. No private marker, prefix or
|
||||||
@@ -688,6 +705,9 @@ class VtStream(pyte.Stream):
|
|||||||
self.csi = dict(self.csi)
|
self.csi = dict(self.csi)
|
||||||
self.csi["S"] = "scroll_up"
|
self.csi["S"] = "scroll_up"
|
||||||
self.csi["T"] = "scroll_down"
|
self.csi["T"] = "scroll_down"
|
||||||
|
self.csi["b"] = "repeat_character"
|
||||||
|
self.csi["s"] = "save_cursor"
|
||||||
|
self.csi["u"] = "restore_cursor"
|
||||||
self._carry = ""
|
self._carry = ""
|
||||||
# param-string -> parsed tuple; SGR strings repeat across a whole frame
|
# param-string -> parsed tuple; SGR strings repeat across a whole frame
|
||||||
self._params_cache = {}
|
self._params_cache = {}
|
||||||
@@ -751,7 +771,8 @@ class VtStream(pyte.Stream):
|
|||||||
raw = match.group(1)
|
raw = match.group(1)
|
||||||
params = cache.get(raw)
|
params = cache.get(raw)
|
||||||
if params is None:
|
if params is None:
|
||||||
params = tuple(min(int(p), 9999) if p else 0 for p in raw.split(";"))
|
# no parameters is (), not (0,): save_cursor takes none
|
||||||
|
params = tuple(min(int(p), 9999) if p else 0 for p in raw.split(";")) if raw else ()
|
||||||
if len(cache) > 4096:
|
if len(cache) > 4096:
|
||||||
cache.clear()
|
cache.clear()
|
||||||
cache[raw] = params
|
cache[raw] = params
|
||||||
@@ -810,24 +831,33 @@ class VtStream(pyte.Stream):
|
|||||||
escape_dispatch = create_dispatcher(self.escape)
|
escape_dispatch = create_dispatcher(self.escape)
|
||||||
csi_dispatch = create_dispatcher(self.csi)
|
csi_dispatch = create_dispatcher(self.csi)
|
||||||
|
|
||||||
|
# the byte after an ESC that cut a string short: any ESC ends a string,
|
||||||
|
# as in xterm, or one with no ST would swallow every later sequence
|
||||||
|
pending = None
|
||||||
while True:
|
while True:
|
||||||
char = yield True
|
char = (yield True) if pending is None else ESC
|
||||||
|
|
||||||
# ESC introducer: fold the 7-bit forms onto their C1 equivalents
|
# ESC introducer: fold the 7-bit forms onto their C1 equivalents
|
||||||
if char == ESC:
|
if char == ESC:
|
||||||
char = yield None
|
if pending is None:
|
||||||
|
char = yield None
|
||||||
|
else:
|
||||||
|
char, pending = pending, None
|
||||||
if char == "[":
|
if char == "[":
|
||||||
char = CSI_C1
|
char = CSI_C1
|
||||||
elif char == "]":
|
elif char == "]":
|
||||||
char = OSC_C1
|
char = OSC_C1
|
||||||
elif char in "PX^_":
|
elif char in "PX^_":
|
||||||
# consume the payload so it never reaches the screen
|
# consume the payload so it never reaches the screen
|
||||||
prev = ""
|
|
||||||
while True:
|
while True:
|
||||||
c = yield None
|
c = yield None
|
||||||
if c == ctrl.ST_C1 or (prev == ESC and c == "\\"):
|
if c == ESC:
|
||||||
|
c = yield None
|
||||||
|
if c != "\\":
|
||||||
|
pending = c
|
||||||
|
break
|
||||||
|
if c == ctrl.ST_C1:
|
||||||
break
|
break
|
||||||
prev = c
|
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
if char == "#":
|
if char == "#":
|
||||||
@@ -853,13 +883,15 @@ class VtStream(pyte.Stream):
|
|||||||
private = False
|
private = False
|
||||||
prefix = ""
|
prefix = ""
|
||||||
intermediate = ""
|
intermediate = ""
|
||||||
|
# inside a `:` subparameter list, which is dropped (4:3 must not read as 43)
|
||||||
|
sub = False
|
||||||
while True:
|
while True:
|
||||||
char = yield None
|
char = yield None
|
||||||
if char == "?":
|
if char == "?":
|
||||||
private = True
|
private = True
|
||||||
elif char in "><=":
|
elif char in "><=":
|
||||||
prefix = char
|
prefix = char
|
||||||
elif char in " $":
|
elif " " <= char <= "/":
|
||||||
intermediate = char
|
intermediate = char
|
||||||
elif char in ALLOWED_IN_CSI:
|
elif char in ALLOWED_IN_CSI:
|
||||||
basic_dispatch[char]()
|
basic_dispatch[char]()
|
||||||
@@ -867,15 +899,17 @@ class VtStream(pyte.Stream):
|
|||||||
draw(char)
|
draw(char)
|
||||||
break
|
break
|
||||||
elif char.isdigit():
|
elif char.isdigit():
|
||||||
current += char
|
if not sub:
|
||||||
|
current += char
|
||||||
elif char == ":":
|
elif char == ":":
|
||||||
# SGR subparameters, which we do not support
|
sub = True
|
||||||
pass
|
|
||||||
elif char == ";":
|
elif char == ";":
|
||||||
params.append(min(int(current or 0), 9999))
|
params.append(min(int(current or 0), 9999))
|
||||||
current = ""
|
current = ""
|
||||||
|
sub = False
|
||||||
else:
|
else:
|
||||||
params.append(min(int(current or 0), 9999))
|
if current or params:
|
||||||
|
params.append(min(int(current or 0), 9999))
|
||||||
self._dispatch_csi(csi_dispatch, listener, char, params, private, prefix, intermediate)
|
self._dispatch_csi(csi_dispatch, listener, char, params, private, prefix, intermediate)
|
||||||
break
|
break
|
||||||
# OSC: a code of up to 8 digits, then a payload up to MAX_OSC,
|
# OSC: a code of up to 8 digits, then a payload up to MAX_OSC,
|
||||||
@@ -890,18 +924,19 @@ class VtStream(pyte.Stream):
|
|||||||
break
|
break
|
||||||
|
|
||||||
payload = ""
|
payload = ""
|
||||||
if char == ";" or (char not in OSC_TERMINATORS and char != ESC):
|
if char == ";":
|
||||||
while True:
|
char = yield None
|
||||||
|
while char not in OSC_TERMINATORS:
|
||||||
|
if char == ESC:
|
||||||
char = yield None
|
char = yield None
|
||||||
if char == ESC:
|
if char != "\\":
|
||||||
char += yield None
|
pending = char
|
||||||
if char in OSC_TERMINATORS:
|
# char holds the whole terminator, ST for a cut string
|
||||||
break
|
char = ctrl.ST_C0
|
||||||
if len(payload) < MAX_OSC:
|
break
|
||||||
payload += char
|
if len(payload) < MAX_OSC:
|
||||||
elif char == ESC:
|
payload += char
|
||||||
# take the '\' of ST, so char holds the whole terminator
|
char = yield None
|
||||||
char += yield None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
listener.handle_osc(int(code or 0), payload, char)
|
listener.handle_osc(int(code or 0), payload, char)
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ class VtermCoreEventListener(sublime_plugin.EventListener):
|
|||||||
"""Answer the contexts the mousemap needs"""
|
"""Answer the contexts the mousemap needs"""
|
||||||
# keymap contexts are native "setting.x"; mousemaps do not evaluate
|
# keymap contexts are native "setting.x"; mousemaps do not evaluate
|
||||||
# those the same way, so their flags still need the hook
|
# those the same way, so their flags still need the hook
|
||||||
if key not in ("vterm_view", "vterm_view.wheel_captured"):
|
if key not in ("vterm_view", "vterm_view.wheel_captured", "vterm_view.mouse_reporting"):
|
||||||
return None
|
return None
|
||||||
value = view.settings().get(key, False)
|
value = view.settings().get(key, False)
|
||||||
if operator == sublime.OP_EQUAL:
|
if operator == sublime.OP_EQUAL:
|
||||||
@@ -104,6 +104,6 @@ class VtermCoreEventListener(sublime_plugin.EventListener):
|
|||||||
if name == "copy":
|
if name == "copy":
|
||||||
return ("vterm_copy", None)
|
return ("vterm_copy", None)
|
||||||
elif name in ("paste", "paste_and_indent"):
|
elif name in ("paste", "paste_and_indent"):
|
||||||
return ("vterm_paste", None)
|
return ("vterm_paste_text", {"text": sublime.get_clipboard()})
|
||||||
elif name == "undo":
|
elif name == "undo":
|
||||||
return ("vterm_noop", None)
|
return ("vterm_noop", None)
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ The trackpad is not handled here. On macOS it never reaches a mousemap binding
|
|||||||
continuous scrolling into discrete wheel events; those arrive as ordinary wheel
|
continuous scrolling into discrete wheel events; those arrive as ordinary wheel
|
||||||
notches and take the path below.
|
notches and take the path below.
|
||||||
"""
|
"""
|
||||||
|
import sublime
|
||||||
import sublime_plugin
|
import sublime_plugin
|
||||||
|
from wcwidth import wcswidth
|
||||||
|
|
||||||
from .terminal import Terminal
|
from .terminal import Terminal
|
||||||
from .utils import settings
|
from .utils import settings
|
||||||
@@ -13,6 +15,8 @@ from .utils import settings
|
|||||||
# SGR button numbers for the wheel; 64 is the "scroll" bit plus direction
|
# SGR button numbers for the wheel; 64 is the "scroll" bit plus direction
|
||||||
WHEEL_UP = 64
|
WHEEL_UP = 64
|
||||||
WHEEL_DOWN = 65
|
WHEEL_DOWN = 65
|
||||||
|
# button 0 with the "moved while held" bit
|
||||||
|
MOTION = 32
|
||||||
|
|
||||||
|
|
||||||
def send_scroll(terminal, up, lines, cell=None):
|
def send_scroll(terminal, up, lines, cell=None):
|
||||||
@@ -36,9 +40,10 @@ def _event_to_cell(view, terminal, event, clamp=False):
|
|||||||
"""Map a mouse event to 1-based (col, row) on the live screen, or None.
|
"""Map a mouse event to 1-based (col, row) on the live screen, or None.
|
||||||
|
|
||||||
Clamping pulls a point above or below the screen onto the nearest row: the
|
Clamping pulls a point above or below the screen onto the nearest row: the
|
||||||
wheel still belongs to the app there, a click does not."""
|
wheel and a release still belong to the app there, a press does not."""
|
||||||
pt = view.window_to_text((event["x"], event["y"]))
|
pt = view.window_to_text((event["x"], event["y"]))
|
||||||
row, col = view.rowcol(pt)
|
row, col = view.rowcol(pt)
|
||||||
|
col = wcswidth(view.substr(sublime.Region(pt - col, pt)))
|
||||||
row -= terminal.offset
|
row -= terminal.offset
|
||||||
screen = terminal.screen
|
screen = terminal.screen
|
||||||
if row < 0 or row >= screen.lines:
|
if row < 0 or row >= screen.lines:
|
||||||
@@ -48,46 +53,60 @@ def _event_to_cell(view, terminal, event, clamp=False):
|
|||||||
return (min(col, screen.columns - 1) + 1, row + 1)
|
return (min(col, screen.columns - 1) + 1, row + 1)
|
||||||
|
|
||||||
|
|
||||||
class VtermMouseEventListener(sublime_plugin.EventListener):
|
class VtermMousePressCommand(sublime_plugin.TextCommand):
|
||||||
"""Hands button-1 clicks to the app, but only while it tracks the mouse."""
|
"""Bound to button 1 in the mousemap while the app tracks the mouse."""
|
||||||
|
|
||||||
def on_text_command(self, view, name, args):
|
|
||||||
"""Replace drag_select with a mouse report; other click variants stay native."""
|
|
||||||
terminal = Terminal.from_id(view.id())
|
|
||||||
if not terminal:
|
|
||||||
return
|
|
||||||
if not terminal.screen.mouse_reporting_enabled():
|
|
||||||
# leave selection, caret and scrolling to sublime
|
|
||||||
return
|
|
||||||
if name == "drag_select" and args and args.get("event", {}).get("button") == 1 and len(args) == 1:
|
|
||||||
return ("vterm_mouse_click", {"event": args["event"]})
|
|
||||||
|
|
||||||
|
|
||||||
class VtermMouseClickCommand(sublime_plugin.TextCommand):
|
|
||||||
"""Reports a click to the app, or focuses the tab if it was not active."""
|
|
||||||
|
|
||||||
def run(self, edit, event):
|
def run(self, edit, event):
|
||||||
"""Focus the tab on the first click, report press and release after that."""
|
"""Focus an inactive tab; else report the press and remember its cell."""
|
||||||
terminal = Terminal.from_id(self.view.id())
|
terminal = Terminal.from_id(self.view.id())
|
||||||
if not terminal:
|
if not terminal:
|
||||||
return
|
return
|
||||||
|
terminal.mouse_press = None # a release may not come: the context can drop mid-drag
|
||||||
window = self.view.window()
|
window = self.view.window()
|
||||||
if window and window.active_view() != self.view:
|
if window and window.active_view() != self.view:
|
||||||
# we replaced drag_select, which is what focuses the group; this
|
# the mousemap binding displaces drag_select, which is what focuses
|
||||||
# click only focuses, as clicking an unfocused terminal window does
|
# the group; this click only focuses, as in a terminal window
|
||||||
window.focus_view(self.view)
|
window.focus_view(self.view)
|
||||||
return
|
return
|
||||||
cell = _event_to_cell(self.view, terminal, event)
|
cell = _event_to_cell(self.view, terminal, event)
|
||||||
if cell is None:
|
if cell is None:
|
||||||
return
|
return
|
||||||
|
terminal.mouse_press = cell
|
||||||
terminal.send_mouse(0, cell[0], cell[1], pressed=True)
|
terminal.send_mouse(0, cell[0], cell[1], pressed=True)
|
||||||
terminal.send_mouse(0, cell[0], cell[1], pressed=False)
|
# native drag_select only previews the selection while dragging; the
|
||||||
|
# app gets the real one at release
|
||||||
|
self.view.run_command("drag_select", {"event": event})
|
||||||
|
|
||||||
def want_event(self):
|
def want_event(self):
|
||||||
"""Ask sublime for the click coordinates."""
|
"""Ask sublime for the click coordinates."""
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class VtermMouseReleaseCommand(sublime_plugin.TextCommand):
|
||||||
|
"""Reports the release, after one drag motion when the button came up elsewhere.
|
||||||
|
|
||||||
|
Sublime raises no motion events, so a drag reaches the app as a single
|
||||||
|
move to the release cell; an app's selection then spans press to release.
|
||||||
|
The native preview selection goes, leaving the app's own highlight."""
|
||||||
|
|
||||||
|
def run(self, edit, event):
|
||||||
|
"""Report motion and release for the pending press, if any."""
|
||||||
|
terminal = Terminal.from_id(self.view.id())
|
||||||
|
if not terminal or terminal.mouse_press is None:
|
||||||
|
return
|
||||||
|
cell = _event_to_cell(self.view, terminal, event, clamp=True)
|
||||||
|
if cell != terminal.mouse_press:
|
||||||
|
terminal.send_mouse(MOTION, cell[0], cell[1], pressed=True)
|
||||||
|
terminal.send_mouse(0, cell[0], cell[1], pressed=False)
|
||||||
|
terminal.mouse_press = None
|
||||||
|
self.view.sel().clear()
|
||||||
|
self.view.run_command("vterm_show_cursor", {"scroll": False})
|
||||||
|
|
||||||
|
def want_event(self):
|
||||||
|
"""Ask sublime for the release coordinates."""
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
class VtermScrollCommand(sublime_plugin.TextCommand):
|
class VtermScrollCommand(sublime_plugin.TextCommand):
|
||||||
"""Bound to scroll wheel in the mousemap while the app tracks the mouse or is on the alt screen."""
|
"""Bound to scroll wheel in the mousemap while the app tracks the mouse or is on the alt screen."""
|
||||||
|
|
||||||
@@ -103,7 +122,7 @@ class VtermScrollCommand(sublime_plugin.TextCommand):
|
|||||||
cell = None
|
cell = None
|
||||||
if terminal.screen.mouse_reporting_enabled() and event:
|
if terminal.screen.mouse_reporting_enabled() and event:
|
||||||
cell = _event_to_cell(self.view, terminal, event, clamp=True)
|
cell = _event_to_cell(self.view, terminal, event, clamp=True)
|
||||||
lines = max(0, int(settings().get("wheel_lines", 3)))
|
lines = max(0, int(settings().get("wheel_lines", 1)))
|
||||||
send_scroll(terminal, up, lines, cell)
|
send_scroll(terminal, up, lines, cell)
|
||||||
|
|
||||||
def want_event(self):
|
def want_event(self):
|
||||||
|
|||||||
224
vterm/render.py
224
vterm/render.py
@@ -1,11 +1,13 @@
|
|||||||
""" Rendering the emulator screen into the Sublime view
|
""" Rendering the emulator screen into the Sublime view
|
||||||
|
|
||||||
The view holds real text: scrollback above `terminal.offset`, the live screen
|
The view holds real text: scrollback above `terminal.offset`, the live screen
|
||||||
window from `offset` on. Dirty rows are whole-line replaced; colors are
|
window from `offset` on. Dirty rows are whole-line replaced, and rows the view
|
||||||
add_regions overlays with scopes served by the dynamic scheme. The ST caret
|
does not hold yet are appended in one insert; colors are add_regions overlays
|
||||||
|
with scopes served by the dynamic scheme. The ST caret
|
||||||
doubles as the terminal cursor.
|
doubles as the terminal cursor.
|
||||||
"""
|
"""
|
||||||
import math
|
import math
|
||||||
|
from bisect import bisect_left
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
import sublime
|
import sublime
|
||||||
@@ -88,8 +90,10 @@ def segment_buffer_line(buffer_line):
|
|||||||
|
|
||||||
# an orphaned wide-char filler (its first half was overwritten) still
|
# an orphaned wide-char filler (its first half was overwritten) still
|
||||||
# occupies a column; render a space so later spans stay aligned
|
# occupies a column; render a space so later spans stay aligned
|
||||||
run.append(char.data or " ")
|
data = char.data
|
||||||
column += 1
|
run.append(data or " ")
|
||||||
|
# a cell can hold more than one code point: a mark NFC could not compose
|
||||||
|
column += len(data) or 1
|
||||||
|
|
||||||
if attrs is not None:
|
if attrs is not None:
|
||||||
yield _run(run, start, column, attrs)
|
yield _run(run, start, column, attrs)
|
||||||
@@ -116,34 +120,49 @@ def ensure_position(view, edit, row, col=0):
|
|||||||
view.insert(edit, line_region.end(), " " * (col - lastcol))
|
view.insert(edit, line_region.end(), " " * (col - lastcol))
|
||||||
|
|
||||||
|
|
||||||
class VtermRenderCommand(sublime_plugin.TextCommand):
|
class RenderState:
|
||||||
"""Draws the screen into the view. Sublime keeps one instance per view, so
|
"""A view's render bookkeeping, kept on its Terminal.
|
||||||
the caches below are that view's render state."""
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
Sublime rebuilds every TextCommand instance of every view whenever any
|
||||||
"""Set up this view's render caches."""
|
plugin module loads. State held on the command is lost then: the overlays
|
||||||
super().__init__(*args, **kwargs)
|
already in the view become untracked, so a row rewrite no longer erases
|
||||||
# the terminal's current SchemeManager; set at the top of each run()
|
them, and the key counter restarts and hands out keys still in use.
|
||||||
self._scheme = None
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Empty bookkeeping, for a view that has nothing painted yet."""
|
||||||
|
# view row -> region keys painted on it
|
||||||
self.colored_lines = {}
|
self.colored_lines = {}
|
||||||
# view row -> (segments, lf) as last rendered; the dirty set is only a
|
# view row -> (segments, lf) as last rendered; the dirty set is only a
|
||||||
# repaint hint, so most dirty rows are identical frame to frame
|
# repaint hint, so most dirty rows are identical frame to frame
|
||||||
self._row_cache = {}
|
self.row_cache = {}
|
||||||
self._key_counter = 0
|
self.key_counter = 0
|
||||||
# chunk ordinal -> scopes it holds a key for; ordinals are view row +
|
# chunk ordinal -> scopes it holds a key for; ordinals are view row +
|
||||||
# rows trimmed so far, so they survive trim_history renumbering
|
# rows trimmed so far, so they survive trim_history renumbering
|
||||||
self._archived_chunks = {}
|
self.archived_chunks = {}
|
||||||
# rows waiting for their chunk to close, and the chunk they belong to
|
# rows waiting for their chunk to close, and the chunk they belong to
|
||||||
self._open_rows = []
|
self.open_rows = []
|
||||||
self._open_chunk = None
|
self.open_chunk = None
|
||||||
self._trimmed = 0
|
self.trimmed = 0
|
||||||
|
|
||||||
|
|
||||||
|
class VtermRenderCommand(sublime_plugin.TextCommand):
|
||||||
|
"""Draws the screen into the view. Holds nothing a frame depends on: the
|
||||||
|
view's bookkeeping is `terminal.render_state` (see RenderState)."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
"""Per-instance scratch only; the render state is bound in run()."""
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
# the terminal's current SchemeManager and RenderState; set at the top of each run()
|
||||||
|
self._scheme = None
|
||||||
|
self.state = None
|
||||||
self._lastrow = 0
|
self._lastrow = 0
|
||||||
self._applied_cursor_style = None
|
self._applied_cursor_style = None
|
||||||
|
|
||||||
def next_key(self):
|
def next_key(self):
|
||||||
"""A region key unique to this view; per-row keys would collide after trim_history renumbers rows"""
|
"""A region key unique to this view; per-row keys would collide after trim_history renumbers rows"""
|
||||||
self._key_counter += 1
|
self.state.key_counter += 1
|
||||||
return "vterm#{}".format(self._key_counter)
|
return "vterm#{}".format(self.state.key_counter)
|
||||||
|
|
||||||
def ensure_row(self, edit, row):
|
def ensure_row(self, edit, row):
|
||||||
"""Row-existence check for the hot path, tracking the last row in python"""
|
"""Row-existence check for the hot path, tracking the last row in python"""
|
||||||
@@ -158,6 +177,9 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
if not terminal:
|
if not terminal:
|
||||||
return
|
return
|
||||||
self._scheme = terminal.scheme
|
self._scheme = terminal.scheme
|
||||||
|
if terminal.render_state is None:
|
||||||
|
terminal.render_state = RenderState()
|
||||||
|
self.state = terminal.render_state
|
||||||
|
|
||||||
screen = terminal.screen
|
screen = terminal.screen
|
||||||
# the caret and the tab name are the user's; an unfocused terminal leaves
|
# the caret and the tab name are the user's; an unfocused terminal leaves
|
||||||
@@ -256,16 +278,16 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
screen = terminal.screen
|
screen = terminal.screen
|
||||||
|
|
||||||
view.replace(edit, sublime.Region(0, view.size()), "")
|
view.replace(edit, sublime.Region(0, view.size()), "")
|
||||||
for line in list(self.colored_lines):
|
for line in list(self.state.colored_lines):
|
||||||
self.decolorize_line(line)
|
self.decolorize_line(line)
|
||||||
for chunk, scopes in self._archived_chunks.items():
|
for chunk, scopes in self.state.archived_chunks.items():
|
||||||
for scope in scopes:
|
for scope in scopes:
|
||||||
view.erase_regions(chunk_key(chunk, scope))
|
view.erase_regions(chunk_key(chunk, scope))
|
||||||
self._archived_chunks.clear()
|
self.state.archived_chunks.clear()
|
||||||
self._open_rows = []
|
self.state.open_rows = []
|
||||||
self._open_chunk = None
|
self.state.open_chunk = None
|
||||||
self._trimmed = 0
|
self.state.trimmed = 0
|
||||||
self._row_cache.clear()
|
self.state.row_cache.clear()
|
||||||
terminal.offset = 0
|
terminal.offset = 0
|
||||||
screen.history.clear()
|
screen.history.clear()
|
||||||
if wipe_screen:
|
if wipe_screen:
|
||||||
@@ -286,23 +308,33 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
# valid for this method only; the trim paths erase rows
|
# valid for this method only; the trim paths erase rows
|
||||||
self._lastrow = self.view.rowcol(self.view.size())[0]
|
self._lastrow = self.view.rowcol(self.view.size())[0]
|
||||||
if dirty_lines:
|
if dirty_lines:
|
||||||
# history pops newest-first, so it fills the rows below `offset` backwards
|
|
||||||
history = screen.history
|
history = screen.history
|
||||||
popped = len(history)
|
popped = len(history)
|
||||||
terminal.offset += popped
|
terminal.offset += popped
|
||||||
offset = terminal.offset
|
offset = terminal.offset
|
||||||
|
first = offset - popped
|
||||||
|
retired = list(history)
|
||||||
|
history.clear()
|
||||||
|
|
||||||
for line in range(popped):
|
# rows the view already holds are rewritten one by one; the rest
|
||||||
buffer_line = history.pop()
|
# are new and go in as one insert, after those edits moved the end
|
||||||
self.update_line(edit, offset - line - 1, buffer_line, columns)
|
split = min(popped, self._lastrow + 1 - first)
|
||||||
|
for i in range(split):
|
||||||
|
self.update_line(edit, first + i, retired[i], columns)
|
||||||
|
if split < popped:
|
||||||
|
self.append_rows(edit, retired[split:], columns, scrollback=True)
|
||||||
|
|
||||||
# after the loop: a pop edits an earlier point than the one before
|
# after the edits: spans measured mid-loop would be stale
|
||||||
# it, so spans measured mid-loop would be stale
|
self.archive_rows(range(first, offset))
|
||||||
self.archive_rows(range(offset - popped, offset))
|
|
||||||
|
|
||||||
for line in dirty_lines:
|
# screen rows past the view's end go in as one insert too
|
||||||
buffer_line = screen.buffer[line]
|
start = self._lastrow + 1 - offset
|
||||||
self.update_line(edit, line + offset, buffer_line, columns)
|
cut = bisect_left(dirty_lines, start)
|
||||||
|
for line in dirty_lines[:cut]:
|
||||||
|
self.update_line(edit, line + offset, screen.buffer[line], columns)
|
||||||
|
if cut < len(dirty_lines):
|
||||||
|
rows = range(start, dirty_lines[-1] + 1)
|
||||||
|
self.append_rows(edit, [screen.buffer[y] for y in rows], columns, scrollback=False)
|
||||||
|
|
||||||
def update_line(self, edit, line, buffer_line, columns):
|
def update_line(self, edit, line, buffer_line, columns):
|
||||||
"""Rewrite one view row from a screen row, unless it is unchanged"""
|
"""Rewrite one view row from a screen row, unless it is unchanged"""
|
||||||
@@ -312,10 +344,20 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
# the dirty set is a repaint hint, not a diff: most dirty rows come
|
# the dirty set is a repaint hint, not a diff: most dirty rows come
|
||||||
# back identical, and rewriting one costs an edit plus its region churn
|
# back identical, and rewriting one costs an edit plus its region churn
|
||||||
rendered = (segments, lf)
|
rendered = (segments, lf)
|
||||||
if self._row_cache.get(line) == rendered:
|
if self.state.row_cache.get(line) == rendered:
|
||||||
return
|
return
|
||||||
self._row_cache[line] = rendered
|
self.state.row_cache[line] = rendered
|
||||||
|
text, by_scope = self.row_text(segments, lf)
|
||||||
|
|
||||||
|
self.ensure_row(edit, line)
|
||||||
|
line_region = view.line(view.text_point(line, 0))
|
||||||
|
self.decolorize_line(line)
|
||||||
|
view.replace(edit, line_region, text)
|
||||||
|
# replacing a line cannot move its start
|
||||||
|
self.colorize_line(line, line_region.begin(), by_scope)
|
||||||
|
|
||||||
|
def row_text(self, segments, lf):
|
||||||
|
"""The view text of a row and its colour spans by scope, as char offsets."""
|
||||||
by_scope = {}
|
by_scope = {}
|
||||||
needed = 0
|
needed = 0
|
||||||
scope_of = self._scheme.scope
|
scope_of = self._scheme.scope
|
||||||
@@ -332,18 +374,59 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
if len(text) < needed:
|
if len(text) < needed:
|
||||||
# pad only to the last coloured run, in the same edit as the text
|
# pad only to the last coloured run, in the same edit as the text
|
||||||
text += " " * (needed - len(text))
|
text += " " * (needed - len(text))
|
||||||
|
return text, by_scope
|
||||||
|
|
||||||
self.ensure_row(edit, line)
|
def append_rows(self, edit, buffer_lines, columns, scrollback):
|
||||||
line_region = view.line(view.text_point(line, 0))
|
"""Add rows below the view's end in one insert.
|
||||||
self.decolorize_line(line)
|
|
||||||
view.replace(edit, line_region, text)
|
Scrollback rows are never rewritten, so their colours go straight onto
|
||||||
# replacing a line cannot move its start
|
the chunk keys with no per-row key for flush_chunk to merge. Screen
|
||||||
self.colorize_line(line, line_region.begin(), by_scope)
|
rows are coloured per row as usual; a trailing blank run the trim
|
||||||
|
already erased is still cached as rendered and is left out.
|
||||||
|
"""
|
||||||
|
view = self.view
|
||||||
|
rows = [(line, list(segment_buffer_line(buffer_line)), buffer_line[columns - 1].linefeed)
|
||||||
|
for line, buffer_line in enumerate(buffer_lines, self._lastrow + 1)]
|
||||||
|
if not scrollback:
|
||||||
|
while rows and self.state.row_cache.get(rows[-1][0]) == rows[-1][1:]:
|
||||||
|
rows.pop()
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
|
||||||
|
point = view.size() + 1
|
||||||
|
texts = []
|
||||||
|
# scrollback: (chunk, scope) -> regions; screen: (line, start, by_scope)
|
||||||
|
painted = {}
|
||||||
|
colored = []
|
||||||
|
trimmed = self.state.trimmed
|
||||||
|
for line, segments, lf in rows:
|
||||||
|
self.state.row_cache[line] = (segments, lf)
|
||||||
|
text, by_scope = self.row_text(segments, lf)
|
||||||
|
texts.append(text)
|
||||||
|
if scrollback:
|
||||||
|
chunk = (line + trimmed) // CHUNK
|
||||||
|
for scope, spans in by_scope.items():
|
||||||
|
painted.setdefault((chunk, scope), []).extend(sublime.Region(point + a, point + b) for a, b in spans)
|
||||||
|
elif by_scope:
|
||||||
|
colored.append((line, point, by_scope))
|
||||||
|
point += len(text) + 1
|
||||||
|
view.insert(edit, view.size(), "\n" + "\n".join(texts))
|
||||||
|
self._lastrow = rows[-1][0]
|
||||||
|
|
||||||
|
for (chunk, scope), regions in painted.items():
|
||||||
|
key = chunk_key(chunk, scope)
|
||||||
|
scopes = self.state.archived_chunks.setdefault(chunk, set())
|
||||||
|
if scope in scopes:
|
||||||
|
regions = view.get_regions(key) + regions
|
||||||
|
view.add_regions(key, regions, scope)
|
||||||
|
scopes.add(scope)
|
||||||
|
for line, start, by_scope in colored:
|
||||||
|
self.colorize_line(line, start, by_scope)
|
||||||
|
|
||||||
def colorize_line(self, line, line_start, by_scope):
|
def colorize_line(self, line, line_start, by_scope):
|
||||||
"""Lay the colour overlays for one row, one add_regions per scope."""
|
"""Lay the colour overlays for one row, one add_regions per scope."""
|
||||||
view = self.view
|
view = self.view
|
||||||
keys = self.colored_lines.setdefault(line, []) if by_scope else None
|
keys = self.state.colored_lines.setdefault(line, []) if by_scope else None
|
||||||
for scope, spans in by_scope.items():
|
for scope, spans in by_scope.items():
|
||||||
key = self.next_key()
|
key = self.next_key()
|
||||||
view.add_regions(key, [sublime.Region(line_start + a, line_start + b) for a, b in spans], scope)
|
view.add_regions(key, [sublime.Region(line_start + a, line_start + b) for a, b in spans], scope)
|
||||||
@@ -351,21 +434,21 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
|
|
||||||
def decolorize_line(self, line):
|
def decolorize_line(self, line):
|
||||||
"""Drop a row's colour overlays before it is rewritten or erased."""
|
"""Drop a row's colour overlays before it is rewritten or erased."""
|
||||||
if line in self.colored_lines:
|
if line in self.state.colored_lines:
|
||||||
for key in self.colored_lines[line]:
|
for key in self.state.colored_lines[line]:
|
||||||
self.view.erase_regions(key)
|
self.view.erase_regions(key)
|
||||||
del self.colored_lines[line]
|
del self.state.colored_lines[line]
|
||||||
|
|
||||||
def archive_rows(self, rows):
|
def archive_rows(self, rows):
|
||||||
"""Queue rows that left the screen for their chunk's merge."""
|
"""Queue rows that left the screen for their chunk's merge."""
|
||||||
for row in rows:
|
for row in rows:
|
||||||
if row not in self.colored_lines:
|
if row not in self.state.colored_lines:
|
||||||
continue
|
continue
|
||||||
chunk = (row + self._trimmed) // CHUNK
|
chunk = (row + self.state.trimmed) // CHUNK
|
||||||
if chunk != self._open_chunk:
|
if chunk != self.state.open_chunk:
|
||||||
self.flush_chunk()
|
self.flush_chunk()
|
||||||
self._open_chunk = chunk
|
self.state.open_chunk = chunk
|
||||||
self._open_rows.append(row)
|
self.state.open_rows.append(row)
|
||||||
|
|
||||||
def flush_chunk(self):
|
def flush_chunk(self):
|
||||||
"""Merge the queued rows onto shared per-(scope, chunk) keys.
|
"""Merge the queued rows onto shared per-(scope, chunk) keys.
|
||||||
@@ -376,8 +459,8 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
whole set, so merging every frame would be quadratic in CHUNK.
|
whole set, so merging every frame would be quadratic in CHUNK.
|
||||||
"""
|
"""
|
||||||
view = self.view
|
view = self.view
|
||||||
rows, chunk = self._open_rows, self._open_chunk
|
rows, chunk = self.state.open_rows, self.state.open_chunk
|
||||||
self._open_rows = []
|
self.state.open_rows = []
|
||||||
if not rows:
|
if not rows:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -385,12 +468,12 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
stale = []
|
stale = []
|
||||||
scope_of = self._scheme.scope
|
scope_of = self._scheme.scope
|
||||||
for row in rows:
|
for row in rows:
|
||||||
keys = self.colored_lines.pop(row, None)
|
keys = self.state.colored_lines.pop(row, None)
|
||||||
if not keys:
|
if not keys:
|
||||||
continue
|
continue
|
||||||
stale += keys
|
stale += keys
|
||||||
# the segments that were painted, straight from the row cache
|
# the segments that were painted, straight from the row cache
|
||||||
segments = self._row_cache[row][0]
|
segments = self.state.row_cache[row][0]
|
||||||
start = view.line(view.text_point(row, 0)).begin()
|
start = view.line(view.text_point(row, 0)).begin()
|
||||||
for _, a, b, fg, bg, style in segments:
|
for _, a, b, fg, bg, style in segments:
|
||||||
if is_default_run(fg, bg, style):
|
if is_default_run(fg, bg, style):
|
||||||
@@ -398,7 +481,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
scope = scope_of(fg, bg, style)
|
scope = scope_of(fg, bg, style)
|
||||||
grouped.setdefault(scope, []).append(sublime.Region(start + a, start + b))
|
grouped.setdefault(scope, []).append(sublime.Region(start + a, start + b))
|
||||||
|
|
||||||
scopes = self._archived_chunks.setdefault(chunk, set())
|
scopes = self.state.archived_chunks.setdefault(chunk, set())
|
||||||
for scope, regions in grouped.items():
|
for scope, regions in grouped.items():
|
||||||
key = chunk_key(chunk, scope)
|
key = chunk_key(chunk, scope)
|
||||||
if scope in scopes:
|
if scope in scopes:
|
||||||
@@ -431,7 +514,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
while row > cursor_row:
|
while row > cursor_row:
|
||||||
line_region = view.line(view.text_point(row, 0))
|
line_region = view.line(view.text_point(row, 0))
|
||||||
text = view.substr(line_region)
|
text = view.substr(line_region)
|
||||||
if len(text.strip()) == 0 and (row not in self.colored_lines or not self.colored_lines[row]):
|
if len(text.strip()) == 0 and (row not in self.state.colored_lines or not self.state.colored_lines[row]):
|
||||||
row = row - 1
|
row = row - 1
|
||||||
else:
|
else:
|
||||||
break
|
break
|
||||||
@@ -445,22 +528,23 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
trailing_region = sublime.Region(line_region.begin() + rev_wcwidth(text, cursor.x) + 1, line_region.end())
|
trailing_region = sublime.Region(line_region.begin() + rev_wcwidth(text, cursor.x) + 1, line_region.end())
|
||||||
if not trailing_region.empty() and len(view.substr(trailing_region).strip()) == 0:
|
if not trailing_region.empty() and len(view.substr(trailing_region).strip()) == 0:
|
||||||
view.erase(edit, trailing_region)
|
view.erase(edit, trailing_region)
|
||||||
self._row_cache.pop(row, None)
|
self.state.row_cache.pop(row, None)
|
||||||
|
|
||||||
def trim_history(self, edit, terminal):
|
def trim_history(self, edit, terminal):
|
||||||
"""Hold the view to scrollback_size rows, and drop rows below the screen."""
|
"""Hold the view to scrollback_size rows, and drop rows below the screen."""
|
||||||
view = self.view
|
view = self.view
|
||||||
screen = terminal.screen
|
screen = terminal.screen
|
||||||
lastrow = view.rowcol(view.size())[0]
|
lastrow = view.rowcol(view.size())[0]
|
||||||
n = terminal.scrollback_size
|
# the setting counts rows above the screen; 0 keeps the screen alone
|
||||||
|
n = terminal.scrollback_size + screen.lines
|
||||||
if lastrow + 1 > n:
|
if lastrow + 1 > n:
|
||||||
# queued rows are about to be renumbered or erased: merge them first
|
# queued rows are about to be renumbered or erased: merge them first
|
||||||
self.flush_chunk()
|
self.flush_chunk()
|
||||||
m = max(lastrow + 1 - n, math.ceil(n / 10))
|
m = max(lastrow + 1 - n, math.ceil(n / 10))
|
||||||
for line in range(m):
|
for line in range(m):
|
||||||
self.decolorize_line(line)
|
self.decolorize_line(line)
|
||||||
self.colored_lines = {k - m: v for (k, v) in self.colored_lines.items()}
|
self.state.colored_lines = {k - m: v for (k, v) in self.state.colored_lines.items()}
|
||||||
self._row_cache = {k - m: v for (k, v) in self._row_cache.items() if k >= m}
|
self.state.row_cache = {k - m: v for (k, v) in self.state.row_cache.items() if k >= m}
|
||||||
top_region = sublime.Region(0, view.full_line(view.text_point(m - 1, 0)).end())
|
top_region = sublime.Region(0, view.full_line(view.text_point(m - 1, 0)).end())
|
||||||
view.erase(edit, top_region)
|
view.erase(edit, top_region)
|
||||||
terminal.offset -= m
|
terminal.offset -= m
|
||||||
@@ -468,12 +552,12 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
|
|
||||||
# chunks wholly above the cut go; the boundary chunk drops the
|
# chunks wholly above the cut go; the boundary chunk drops the
|
||||||
# regions the erase collapsed to empty
|
# regions the erase collapsed to empty
|
||||||
self._trimmed += m
|
self.state.trimmed += m
|
||||||
cut = self._trimmed // CHUNK
|
cut = self.state.trimmed // CHUNK
|
||||||
for chunk in [c for c in self._archived_chunks if c < cut]:
|
for chunk in [c for c in self.state.archived_chunks if c < cut]:
|
||||||
for scope in self._archived_chunks.pop(chunk):
|
for scope in self.state.archived_chunks.pop(chunk):
|
||||||
view.erase_regions(chunk_key(chunk, scope))
|
view.erase_regions(chunk_key(chunk, scope))
|
||||||
for scope in self._archived_chunks.get(cut, ()):
|
for scope in self.state.archived_chunks.get(cut, ()):
|
||||||
key = chunk_key(cut, scope)
|
key = chunk_key(cut, scope)
|
||||||
view.add_regions(key, [r for r in view.get_regions(key) if not r.empty()], scope)
|
view.add_regions(key, [r for r in view.get_regions(key) if not r.empty()], scope)
|
||||||
|
|
||||||
@@ -482,7 +566,7 @@ class VtermRenderCommand(sublime_plugin.TextCommand):
|
|||||||
for line in view.lines(tail_region):
|
for line in view.lines(tail_region):
|
||||||
row = view.rowcol(line.begin())[0]
|
row = view.rowcol(line.begin())[0]
|
||||||
self.decolorize_line(row)
|
self.decolorize_line(row)
|
||||||
self._row_cache.pop(row, None)
|
self.state.row_cache.pop(row, None)
|
||||||
view.erase(edit, tail_region)
|
view.erase(edit, tail_region)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import codecs
|
import codecs
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from collections import deque
|
from collections import deque
|
||||||
@@ -26,6 +27,9 @@ UNFOCUSED_RENDER_PERIOD = 0.075
|
|||||||
# max chars parsed per tick, so a giant burst still renders intermediate frames.
|
# max chars parsed per tick, so a giant burst still renders intermediate frames.
|
||||||
FEED_LIMIT = 48 * 1024
|
FEED_LIMIT = 48 * 1024
|
||||||
|
|
||||||
|
# unparsed 8K reads the reader may hold before it pauses: ~10 ticks of parsing
|
||||||
|
PENDING_CHUNKS = 64
|
||||||
|
|
||||||
# SUPPRESS_HISTORY_AFTER_CLEAR in emulator.py must outlast this
|
# SUPPRESS_HISTORY_AFTER_CLEAR in emulator.py must outlast this
|
||||||
REPAINT_NUDGE_DELAY = 0.25
|
REPAINT_NUDGE_DELAY = 0.25
|
||||||
|
|
||||||
@@ -37,6 +41,10 @@ IDLE_TICK = 0.2
|
|||||||
# a millisecond apart, and drawing between them flashes its showcmd echo
|
# a millisecond apart, and drawing between them flashes its showcmd echo
|
||||||
SETTLE = 0.005
|
SETTLE = 0.005
|
||||||
|
|
||||||
|
# C0 controls bar tab/LF/CR, stripped from text sent as typed: a pasted
|
||||||
|
# ESC[201~ would end the bracket early and type the rest into the app
|
||||||
|
CONTROL_CHARS = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]")
|
||||||
|
|
||||||
# frames between undo-stack clears: every render edit lands on the view's undo
|
# frames between undo-stack clears: every render edit lands on the view's undo
|
||||||
# stack, which only focus changes clear otherwise, and undo is swallowed anyway
|
# stack, which only focus changes clear otherwise, and undo is swallowed anyway
|
||||||
UNDO_CLEAR_FRAMES = 100
|
UNDO_CLEAR_FRAMES = 100
|
||||||
@@ -101,6 +109,7 @@ class Terminal:
|
|||||||
"""State only; the pty and the threads come up in start()."""
|
"""State only; the pty and the threads come up in start()."""
|
||||||
self.view = view
|
self.view = view
|
||||||
self.scheme = scheme
|
self.scheme = scheme
|
||||||
|
self.render_state = None
|
||||||
# process.isalive() costs two waitpid syscalls; UI hooks read this instead
|
# process.isalive() costs two waitpid syscalls; UI hooks read this instead
|
||||||
self.alive = True
|
self.alive = True
|
||||||
self.default_title = ""
|
self.default_title = ""
|
||||||
@@ -120,6 +129,8 @@ class Terminal:
|
|||||||
# guards the queue/flag handoff between send_string and the drain thread
|
# guards the queue/flag handoff between send_string and the drain thread
|
||||||
self._send_lock = threading.Lock()
|
self._send_lock = threading.Lock()
|
||||||
self._wheel_captured = False
|
self._wheel_captured = False
|
||||||
|
self._mouse_reporting = False
|
||||||
|
self.mouse_press = None # cell of the pending button-1 press, for the release report
|
||||||
self._scroll_pending = False
|
self._scroll_pending = False
|
||||||
self.pending_clear = False
|
self.pending_clear = False
|
||||||
self.pending_clear_screen = False
|
self.pending_clear_screen = False
|
||||||
@@ -197,7 +208,6 @@ class Terminal:
|
|||||||
history=self.scrollback_size,
|
history=self.scrollback_size,
|
||||||
erase_scrollback=settings().get("honor_erase_scrollback", False),
|
erase_scrollback=settings().get("honor_erase_scrollback", False),
|
||||||
clear_callback=self._clear_callback,
|
clear_callback=self._clear_callback,
|
||||||
clipboard_callback=self._clipboard_callback,
|
|
||||||
fg_color=self.scheme.foreground.lstrip("#"),
|
fg_color=self.scheme.foreground.lstrip("#"),
|
||||||
bg_color=self.scheme.background.lstrip("#"),
|
bg_color=self.scheme.background.lstrip("#"),
|
||||||
is_dark=self.scheme.is_dark(),
|
is_dark=self.scheme.is_dark(),
|
||||||
@@ -230,7 +240,8 @@ class Terminal:
|
|||||||
self.alive = False
|
self.alive = False
|
||||||
self._wake.set()
|
self._wake.set()
|
||||||
try:
|
try:
|
||||||
self.process.terminate()
|
# force: a child ignoring HUP and INT would otherwise outlive the tab
|
||||||
|
self.process.terminate(force=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
LOG.warning("terminate failed", exc_info=True)
|
LOG.warning("terminate failed", exc_info=True)
|
||||||
vid = self.view.id()
|
vid = self.view.id()
|
||||||
@@ -241,10 +252,6 @@ class Terminal:
|
|||||||
"""Screen asked for a clear; the next render performs it on the UI thread."""
|
"""Screen asked for a clear; the next render performs it on the UI thread."""
|
||||||
self.pending_clear = True
|
self.pending_clear = True
|
||||||
|
|
||||||
def _clipboard_callback(self, text):
|
|
||||||
"""OSC 52 payload; the clipboard may only be touched on the UI thread."""
|
|
||||||
sublime.set_timeout(lambda: sublime.set_clipboard(text))
|
|
||||||
|
|
||||||
def _need_to_render(self):
|
def _need_to_render(self):
|
||||||
"""Whether anything changed since the last frame: dirty rows, a clear, or the cursor."""
|
"""Whether anything changed since the last frame: dirty rows, a clear, or the cursor."""
|
||||||
screen = self.screen
|
screen = self.screen
|
||||||
@@ -280,6 +287,10 @@ class Terminal:
|
|||||||
break
|
break
|
||||||
chunks.append(self._decoder.decode(raw))
|
chunks.append(self._decoder.decode(raw))
|
||||||
self._wake.set()
|
self._wake.set()
|
||||||
|
# backpressure: once this much waits unparsed, stop reading so
|
||||||
|
# the pty fills and the child blocks, as under a real terminal
|
||||||
|
while len(chunks) > PENDING_CHUNKS and not done[0] and self.is_hosted():
|
||||||
|
time.sleep(RENDER_PERIOD)
|
||||||
if done[0] or not self.is_hosted():
|
if done[0] or not self.is_hosted():
|
||||||
LOG.debug("[reader] stops")
|
LOG.debug("[reader] stops")
|
||||||
break
|
break
|
||||||
@@ -328,7 +339,7 @@ class Terminal:
|
|||||||
if frames >= UNDO_CLEAR_FRAMES:
|
if frames >= UNDO_CLEAR_FRAMES:
|
||||||
frames = 0
|
frames = 0
|
||||||
self.view.run_command("vterm_clear_undo_stack")
|
self.view.run_command("vterm_clear_undo_stack")
|
||||||
self._sync_wheel_flag()
|
self._sync_mouse_flags()
|
||||||
if done[0] or not self.is_hosted():
|
if done[0] or not self.is_hosted():
|
||||||
LOG.debug("[renderer] stops")
|
LOG.debug("[renderer] stops")
|
||||||
break
|
break
|
||||||
@@ -388,7 +399,7 @@ class Terminal:
|
|||||||
# escape sequences go through untouched; typed and pasted text has its
|
# escape sequences go through untouched; typed and pasted text has its
|
||||||
# line endings canonicalised for the app's LNM setting
|
# line endings canonicalised for the app's LNM setting
|
||||||
if normalized:
|
if normalized:
|
||||||
string = string.replace("\r\n", "\n")
|
string = CONTROL_CHARS.sub("", string).replace("\r\n", "\n")
|
||||||
if mo.LNM in self.screen.mode:
|
if mo.LNM in self.screen.mode:
|
||||||
string = string.replace("\n", "\r\n")
|
string = string.replace("\n", "\r\n")
|
||||||
else:
|
else:
|
||||||
@@ -424,12 +435,16 @@ class Terminal:
|
|||||||
continue
|
continue
|
||||||
self.process.write(string)
|
self.process.write(string)
|
||||||
|
|
||||||
def _sync_wheel_flag(self):
|
def _sync_mouse_flags(self):
|
||||||
"""Publish whether the app owns the wheel, for the mousemap context."""
|
"""Publish whether the app owns the wheel and the mouse, for the mousemap contexts."""
|
||||||
captured = self.screen.mouse_reporting_enabled() or self.screen.alternate_buffer_mode
|
reporting = self.screen.mouse_reporting_enabled()
|
||||||
|
captured = reporting or self.screen.alternate_buffer_mode
|
||||||
if captured != self._wheel_captured:
|
if captured != self._wheel_captured:
|
||||||
self._wheel_captured = captured
|
self._wheel_captured = captured
|
||||||
self.view.settings().set("vterm_view.wheel_captured", captured)
|
self.view.settings().set("vterm_view.wheel_captured", captured)
|
||||||
|
if reporting != self._mouse_reporting:
|
||||||
|
self._mouse_reporting = reporting
|
||||||
|
self.view.settings().set("vterm_view.mouse_reporting", reporting)
|
||||||
|
|
||||||
def send_focus(self, focused):
|
def send_focus(self, focused):
|
||||||
"""Report focus gain or loss, when the app asked for it (?1004)."""
|
"""Report focus gain or loss, when the app asked for it (?1004)."""
|
||||||
|
|||||||
Reference in New Issue
Block a user