From 93b189381a9324492a9bffe1785dc4eaff0564a5 Mon Sep 17 00:00:00 2001 From: FichteFoll Date: Wed, 14 Oct 2026 20:03:54 +0200 Subject: [PATCH 01/21] Add the `tabstack-keystate` evdev helper and its CI smoke test Native Wayland clients cannot query the global keyboard state, so Ctrl-release detection needs another source. `tabstack-keystate` reads the Ctrl state from `/dev/input/event*` with `EVIOCGKEY` and reports changes on stdout using protocol version 1. It is meant to be installed with `cap_dac_read_search=ep`, sets `PR_SET_NO_NEW_PRIVS` and drops all capabilities right after opening the devices. The new `keystate-helper` CI job builds it with `-Werror` and runs `tests/keystate_smoke.py` against a virtual `uinput` keyboard, with and without the capability. `tests/` is excluded from package exports. Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 1 + .github/workflows/ci.yml | 9 ++ keystate/tabstack-keystate.c | 193 +++++++++++++++++++++++++++ tests/keystate_smoke.py | 251 +++++++++++++++++++++++++++++++++++ 4 files changed, 454 insertions(+) create mode 100644 keystate/tabstack-keystate.c create mode 100644 tests/keystate_smoke.py diff --git a/.gitattributes b/.gitattributes index 3ed0f36..bba8b5d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ media/** export-ignore uv.lock export-ignore +tests/** export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce5794b..1f3ae01 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,3 +18,12 @@ jobs: - run: uv sync --group dev --no-install-project - run: uv run ruff check . - run: uv run basedpyright + + keystate-helper: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6.0.3 + - run: sudo modprobe uinput + - run: mkdir -p build + - run: cc -std=c11 -O2 -Wall -Wextra -Werror -o build/tabstack-keystate keystate/tabstack-keystate.c + - run: sudo python3 -I tests/keystate_smoke.py --helper build/tabstack-keystate --user "$USER" diff --git a/keystate/tabstack-keystate.c b/keystate/tabstack-keystate.c new file mode 100644 index 0000000..0fcc9ac --- /dev/null +++ b/keystate/tabstack-keystate.c @@ -0,0 +1,193 @@ +/* + * tabstack-keystate - reports whether a Ctrl key is held, for the TabStack + * Sublime Text package. + * + * A native Wayland client cannot query the global keyboard state, so TabStack + * cannot tell when Ctrl is released after Ctrl+Tab. This helper reads the key + * state from the kernel's evdev devices (/dev/input/event*) instead. + * + * Those devices are readable only by root and the `input` group. Installed with + * `cap_dac_read_search=ep`, the helper can open them read-only without either. + * It sets no_new_privs first and drops all capabilities right after opening + * the devices, before it reads any input or reports anything that depends on + * them. + * + * Protocol, version 1: ASCII lines on stdout, each flushed. + * "tabstack-keystate 1" first, then either "error " and + * exit status 1, or the state ("1" while a Ctrl key is held, else "0") + * followed by one line per change. Exits 0 when stdin reaches EOF. + * + * Build: cc -std=c11 -O2 -Wall -Wextra -o tabstack-keystate tabstack-keystate.c + */ +#define _DEFAULT_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define MAX_DEVICES 64 +#define BITS_PER_LONG (8 * sizeof(unsigned long)) +#define KEY_LONGS ((KEY_CNT + BITS_PER_LONG - 1) / BITS_PER_LONG) + +static int devices[MAX_DEVICES]; +static int device_count; + +static void emit(const char *line) { + if (puts(line) == EOF || fflush(stdout) == EOF) { + exit(errno == EPIPE ? 0 : 1); + } +} + +static void fail(const char *code, const char *message) { + char line[256]; + snprintf(line, sizeof line, "error %s %s", code, message); + emit(line); + exit(1); +} + +static bool has_ctrl(const unsigned long *keys) { + const unsigned int codes[] = {KEY_LEFTCTRL, KEY_RIGHTCTRL}; + for (size_t i = 0; i < sizeof codes / sizeof codes[0]; i++) { + if ((keys[codes[i] / BITS_PER_LONG] >> (codes[i] % BITS_PER_LONG)) & 1UL) { + return true; + } + } + return false; +} + +/* Opens every device with a Ctrl key. Returns whether any open was denied. */ +static bool open_keyboards(void) { + DIR *dir = opendir("/dev/input"); + if (dir == NULL) { + return errno == EACCES || errno == EPERM; + } + bool denied = false; + struct dirent *entry; + while (device_count < MAX_DEVICES && (entry = readdir(dir)) != NULL) { + if (strncmp(entry->d_name, "event", 5) != 0) { + continue; + } + int fd = openat(dirfd(dir), entry->d_name, O_RDONLY | O_NONBLOCK | O_CLOEXEC); + if (fd < 0) { + denied = denied || errno == EACCES || errno == EPERM; + continue; + } + unsigned long keys[KEY_LONGS] = {0}; + if (ioctl(fd, EVIOCGBIT(EV_KEY, sizeof keys), keys) < 0 || !has_ctrl(keys)) { + close(fd); + continue; + } + devices[device_count++] = fd; + } + closedir(dir); + return denied; +} + +/* Raw capset(2) instead of libcap, so that building needs no extra library. */ +static int drop_capabilities(void) { + struct __user_cap_header_struct header = {.version = _LINUX_CAPABILITY_VERSION_3, .pid = 0}; + struct __user_cap_data_struct data[_LINUX_CAPABILITY_U32S_3]; + memset(data, 0, sizeof data); + return (int)syscall(SYS_capset, &header, data); +} + +static int ctrl_state(void) { + for (int i = 0; i < device_count; i++) { + unsigned long keys[KEY_LONGS] = {0}; + if (ioctl(devices[i], EVIOCGKEY(sizeof keys), keys) >= 0 && has_ctrl(keys)) { + return 1; + } + } + return 0; +} + +/* Discards pending events; only EVIOCGKEY matters. Returns false once the device is gone. */ +static bool drain(int fd) { + struct input_event events[64]; + for (;;) { + ssize_t n = read(fd, events, sizeof events); + if (n > 0 || (n < 0 && errno == EINTR)) { + continue; + } + return n < 0 && errno == EAGAIN; + } +} + +/* Returns false on stdin EOF, hangup or error. */ +static bool stdin_open(short revents) { + if (!(revents & (POLLIN | POLLHUP | POLLERR | POLLNVAL))) { + return true; + } + char buffer[256]; + ssize_t n = read(STDIN_FILENO, buffer, sizeof buffer); + return n > 0 || (n < 0 && (errno == EINTR || errno == EAGAIN)); +} + +int main(void) { + signal(SIGPIPE, SIG_IGN); + if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) { + perror("tabstack-keystate: prctl"); + return 1; + } + emit("tabstack-keystate 1"); + + bool denied = open_keyboards(); + if (drop_capabilities() != 0) { + fail("capset", strerror(errno)); + } + if (device_count == 0) { + if (denied) { + fail("no-access", "cannot open /dev/input/event* (is cap_dac_read_search set?)"); + } + fail("no-keyboards", "no input device has a Ctrl key"); + } + + int state = ctrl_state(); + emit(state ? "1" : "0"); + + for (;;) { + struct pollfd fds[MAX_DEVICES + 1]; + fds[0] = (struct pollfd){.fd = STDIN_FILENO, .events = POLLIN}; + for (int i = 0; i < device_count; i++) { + fds[i + 1] = (struct pollfd){.fd = devices[i], .events = POLLIN}; + } + if (poll(fds, (nfds_t)device_count + 1, -1) < 0) { + if (errno == EINTR) { + continue; + } + return 1; + } + if (!stdin_open(fds[0].revents)) { + return 0; + } + /* Backwards, so that moving the last device into a removed slot is safe. */ + for (int i = device_count - 1; i >= 0; i--) { + short revents = fds[i + 1].revents; + bool gone = revents & (POLLERR | POLLHUP | POLLNVAL); + if (!gone && (revents & POLLIN)) { + gone = !drain(devices[i]); + } + if (gone) { + close(devices[i]); + devices[i] = devices[--device_count]; + } + } + int next = ctrl_state(); + if (next != state) { + state = next; + emit(state ? "1" : "0"); + } + } +} diff --git a/tests/keystate_smoke.py b/tests/keystate_smoke.py new file mode 100644 index 0000000..32545f7 --- /dev/null +++ b/tests/keystate_smoke.py @@ -0,0 +1,251 @@ +"""Smoke test for the `tabstack-keystate` helper against a virtual uinput keyboard. + +Must run as root, with the `uinput` module loaded: + + sudo python3 -I tests/keystate_smoke.py --helper build/tabstack-keystate --user "$USER" + +The user given with `--user` must not be able to read `/dev/input/event*` on its own, +otherwise the check without the capability is not meaningful. +""" + +from __future__ import annotations + +import argparse +import fcntl +import os +import pwd +import selectors +import shutil +import struct +import subprocess +import sys +import tempfile +import time +from typing import Optional, cast + +HANDSHAKE = "tabstack-keystate 1" +LINE_TIMEOUT = 2.0 +SILENCE_TIMEOUT = 0.3 +EXIT_TIMEOUT = 1.0 +# `/tmp` is often a `nosuid` tmpfs, e.g. on systemd hosts, and the capable copy lives here. +TEMP_PARENT = "/var/tmp" + +# +EV_SYN = 0x00 +EV_KEY = 0x01 +SYN_REPORT = 0 +KEY_LEFTCTRL = 29 +KEY_A = 30 +KEY_RIGHTCTRL = 97 +BUS_USB = 0x03 + +# struct input_event: struct timeval, __u16 type, __u16 code, __s32 value +INPUT_EVENT = struct.Struct("@llHHi") +# struct uinput_setup: struct input_id (4 x __u16), char name[80], __u32 ff_effects_max +UINPUT_SETUP = struct.Struct("@HHHH80sI") + + +def _ioc(direction: int, number: int, size: int) -> int: + return (direction << 30) | (size << 16) | (ord("U") << 8) | number + + +# ; _IOC_WRITE is 1 +UI_DEV_CREATE = _ioc(0, 1, 0) +UI_DEV_DESTROY = _ioc(0, 2, 0) +UI_DEV_SETUP = _ioc(1, 3, UINPUT_SETUP.size) +UI_SET_EVBIT = _ioc(1, 100, 4) +UI_SET_KEYBIT = _ioc(1, 101, 4) + + +class SmokeFailure(Exception): + pass + + +class VirtualKeyboard: + def __init__(self) -> None: + self._fd: int = os.open("/dev/uinput", os.O_WRONLY | os.O_NONBLOCK) + _ = fcntl.ioctl(self._fd, UI_SET_EVBIT, EV_KEY) + for key in (KEY_LEFTCTRL, KEY_RIGHTCTRL, KEY_A): + _ = fcntl.ioctl(self._fd, UI_SET_KEYBIT, key) + setup = UINPUT_SETUP.pack(BUS_USB, 0x1234, 0x5678, 1, b"tabstack-keystate smoke test", 0) + _ = fcntl.ioctl(self._fd, UI_DEV_SETUP, setup) + _ = fcntl.ioctl(self._fd, UI_DEV_CREATE) + # udev needs a moment to create the device node. + time.sleep(0.5) + + def press(self, key: int, value: int) -> None: + _ = os.write(self._fd, INPUT_EVENT.pack(0, 0, EV_KEY, key, value)) + _ = os.write(self._fd, INPUT_EVENT.pack(0, 0, EV_SYN, SYN_REPORT, 0)) + + def destroy(self) -> None: + try: + _ = fcntl.ioctl(self._fd, UI_DEV_DESTROY) + finally: + os.close(self._fd) + + +class Helper: + def __init__(self, path: str, user: pwd.struct_passwd) -> None: + command = [ + "setpriv", + "--reuid", + str(user.pw_uid), + "--regid", + str(user.pw_gid), + "--init-groups", + path, + ] + self.process: subprocess.Popen[bytes] = subprocess.Popen( + command, stdin=subprocess.PIPE, stdout=subprocess.PIPE + ) + stdout = self.process.stdout + assert stdout is not None + self._stdout_fd: int = stdout.fileno() + self._selector: selectors.DefaultSelector = selectors.DefaultSelector() + _ = self._selector.register(self._stdout_fd, selectors.EVENT_READ) + self._buffer: bytes = b"" + + def read_line(self, timeout: float) -> Optional[str]: + """Return the next line, or `None` on timeout or EOF.""" + deadline = time.monotonic() + timeout + while b"\n" not in self._buffer: + remaining = deadline - time.monotonic() + if remaining <= 0 or not self._selector.select(remaining): + return None + chunk = os.read(self._stdout_fd, 4096) + if not chunk: + return None + self._buffer += chunk + line, self._buffer = self._buffer.split(b"\n", 1) + return line.decode("ascii", "replace") + + def expect_line(self, expected: str, step: str) -> None: + line = self.read_line(LINE_TIMEOUT) + if line != expected: + raise SmokeFailure(f"{step}: expected {expected!r}, got {line!r}") + + def expect_silence(self, step: str) -> None: + line = self.read_line(SILENCE_TIMEOUT) + if line is not None: + raise SmokeFailure(f"{step}: expected no output, got {line!r}") + + def expect_exit(self, expected: int, step: str) -> None: + try: + status = self.process.wait(EXIT_TIMEOUT) + except subprocess.TimeoutExpired: + raise SmokeFailure(f"{step}: helper still running after {EXIT_TIMEOUT} s") from None + if status != expected: + raise SmokeFailure(f"{step}: expected exit status {expected}, got {status}") + + def close(self) -> None: + self._selector.close() + if self.process.poll() is None: + self.process.kill() + _ = self.process.wait() + for stream in (self.process.stdin, self.process.stdout): + if stream is not None: + stream.close() + + +def check_capabilities_dropped(pid: int) -> None: + with open(f"/proc/{pid}/status") as status: + for line in status: + name, _, value = line.partition(":") + if name in ("CapEff", "CapPrm", "CapAmb") and int(value, 16) != 0: + raise SmokeFailure(f"capabilities not dropped: {name} = {value.strip()}") + + +def run_capable(path: str, user: pwd.struct_passwd, keyboard: VirtualKeyboard) -> None: + helper = Helper(path, user) + try: + helper.expect_line(HANDSHAKE, "capable: handshake") + helper.expect_line("0", "capable: initial state") + check_capabilities_dropped(helper.process.pid) + for name, key in (("left", KEY_LEFTCTRL), ("right", KEY_RIGHTCTRL)): + keyboard.press(key, 1) + helper.expect_line("1", f"capable: {name} Ctrl down") + keyboard.press(key, 0) + helper.expect_line("0", f"capable: {name} Ctrl up") + keyboard.press(KEY_A, 1) + keyboard.press(KEY_A, 0) + helper.expect_silence("capable: KEY_A down/up") + stdin = helper.process.stdin + assert stdin is not None + stdin.close() + helper.expect_exit(0, "capable: stdin closed") + finally: + helper.close() + + +def run_incapable(path: str, user: pwd.struct_passwd) -> None: + helper = Helper(path, user) + try: + helper.expect_line(HANDSHAKE, "incapable: handshake") + line = helper.read_line(LINE_TIMEOUT) + if line in ("0", "1"): + raise SmokeFailure( + f"incapable: helper reported state {line!r} without the capability;" + + f" user {user.pw_name!r} can already read input devices" + + " (e.g. `input` group membership), so the negative case is not meaningful" + ) + if line is None or not line.startswith("error no-access"): + raise SmokeFailure(f"incapable: expected 'error no-access ...', got {line!r}") + helper.expect_exit(1, "incapable: after error") + finally: + helper.close() + + +def check_capabilities_honored(directory: str) -> None: + # The kernel ignores file capabilities on `nosuid` mounts (capabilities(7)). + if os.statvfs(directory).f_flag & os.ST_NOSUID: + raise SmokeFailure( + f"{directory} is on a `nosuid` mount, which ignores file capabilities;" + + f" use a temporary directory without `nosuid` (see {TEMP_PARENT})" + ) + + +def install_copies(helper: str, directory: str) -> tuple[str, str]: + os.chmod(directory, 0o755) + check_capabilities_honored(directory) + capable = os.path.join(directory, "capable") + incapable = os.path.join(directory, "incapable") + for target in (capable, incapable): + _ = shutil.copyfile(helper, target) + os.chmod(target, 0o755) + _ = subprocess.run(["setcap", "cap_dac_read_search=ep", capable], check=True) + return capable, incapable + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + _ = parser.add_argument("--helper", required=True, help="path to the built helper") + _ = parser.add_argument("--user", required=True, help="unprivileged user to run it as") + args = parser.parse_args() + helper_path = cast(str, args.helper) + user_name = cast(str, args.user) + + if os.geteuid() != 0: + print("FAIL: must run as root", file=sys.stderr) + return 1 + user = pwd.getpwnam(user_name) + + keyboard = VirtualKeyboard() + try: + directory = tempfile.mkdtemp(prefix="tabstack-keystate-", dir=TEMP_PARENT) + try: + capable, incapable = install_copies(helper_path, directory) + run_capable(capable, user, keyboard) + run_incapable(incapable, user) + finally: + shutil.rmtree(directory) + except SmokeFailure as failure: + print(f"FAIL: {failure}", file=sys.stderr) + return 1 + finally: + keyboard.destroy() + print("OK: tabstack-keystate smoke test passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 5429ee3eb876004ef04066da3500fc6afcb270cc Mon Sep 17 00:00:00 2001 From: FichteFoll Date: Wed, 14 Oct 2026 20:04:27 +0200 Subject: [PATCH 02/21] Use the `tabstack-keystate` helper for Ctrl-release detection on Linux Add `HelperCtrlReleasePoller`, which spawns the helper once per session and reads protocol version 1 from its stdout. A wrong handshake, an `error` line, EOF or a `0` line fire the release exactly once, and never after `stop()`. Ctrl counts as held until the helper reports its first state, so a slow helper start does not drop the session. `probe_helper()` maps the helper's output to a `HelperStatus`. `plugin_loaded` runs it on the async thread via `refresh_availability()`, because it can take up to 2 s. Until the result arrives, Linux behaves as if the helper were missing. The probe records the helper path it ran with, sessions run exactly that path, and a change of `keystate_helper_path` triggers a new probe. On Linux, `create_poller` prefers the helper whenever its probe is `OK` and falls back to X11 otherwise. X11 is no longer considered available on native Wayland, where XWayland reports Ctrl as released while Sublime has focus. When Ctrl turns out to be released by the time the panel would open, the pending session is now committed like a quick press instead of being dropped. The helper path is configurable via `keystate_helper_path` in the new `TabStack.sublime-settings`. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 +- TabStack.sublime-settings | 5 + plugin/__init__.py | 5 + plugin/ctrl_release/__init__.py | 56 ++++- plugin/ctrl_release/keystate_helper.py | 272 +++++++++++++++++++++++++ plugin/ctrl_release/linux.py | 30 ++- plugin/session.py | 7 +- plugin/state.py | 2 + 8 files changed, 371 insertions(+), 10 deletions(-) create mode 100644 TabStack.sublime-settings create mode 100644 plugin/ctrl_release/keystate_helper.py diff --git a/AGENTS.md b/AGENTS.md index 3c73538..62e0b3e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,9 @@ - `TabStackListener.on_close` prunes closed sheets and removes empty window state. - Tab captions come from `plugin/captions.py`; widgets, panels, and transient/non-tab views are excluded from MRU. - Ctrl-release detection lives in `plugin/ctrl_release/`. -- Uner Linux, Ctrl-release polling uses the X11 key state API, so Sublime must run under X11 or XWayland; a native Wayland session has no pollable global key state and Sublime exposes no key-release API. +- Under Linux, Ctrl-release detection uses the `tabstack-keystate` helper (`plugin/ctrl_release/keystate_helper.py`) when its probe succeeds; the probe result is cached per plugin load. + The helper reads evdev key state with `cap_dac_read_search`, is spawned once per session, and reports Ctrl state changes on stdout. +- Without a working helper, Linux falls back to the X11 key state API, but not on native Wayland (`is_native_wayland()` in `plugin/ctrl_release/linux.py`), because XWayland reports Ctrl as released while a Wayland client has focus; Sublime exposes no key-release API. - `tab_stack_open` arms a pending session, starts a Ctrl-release poller, and opens the quick panel after a short delay unless Ctrl is released first. - Wrap-around cycling is implemented by intentionally closing and reopening the quick panel at the opposite boundary. That handoff keeps the session active and reuses the existing Ctrl-release poller. diff --git a/TabStack.sublime-settings b/TabStack.sublime-settings new file mode 100644 index 0000000..6fd8687 --- /dev/null +++ b/TabStack.sublime-settings @@ -0,0 +1,5 @@ +{ + // Path to the `tabstack-keystate` helper that detects Ctrl releases on Linux. + // It is used whenever it works, and it is required under native Wayland. + "keystate_helper_path": "/usr/local/libexec/tabstack-keystate", +} diff --git a/plugin/__init__.py b/plugin/__init__.py index f4adc99..462e15a 100644 --- a/plugin/__init__.py +++ b/plugin/__init__.py @@ -1,9 +1,14 @@ from .commands import * # noqa: F401,F403 +from .ctrl_release import plugin_loaded as _ctrl_release_plugin_loaded from .ctrl_release import plugin_unloaded as _ctrl_release_plugin_unloaded from .listener import * # noqa: F401,F403 from .state import iter_states as _iter_states +def plugin_loaded() -> None: + _ctrl_release_plugin_loaded() + + def plugin_unloaded() -> None: for state in _iter_states(): if state.selection_poller is not None: diff --git a/plugin/ctrl_release/__init__.py b/plugin/ctrl_release/__init__.py index 5dd6020..74cd6aa 100644 --- a/plugin/ctrl_release/__init__.py +++ b/plugin/ctrl_release/__init__.py @@ -1,33 +1,81 @@ from __future__ import annotations import sys +from collections.abc import Callable from ._availability import is_available as _is_available from ._availability import reset as _reset_availability if sys.platform == "darwin": - from .macos import CtrlReleasePoller, probe + from .macos import CtrlReleasePoller as create_poller from .macos import plugin_unloaded as _platform_plugin_unloaded + from .macos import probe + + def _start_platform_probe(on_done: Callable[[], None]) -> None: + on_done() + + def _watch_platform_settings(on_change: Callable[[], None]) -> None: + _ = on_change + elif sys.platform == "win32": - from .windows import CtrlReleasePoller, probe + from .windows import CtrlReleasePoller as create_poller from .windows import plugin_unloaded as _platform_plugin_unloaded + from .windows import probe + + def _start_platform_probe(on_done: Callable[[], None]) -> None: + on_done() + + def _watch_platform_settings(on_change: Callable[[], None]) -> None: + _ = on_change + else: - from .linux import CtrlReleasePoller, probe + from .keystate_helper import HelperProbe, start_helper_probe + from .keystate_helper import watch_helper_path as _watch_platform_settings + from .linux import create_poller, probe from .linux import plugin_unloaded as _platform_plugin_unloaded + def _start_platform_probe(on_done: Callable[[], None]) -> None: + def on_helper_probed(_: HelperProbe) -> None: + on_done() + + start_helper_probe(on_helper_probed) + def is_available() -> bool: return _is_available(probe) +def refresh_availability(on_done: Callable[[], None] = lambda: None) -> None: + """Probe the platform in the background and call `on_done` on the main thread. + + Until then, `is_available` answers without the platform probe's result. + """ + + def on_probed() -> None: + # Drop the answer given while the probe was running. + _reset_availability() + on_done() + + _reset_availability() + _start_platform_probe(on_probed) + + +def plugin_loaded() -> None: + refresh_availability() + # The probe result only holds for the settings it was made with. + _watch_platform_settings(refresh_availability) + + def plugin_unloaded() -> None: _platform_plugin_unloaded() _reset_availability() __all__ = [ - "CtrlReleasePoller", + "create_poller", "is_available", "probe", + "plugin_loaded", "plugin_unloaded", + "refresh_availability", ] diff --git a/plugin/ctrl_release/keystate_helper.py b/plugin/ctrl_release/keystate_helper.py new file mode 100644 index 0000000..ee72604 --- /dev/null +++ b/plugin/ctrl_release/keystate_helper.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +import os +import subprocess +import threading +from collections.abc import Callable +from dataclasses import dataclass +from enum import Enum +from typing import IO, Optional, final + +import sublime + +HANDSHAKE = "tabstack-keystate 1" +DEFAULT_HELPER_PATH = "/usr/local/libexec/tabstack-keystate" + +_HANDSHAKE_PREFIX = "tabstack-keystate " +_SETTINGS_FILE = "TabStack.sublime-settings" +_SETTINGS_WATCH_KEY = "tab_stack_keystate_helper_path" +_PROBE_TIMEOUT_S = 2 +_EXIT_TIMEOUT_S = 1 + + +class HelperStatus(Enum): + OK = "ok" + MISSING = "missing" + OUTDATED = "outdated" + NO_ACCESS = "no-access" + FAILED = "failed" + + +@dataclass(frozen=True) +class HelperProbe: + # The probed path; the poller runs exactly this one, so both stay consistent + # until the next probe even if `keystate_helper_path` changes in between. + path: str + status: HelperStatus + detail: str + + +_probe: Optional[HelperProbe] = None +_probe_generation = 0 +# The path of the probe that is running or finished, as `_probe` may still be `None`. +_probed_path: Optional[str] = None + + +def helper_path() -> str: + path = sublime.load_settings(_SETTINGS_FILE).get("keystate_helper_path", DEFAULT_HELPER_PATH) + if not isinstance(path, str) or not path: + path = DEFAULT_HELPER_PATH + return os.path.expanduser(path) + + +def probe_helper(path: str) -> HelperProbe: + try: + # Empty stdin makes the helper exit right after reporting the initial state. + result = subprocess.run([path], input=b"", capture_output=True, timeout=_PROBE_TIMEOUT_S) + except FileNotFoundError: + return HelperProbe(path, HelperStatus.MISSING, f"{path} does not exist.") + except subprocess.TimeoutExpired: + return HelperProbe( + path, HelperStatus.FAILED, f"{path} did not exit within {_PROBE_TIMEOUT_S} seconds." + ) + except OSError as exc: + return HelperProbe(path, HelperStatus.FAILED, f"{path} could not be run: {exc}") + + lines = result.stdout.decode("ascii", errors="replace").splitlines() + return _parse_probe_output(path, lines) + + +def _parse_probe_output(path: str, lines: list[str]) -> HelperProbe: + handshake = lines[0] if lines else "" + if handshake != HANDSHAKE: + if handshake.startswith(_HANDSHAKE_PREFIX): + return HelperProbe( + path, + HelperStatus.OUTDATED, + f"{path} speaks protocol {handshake!r}, but {HANDSHAKE!r} is required.", + ) + return HelperProbe( + path, HelperStatus.FAILED, f"{path} did not identify itself as `tabstack-keystate`." + ) + + report = lines[1] if len(lines) > 1 else "" + if report in ("0", "1"): + return HelperProbe(path, HelperStatus.OK, f"{path} reports the Ctrl key state.") + if report.split(" ")[:2] == ["error", "no-access"]: + return HelperProbe( + path, + HelperStatus.NO_ACCESS, + f"{path} cannot open the keyboard devices: {report}", + ) + if report.startswith("error "): + return HelperProbe(path, HelperStatus.FAILED, f"{path} reported: {report}") + return HelperProbe(path, HelperStatus.FAILED, f"{path} reported no Ctrl key state.") + + +def get_helper_probe() -> Optional[HelperProbe]: + """Return the result of the last probe, or `None` while it is still running.""" + return _probe + + +def start_helper_probe(on_done: Callable[[HelperProbe], None]) -> None: + """Probe the helper on the async thread and call `on_done` on the main thread. + + Running the helper can take up to `_PROBE_TIMEOUT_S`, which must not block the UI. + """ + global _probe, _probe_generation, _probed_path + _probe = None + _probe_generation += 1 + generation = _probe_generation + # Resolved here because the Sublime API is only safe on the main thread. + path = helper_path() + _probed_path = path + + def probe() -> None: + result = probe_helper(path) + sublime.set_timeout(lambda: _finish_probe(generation, result, on_done)) + + sublime.set_timeout_async(probe) + + +def _finish_probe( + generation: int, result: HelperProbe, on_done: Callable[[HelperProbe], None] +) -> None: + global _probe + # A newer probe or a reset supersedes this one. + if generation != _probe_generation: + return + _probe = result + on_done(result) + + +def reset_helper_probe() -> None: + global _probe, _probe_generation, _probed_path + _probe = None + _probe_generation += 1 + _probed_path = None + + +def watch_helper_path(on_change: Callable[[], None]) -> None: + """Call `on_change` whenever `keystate_helper_path` no longer matches the probed path.""" + + def on_settings_changed() -> None: + if helper_path() != _probed_path: + on_change() + + sublime.load_settings(_SETTINGS_FILE).add_on_change(_SETTINGS_WATCH_KEY, on_settings_changed) + + +@final +class HelperCtrlReleasePoller(threading.Thread): + def __init__(self, on_release: Callable[[], None], interval_ms: int, path: str) -> None: + super().__init__(daemon=True) + # The helper reports state changes as they happen, so there is nothing to poll. + _ = interval_ms + self._on_release = on_release + self._path = path + self._stop_event = threading.Event() + self._lock = threading.Lock() + self._process: Optional[subprocess.Popen[str]] = None + # Ctrl counts as held until the helper reports otherwise, + # so that a slow helper start keeps the session instead of dropping it. + self._ctrl_down = True + + def start(self) -> None: + if self.is_alive(): + return + super().start() + + def stop(self) -> None: + with self._lock: + self._stop_event.set() + self._close_stdin() + + def run(self) -> None: + process = self._spawn() + if process is None: + self._fail() + return + try: + if process.stdout is None: + self._fail() + return + self._read_states(process.stdout) + finally: + self._terminate(process) + + def is_ctrl_down(self) -> bool: + return self._ctrl_down + + def _spawn(self) -> Optional[subprocess.Popen[str]]: + with self._lock: + if self._stop_event.is_set(): + return None + try: + self._process = subprocess.Popen( + [self._path], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + encoding="ascii", + errors="replace", + bufsize=1, + ) + except OSError as exc: + print(f"TabStack: failed to start {self._path}: {exc}") + return None + return self._process + + def _read_states(self, stdout: IO[str]) -> None: + handshake = stdout.readline().rstrip("\n") + if handshake != HANDSHAKE: + print(f"TabStack: unexpected handshake from {self._path}: {handshake!r}") + self._fail() + return + + for raw_line in iter(stdout.readline, ""): + line = raw_line.rstrip("\n") + if line == "1": + self._ctrl_down = True + elif line == "0": + self._ctrl_down = False + self._fire_release() + return + elif line.startswith("error "): + print(f"TabStack: {self._path} reported: {line}") + self._fail() + return + else: + print(f"TabStack: unexpected line from {self._path}: {line!r}") + + self._fail() + + def _fail(self) -> None: + self._ctrl_down = False + self._fire_release() + + def _fire_release(self) -> None: + # The lock makes the check-and-set atomic with respect to `stop`, + # so that no release is scheduled once the session stopped the poller. + with self._lock: + if self._stop_event.is_set(): + return + self._stop_event.set() + sublime.set_timeout(self._on_release) + + def _close_stdin(self) -> None: + with self._lock: + process = self._process + if process is None or process.stdin is None: + return + try: + process.stdin.close() + except OSError: + pass + + def _terminate(self, process: subprocess.Popen[str]) -> None: + # The helper exits on stdin EOF; killing it is only the fallback. + self._close_stdin() + try: + _ = process.wait(timeout=_EXIT_TIMEOUT_S) + except subprocess.TimeoutExpired: + process.kill() + _ = process.wait() + if process.stdout is not None: + process.stdout.close() + + +def plugin_unloaded() -> None: + sublime.load_settings(_SETTINGS_FILE).clear_on_change(_SETTINGS_WATCH_KEY) + reset_helper_probe() diff --git a/plugin/ctrl_release/linux.py b/plugin/ctrl_release/linux.py index 2283c20..49afa3b 100644 --- a/plugin/ctrl_release/linux.py +++ b/plugin/ctrl_release/linux.py @@ -10,7 +10,10 @@ import sublime +from ..state import CtrlPoller from ._availability import set_unavailable +from .keystate_helper import HelperCtrlReleasePoller, HelperStatus, get_helper_probe +from .keystate_helper import plugin_unloaded as _helper_plugin_unloaded _XK_Control_L = 0xFFE3 _XK_Control_R = 0xFFE4 @@ -27,7 +30,7 @@ class _X11State: @final -class CtrlReleasePoller(threading.Thread): +class X11CtrlReleasePoller(threading.Thread): def __init__(self, on_release: Callable[[], None], interval_ms: int) -> None: super().__init__(daemon=True) self._on_release = on_release @@ -111,8 +114,30 @@ def _open_x11_state(self) -> Optional[_X11State]: ) +def is_native_wayland() -> bool: + return bool(os.environ.get("WAYLAND_DISPLAY")) and os.environ.get("GDK_BACKEND") != "x11" + + +def create_poller(on_release: Callable[[], None], interval_ms: int) -> CtrlPoller: + path = _working_helper_path() + if path is not None: + return HelperCtrlReleasePoller(on_release, interval_ms, path) + return X11CtrlReleasePoller(on_release, interval_ms) + + def probe() -> bool: - return _get_x11() is not None + if _working_helper_path() is not None: + return True + # Under native Wayland, XWayland reports Ctrl as released while Sublime has focus, + # which would commit every session immediately. + return not is_native_wayland() and _get_x11() is not None + + +def _working_helper_path() -> Optional[str]: + helper_probe = get_helper_probe() + if helper_probe is None or helper_probe.status is not HelperStatus.OK: + return None + return helper_probe.path def _keycode_is_down(keymap: ctypes.Array[ctypes.c_ubyte], keycode: int) -> bool: @@ -154,3 +179,4 @@ def _get_x11() -> Optional[ctypes.CDLL]: def plugin_unloaded() -> None: global _x11 _x11 = None + _helper_plugin_unloaded() diff --git a/plugin/session.py b/plugin/session.py index aca481a..e816b03 100644 --- a/plugin/session.py +++ b/plugin/session.py @@ -3,7 +3,7 @@ import sublime from .constants import KIND_MODIFIED -from .ctrl_release import CtrlReleasePoller +from .ctrl_release import create_poller from .sheets import apply_group_selection from .state import TabStackWindowState @@ -20,7 +20,7 @@ def on_release() -> None: elif state.session_pending: _commit_pending_session(window, state) - poller = CtrlReleasePoller(on_release, 25) + poller = create_poller(on_release, 25) state.ctrl_release_poller = poller poller.start() @@ -70,7 +70,8 @@ def open_if_still_pending() -> None: if not state.session_pending or state.session_pending_token != token: return if state.ctrl_release_poller is not None and not state.ctrl_release_poller.is_ctrl_down(): - state.clear_session() + # Ctrl was released before the panel appeared; switch like a quick press would. + _commit_pending_session(window, state) return state.session_active = True state.session_pending = False diff --git a/plugin/state.py b/plugin/state.py index 772c932..1c506ed 100644 --- a/plugin/state.py +++ b/plugin/state.py @@ -15,6 +15,8 @@ def stop(self) -> None: ... class CtrlPoller(Poller, Protocol): + def start(self) -> None: ... + def is_ctrl_down(self) -> bool: ... From 94cd796edc33ba03f87d07ec51300b711fa9ae0f Mon Sep 17 00:00:00 2001 From: FichteFoll Date: Wed, 14 Oct 2026 20:10:43 +0200 Subject: [PATCH 03/21] Guide Wayland users through setting up the `tabstack-keystate` helper Add the `tab_stack_setup_keystate_helper` command, available in the Command Palette as `TabStack: Set Up Ctrl Detection for Wayland`. It re-probes the helper in the background and reports success, or extracts the helper source to the cache directory, opens it for review, and opens a read-only view with the build, install, `setcap` and uninstall commands and the security implications. The plugin never runs any of these commands. On native Wayland without a working helper, the first `ctrl+tab` per plugin load offers the setup in a dialog instead of falling back to `next_view_in_stack`. "Don't Ask Again" persists `show_wayland_setup_dialog: false`. While the background probe is still running, the dialog is not offered. `plugin.ctrl_release` now exports a platform-neutral `is_native_wayland` that delegates to the Linux implementation. Co-Authored-By: Claude Opus 5.5 --- Default.sublime-commands | 6 + TabStack.sublime-settings | 4 + plugin/__init__.py | 1 + plugin/commands.py | 4 + plugin/ctrl_release/__init__.py | 9 +- plugin/setup.py | 207 ++++++++++++++++++++++++++++++++ 6 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 Default.sublime-commands create mode 100644 plugin/setup.py diff --git a/Default.sublime-commands b/Default.sublime-commands new file mode 100644 index 0000000..982d89f --- /dev/null +++ b/Default.sublime-commands @@ -0,0 +1,6 @@ +[ + { + "caption": "TabStack: Set Up Ctrl Detection for Wayland", + "command": "tab_stack_setup_keystate_helper" + }, +] diff --git a/TabStack.sublime-settings b/TabStack.sublime-settings index 6fd8687..e42f314 100644 --- a/TabStack.sublime-settings +++ b/TabStack.sublime-settings @@ -2,4 +2,8 @@ // Path to the `tabstack-keystate` helper that detects Ctrl releases on Linux. // It is used whenever it works, and it is required under native Wayland. "keystate_helper_path": "/usr/local/libexec/tabstack-keystate", + + // Offer the helper's setup instructions on the first Ctrl+Tab per plugin load + // when Ctrl-release detection is unavailable under native Wayland. + "show_wayland_setup_dialog": true, } diff --git a/plugin/__init__.py b/plugin/__init__.py index 462e15a..ec0de5a 100644 --- a/plugin/__init__.py +++ b/plugin/__init__.py @@ -2,6 +2,7 @@ from .ctrl_release import plugin_loaded as _ctrl_release_plugin_loaded from .ctrl_release import plugin_unloaded as _ctrl_release_plugin_unloaded from .listener import * # noqa: F401,F403 +from .setup import * # noqa: F401,F403 from .state import iter_states as _iter_states diff --git a/plugin/commands.py b/plugin/commands.py index e85a147..db6c4c2 100644 --- a/plugin/commands.py +++ b/plugin/commands.py @@ -6,6 +6,7 @@ from .history import current_group_selection_state, sync_selection_history from .mru import collect_entries from .session import cancel_session, reopen_panel_at_index, schedule_panel, show_panel +from .setup import offer_wayland_setup from .state import get_state @@ -13,6 +14,9 @@ class TabStackOpenCommand(sublime_plugin.WindowCommand): def run(self, *, forward: bool = True, selected_index: int = 1) -> None: window = self.window if not is_available(): + # The dialog replaces the fallback for this key press, whatever the answer. + if offer_wayland_setup(window): + return window.run_command("next_view_in_stack" if forward else "prev_view_in_stack") return elif not forward: diff --git a/plugin/ctrl_release/__init__.py b/plugin/ctrl_release/__init__.py index 74cd6aa..85ab4d8 100644 --- a/plugin/ctrl_release/__init__.py +++ b/plugin/ctrl_release/__init__.py @@ -17,6 +17,9 @@ def _start_platform_probe(on_done: Callable[[], None]) -> None: def _watch_platform_settings(on_change: Callable[[], None]) -> None: _ = on_change + def is_native_wayland() -> bool: + return False + elif sys.platform == "win32": from .windows import CtrlReleasePoller as create_poller from .windows import plugin_unloaded as _platform_plugin_unloaded @@ -28,10 +31,13 @@ def _start_platform_probe(on_done: Callable[[], None]) -> None: def _watch_platform_settings(on_change: Callable[[], None]) -> None: _ = on_change + def is_native_wayland() -> bool: + return False + else: from .keystate_helper import HelperProbe, start_helper_probe from .keystate_helper import watch_helper_path as _watch_platform_settings - from .linux import create_poller, probe + from .linux import create_poller, is_native_wayland, probe from .linux import plugin_unloaded as _platform_plugin_unloaded def _start_platform_probe(on_done: Callable[[], None]) -> None: @@ -74,6 +80,7 @@ def plugin_unloaded() -> None: __all__ = [ "create_poller", "is_available", + "is_native_wayland", "probe", "plugin_loaded", "plugin_unloaded", diff --git a/plugin/setup.py b/plugin/setup.py new file mode 100644 index 0000000..34858f8 --- /dev/null +++ b/plugin/setup.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import os +import shlex + +import sublime +import sublime_plugin + +from .ctrl_release import is_native_wayland, refresh_availability +from .ctrl_release.keystate_helper import ( + DEFAULT_HELPER_PATH, + HelperProbe, + HelperStatus, + get_helper_probe, +) + +__all__ = ["TabStackSetupKeystateHelperCommand"] + +assert __package__ is not None +# This module lives in the `plugin` subpackage; resources are addressed by the top-level package. +_PACKAGE_NAME = __package__.split(".")[0] +_SETTINGS_FILE = "TabStack.sublime-settings" +_SOURCE_NAME = "tabstack-keystate.c" +_SETUP_COMMAND = "tab_stack_setup_keystate_helper" +_SETUP_CAPTION = "TabStack: Set Up Ctrl Detection for Wayland" + +_wayland_dialog_shown = False + + +class TabStackSetupKeystateHelperCommand(sublime_plugin.WindowCommand): + def is_visible(self) -> bool: + return sublime.platform() == "linux" + + def run(self) -> None: + # Re-probing makes this command double as the "check again" step. + sublime.status_message("TabStack: checking the tabstack-keystate helper...") + refresh_availability(self._on_probed) + + def _on_probed(self) -> None: + probe = get_helper_probe() + if probe is None: + # The helper is only probed on Linux. + return + path = probe.path + if probe.status is HelperStatus.OK: + sublime.message_dialog( + f"Ctrl detection via the tabstack-keystate helper at {path} works." + ) + return + + extraction_dir = os.path.join(sublime.cache_path(), "TabStack", "keystate") + try: + source_path = _extract_source(extraction_dir) + except OSError as exc: + sublime.error_message(f"TabStack: could not extract the helper source: {exc}") + return + + window = self.window + _ = window.open_file(source_path) + view = window.new_file(syntax="Packages/Markdown/Markdown.sublime-syntax") + view.set_name("TabStack Setup") + view.set_scratch(True) + view.run_command( + "append", {"characters": _instructions(probe, extraction_dir, source_path)} + ) + view.set_read_only(True) + + +def offer_wayland_setup(window: sublime.Window) -> bool: + """Show the setup dialog once per plugin load on native Wayland. + + Returns whether the dialog was shown. + """ + global _wayland_dialog_shown + if _wayland_dialog_shown or not is_native_wayland(): + return False + if get_helper_probe() is None: + # The background probe has not finished; the helper may still turn out to work. + return False + settings = sublime.load_settings(_SETTINGS_FILE) + if not settings.get("show_wayland_setup_dialog", True): + return False + + _wayland_dialog_shown = True + result = sublime.yes_no_cancel_dialog( + _DIALOG_MESSAGE, + yes_title="Show Setup", + no_title="Don't Ask Again", + ) + if result == sublime.DIALOG_YES: + window.run_command(_SETUP_COMMAND) + elif result == sublime.DIALOG_NO: + settings.set("show_wayland_setup_dialog", False) + sublime.save_settings(_SETTINGS_FILE) + return True + + +def _extract_source(extraction_dir: str) -> str: + source = sublime.load_binary_resource(f"Packages/{_PACKAGE_NAME}/keystate/{_SOURCE_NAME}") + os.makedirs(extraction_dir, exist_ok=True) + source_path = os.path.join(extraction_dir, _SOURCE_NAME) + with open(source_path, "wb") as file: + _ = file.write(source) + return source_path + + +def _instructions(probe: HelperProbe, extraction_dir: str, source_path: str) -> str: + quoted_path = shlex.quote(probe.path) + custom_path_note = _CUSTOM_PATH_NOTE if probe.path != DEFAULT_HELPER_PATH else "" + return _INSTRUCTIONS.format( + caption=_SETUP_CAPTION, + status=probe.status.name, + detail=probe.detail, + status_note=_STATUS_NOTES.get(probe.status, ""), + source_path=source_path, + extraction_dir=shlex.quote(extraction_dir), + helper_path=quoted_path, + custom_path_note=custom_path_note, + ) + + +_DIALOG_MESSAGE = """\ +TabStack cannot detect when Ctrl is released under native Wayland \ +without the tabstack-keystate helper, \ +so Ctrl+Tab falls back to Sublime Text's built-in tab cycling. + +Show the instructions for setting up the helper?""" + +_STATUS_NOTES = { + HelperStatus.OUTDATED: """ +The installed helper speaks a different protocol version than this version of TabStack expects, +usually because the package was updated. +Rebuild it from the extracted source and run the install and grant commands again. +""", + HelperStatus.NO_ACCESS: """ +The helper runs but cannot open the keyboard devices. +Usually its capability is missing, e.g. because the binary was replaced. +Run the install and grant commands below again. +""", +} + +_CUSTOM_PATH_NOTE = """ +The helper path comes from the `keystate_helper_path` setting. +Its file system must not be mounted with `nosuid`, +or the kernel ignores the file capability. +""" + +_INSTRUCTIONS = """\ +# {caption} + +Status: {status} +{detail} +{status_note} +## Why a helper is needed + +Sublime Text does not tell plugins when a key is released, +so TabStack polls the global keyboard state to notice when Ctrl is released. +A native Wayland session has no such global key state. +The `tabstack-keystate` helper reads the Ctrl state +from the kernel's input devices (`/dev/input/event*`) instead +and reports changes to TabStack on its standard output. + +## Security implications + +Review the helper's source before building it. +It was extracted to `{source_path}` and opened in another tab. + +- The input devices are only readable by root (and usually the `input` group). + The helper is therefore granted the `cap_dac_read_search` file capability, + which allows reading *any* file on the system, not only input devices. +- At startup, the helper sets `no_new_privs`, + opens every `/dev/input/event*` device read-only, + keeps only the devices that advertise a Ctrl key, + and then drops all capabilities before it reports any state. +- Any local user can run the helper + and read through it whether Ctrl is held on any keyboard of this machine. + It reports nothing but the Ctrl state. +- The capability is attached to the installed file. + Replacing the file, e.g. after rebuilding, drops the capability, + so run the grant command again after every install. + +## Setup + +Building needs a C compiler and the kernel headers; +`setcap` usually comes with the libcap tools package. +Build the helper, install it, and grant it the capability: + +```sh +cd {extraction_dir} +cc -std=c11 -O2 -Wall -Wextra -o tabstack-keystate tabstack-keystate.c +sudo install -D -o root -g root -m 0755 tabstack-keystate {helper_path} +sudo setcap cap_dac_read_search=ep {helper_path} +``` + +Then run `{caption}` from the Command Palette again +to verify that the helper works. +{custom_path_note} +Flatpak and Snap builds of Sublime Text are not supported: +their sandbox makes the kernel ignore file capabilities +when Sublime Text runs the helper. + +## Uninstall + +```sh +sudo rm {helper_path} +``` +""" From df471eed6be5b0f5b2399eacb66a1bf48e832d95 Mon Sep 17 00:00:00 2001 From: FichteFoll Date: Wed, 14 Oct 2026 20:01:38 +0200 Subject: [PATCH 04/21] Document the Wayland setup with the `tabstack-keystate` helper Explain that Linux uses the `tabstack-keystate` helper when it works and falls back to X11 otherwise, how to set the helper up with the `TabStack: Set Up Ctrl Detection for Wayland` command, and the build, install, grant and uninstall commands. Also document the `keystate_helper_path` and `show_wayland_setup_dialog` settings, the security implications of `cap_dac_read_search` and of `input` group membership, and that Flatpak and Snap builds are unsupported. `AGENTS.md` gains the setup command, the dialog and how the helper is built and tested. Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 11 +++++-- README.md | 97 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 103 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 62e0b3e..9906748 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,9 +15,14 @@ - `TabStackListener.on_close` prunes closed sheets and removes empty window state. - Tab captions come from `plugin/captions.py`; widgets, panels, and transient/non-tab views are excluded from MRU. - Ctrl-release detection lives in `plugin/ctrl_release/`. -- Under Linux, Ctrl-release detection uses the `tabstack-keystate` helper (`plugin/ctrl_release/keystate_helper.py`) when its probe succeeds; the probe result is cached per plugin load. +- Under Linux, Ctrl-release detection uses the `tabstack-keystate` helper (`plugin/ctrl_release/keystate_helper.py`) when its probe succeeds. + `plugin_loaded` probes it on the async thread, because running it can take up to 2 s; until the result arrives, Linux behaves as if the helper were missing, but without the setup dialog. + The probe records the helper path it ran with, the poller runs exactly that path, and a change of `keystate_helper_path` triggers a new probe. The helper reads evdev key state with `cap_dac_read_search`, is spawned once per session, and reports Ctrl state changes on stdout. - Without a working helper, Linux falls back to the X11 key state API, but not on native Wayland (`is_native_wayland()` in `plugin/ctrl_release/linux.py`), because XWayland reports Ctrl as released while a Wayland client has focus; Sublime exposes no key-release API. +- On native Wayland without a working helper, the first `ctrl+tab` per plugin load shows a dialog offering the setup (`offer_wayland_setup` in `plugin/setup.py`), unless `show_wayland_setup_dialog` is `false`. +- `tab_stack_setup_keystate_helper` (Command Palette: `TabStack: Set Up Ctrl Detection for Wayland`) re-probes, extracts `keystate/tabstack-keystate.c` to `sublime.cache_path()/TabStack/keystate/`, and shows the build, install and `setcap` commands. + The plugin never runs `sudo`, a compiler or `setcap`; the user installs the helper to `keystate_helper_path` (default `/usr/local/libexec/tabstack-keystate`) with `cap_dac_read_search`, which the helper drops right after opening the input devices. - `tab_stack_open` arms a pending session, starts a Ctrl-release poller, and opens the quick panel after a short delay unless Ctrl is released first. - Wrap-around cycling is implemented by intentionally closing and reopening the quick panel at the opposite boundary. That handoff keeps the session active and reuses the existing Ctrl-release poller. @@ -33,4 +38,6 @@ - Use `uv run ruff check .` for linting. - Use `uv run ruff format .` for formatting. - Use `uv run basedpyright` for type checks. -- `pyproject.toml` only defines Ruff settings; there is no repo-local test runner. +- `pyproject.toml` only defines Ruff settings; there is no test runner for the plugin. +- The helper builds with `cc -std=c11 -O2 -Wall -Wextra -Werror -o /tabstack-keystate keystate/tabstack-keystate.c`. +- `tests/keystate_smoke.py` tests the helper against a virtual uinput keyboard; it needs root and `/dev/uinput` and runs in CI (`tests/` is export-ignored). diff --git a/README.md b/README.md index d25e3af..442ce0b 100644 --- a/README.md +++ b/README.md @@ -42,17 +42,108 @@ Press `ctrl+escape` to cancel. ## Platform Support TabStack supports **Linux, Windows, and macOS**. -It polls the native key state APIs to detect modifier release events. +It reads the native key state to detect modifier release events. -On Linux, detection uses the X11 key state API, +On Linux, TabStack uses the `tabstack-keystate` helper +whenever it is installed and works, +see [Wayland Setup](#wayland-setup). +Otherwise it falls back to the X11 key state API, so Sublime must run under X11 or XWayland (for example with `GDK_BACKEND=x11`). -Note that this has not been reported to work yet. +Note that X11 detection has not been reported to work yet. +TabStack treats a session as native Wayland +when `WAYLAND_DISPLAY` is set and `GDK_BACKEND` is not `x11`, +so `GDK_BACKEND=x11` is also the way +to use X11 detection without the helper on a Wayland desktop. A native Wayland session exposes no way to poll global key state (a deliberate security restriction in the protocol), and Sublime Text does not provide a key-release API to plugins. See also the related [upstream issue][]. +As a workaround, +TabStack ships the source of a small helper program, `tabstack-keystate`, +that reads the Ctrl key state from the kernel's input devices. +Native Wayland needs this helper, +see [Wayland Setup](#wayland-setup). + + +## Wayland Setup + +The helper is not installed automatically. +You review its source, +then build and install it yourself. +TabStack never runs `sudo`, a compiler or `setcap`. + +Run `TabStack: Set Up Ctrl Detection for Wayland` from the Command Palette. +It extracts the helper source, +opens it for review, +and shows the commands below. +When the first `ctrl+tab` on native Wayland finds no working helper, +TabStack also offers to show this setup once per plugin load. + +In the directory the command extracted the source to, +build, install and grant the capability: + +```sh +cc -std=c11 -O2 -Wall -Wextra -o tabstack-keystate tabstack-keystate.c +sudo install -D -o root -g root -m 0755 tabstack-keystate /usr/local/libexec/tabstack-keystate +sudo setcap cap_dac_read_search=ep /usr/local/libexec/tabstack-keystate +``` + +Then run the setup command again to verify that the helper works. + +To uninstall: + +```sh +sudo rm /usr/local/libexec/tabstack-keystate +``` + +After a package update, +the setup command may report an outdated helper. +In that case, build and install it again with the commands above. + +### Settings + +In `TabStack.sublime-settings`: + +- `keystate_helper_path`: + the path of the installed helper, + `/usr/local/libexec/tabstack-keystate` by default. +- `show_wayland_setup_dialog`: + set to `false` to suppress the dialog + that offers the setup on native Wayland. + The dialog's "Don't Ask Again" button sets it for you. + +### Security Notes + +- Reading `/dev/input/event*` requires privileges. + The helper gets them through the file capability `cap_dac_read_search`, + which allows reading any file regardless of permissions. +- The helper sets `no_new_privs` at startup, + opens every `/dev/input/event*` device read-only + and keeps only those that advertise a Ctrl key. + It then drops all capabilities immediately, + before it reports any state. + Review the source to confirm this. +- Anyone who can execute the helper can run it, + so any local user can read the Ctrl state of your keyboards through it. + It reports nothing but whether a Ctrl key is held. +- Installing it as `root:root` with mode `0755` in `/usr/local/libexec` + prevents the user's own processes from modifying it. + Replacing the binary drops the capability, + so grant it again after every rebuild. +- An alternative is to add your user to the `input` group + and skip the capability. + Note that this typically gives every process of your user + read-write access to all input event devices (keyboards and mice), + allowing it to read all keystrokes including passwords, + inject events and grab devices. + +### Limitations + +Flatpak and Snap builds of Sublime Text are not supported. +Their sandbox or `no_new_privs` setting +makes the kernel ignore file capabilities when running the helper. ## Caveats From 2639bbfea5acba9808d87a104ca6e50fc419f51f Mon Sep 17 00:00:00 2001 From: FichteFoll Date: Wed, 14 Oct 2026 21:46:19 +0200 Subject: [PATCH 05/21] Move the Linux Ctrl-release backends into a `linux` sub-package `plugin/ctrl_release/linux/` now holds one module per backend: `x11.py` for the X11 poller and `keystate_helper.py` for the helper. Its `__init__.py` keeps the backend selection (`create_poller`, `probe`) and `is_native_wayland`. No behavior changes. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 +- plugin/ctrl_release/__init__.py | 4 +- plugin/ctrl_release/linux/__init__.py | 42 +++++++++++++++++++ .../{ => linux}/keystate_helper.py | 0 .../ctrl_release/{linux.py => linux/x11.py} | 32 ++------------ plugin/setup.py | 2 +- 6 files changed, 50 insertions(+), 34 deletions(-) create mode 100644 plugin/ctrl_release/linux/__init__.py rename plugin/ctrl_release/{ => linux}/keystate_helper.py (100%) rename plugin/ctrl_release/{linux.py => linux/x11.py} (79%) diff --git a/AGENTS.md b/AGENTS.md index 9906748..a9e7b47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,11 +15,11 @@ - `TabStackListener.on_close` prunes closed sheets and removes empty window state. - Tab captions come from `plugin/captions.py`; widgets, panels, and transient/non-tab views are excluded from MRU. - Ctrl-release detection lives in `plugin/ctrl_release/`. -- Under Linux, Ctrl-release detection uses the `tabstack-keystate` helper (`plugin/ctrl_release/keystate_helper.py`) when its probe succeeds. +- Under Linux, Ctrl-release detection uses the `tabstack-keystate` helper (`plugin/ctrl_release/linux/keystate_helper.py`) when its probe succeeds. `plugin_loaded` probes it on the async thread, because running it can take up to 2 s; until the result arrives, Linux behaves as if the helper were missing, but without the setup dialog. The probe records the helper path it ran with, the poller runs exactly that path, and a change of `keystate_helper_path` triggers a new probe. The helper reads evdev key state with `cap_dac_read_search`, is spawned once per session, and reports Ctrl state changes on stdout. -- Without a working helper, Linux falls back to the X11 key state API, but not on native Wayland (`is_native_wayland()` in `plugin/ctrl_release/linux.py`), because XWayland reports Ctrl as released while a Wayland client has focus; Sublime exposes no key-release API. +- Without a working helper, Linux falls back to the X11 key state API (`plugin/ctrl_release/linux/x11.py`), but not on native Wayland (`is_native_wayland()` in `plugin/ctrl_release/linux/__init__.py`), because XWayland reports Ctrl as released while a Wayland client has focus; Sublime exposes no key-release API. - On native Wayland without a working helper, the first `ctrl+tab` per plugin load shows a dialog offering the setup (`offer_wayland_setup` in `plugin/setup.py`), unless `show_wayland_setup_dialog` is `false`. - `tab_stack_setup_keystate_helper` (Command Palette: `TabStack: Set Up Ctrl Detection for Wayland`) re-probes, extracts `keystate/tabstack-keystate.c` to `sublime.cache_path()/TabStack/keystate/`, and shows the build, install and `setcap` commands. The plugin never runs `sudo`, a compiler or `setcap`; the user installs the helper to `keystate_helper_path` (default `/usr/local/libexec/tabstack-keystate`) with `cap_dac_read_search`, which the helper drops right after opening the input devices. diff --git a/plugin/ctrl_release/__init__.py b/plugin/ctrl_release/__init__.py index 85ab4d8..fa96eec 100644 --- a/plugin/ctrl_release/__init__.py +++ b/plugin/ctrl_release/__init__.py @@ -35,10 +35,10 @@ def is_native_wayland() -> bool: return False else: - from .keystate_helper import HelperProbe, start_helper_probe - from .keystate_helper import watch_helper_path as _watch_platform_settings from .linux import create_poller, is_native_wayland, probe from .linux import plugin_unloaded as _platform_plugin_unloaded + from .linux.keystate_helper import HelperProbe, start_helper_probe + from .linux.keystate_helper import watch_helper_path as _watch_platform_settings def _start_platform_probe(on_done: Callable[[], None]) -> None: def on_helper_probed(_: HelperProbe) -> None: diff --git a/plugin/ctrl_release/linux/__init__.py b/plugin/ctrl_release/linux/__init__.py new file mode 100644 index 0000000..42a1407 --- /dev/null +++ b/plugin/ctrl_release/linux/__init__.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import os +from collections.abc import Callable +from typing import Optional + +from ...state import CtrlPoller +from .keystate_helper import HelperCtrlReleasePoller, HelperStatus, get_helper_probe +from .keystate_helper import plugin_unloaded as _helper_plugin_unloaded +from .x11 import X11CtrlReleasePoller, is_x11_available +from .x11 import plugin_unloaded as _x11_plugin_unloaded + + +def is_native_wayland() -> bool: + return bool(os.environ.get("WAYLAND_DISPLAY")) and os.environ.get("GDK_BACKEND") != "x11" + + +def create_poller(on_release: Callable[[], None], interval_ms: int) -> CtrlPoller: + path = _working_helper_path() + if path is not None: + return HelperCtrlReleasePoller(on_release, interval_ms, path) + return X11CtrlReleasePoller(on_release, interval_ms) + + +def probe() -> bool: + if _working_helper_path() is not None: + return True + # Under native Wayland, XWayland reports Ctrl as released while Sublime has focus, + # which would commit every session immediately. + return not is_native_wayland() and is_x11_available() + + +def _working_helper_path() -> Optional[str]: + helper_probe = get_helper_probe() + if helper_probe is None or helper_probe.status is not HelperStatus.OK: + return None + return helper_probe.path + + +def plugin_unloaded() -> None: + _x11_plugin_unloaded() + _helper_plugin_unloaded() diff --git a/plugin/ctrl_release/keystate_helper.py b/plugin/ctrl_release/linux/keystate_helper.py similarity index 100% rename from plugin/ctrl_release/keystate_helper.py rename to plugin/ctrl_release/linux/keystate_helper.py diff --git a/plugin/ctrl_release/linux.py b/plugin/ctrl_release/linux/x11.py similarity index 79% rename from plugin/ctrl_release/linux.py rename to plugin/ctrl_release/linux/x11.py index 49afa3b..adc1195 100644 --- a/plugin/ctrl_release/linux.py +++ b/plugin/ctrl_release/linux/x11.py @@ -10,10 +10,7 @@ import sublime -from ..state import CtrlPoller -from ._availability import set_unavailable -from .keystate_helper import HelperCtrlReleasePoller, HelperStatus, get_helper_probe -from .keystate_helper import plugin_unloaded as _helper_plugin_unloaded +from .._availability import set_unavailable _XK_Control_L = 0xFFE3 _XK_Control_R = 0xFFE4 @@ -114,30 +111,8 @@ def _open_x11_state(self) -> Optional[_X11State]: ) -def is_native_wayland() -> bool: - return bool(os.environ.get("WAYLAND_DISPLAY")) and os.environ.get("GDK_BACKEND") != "x11" - - -def create_poller(on_release: Callable[[], None], interval_ms: int) -> CtrlPoller: - path = _working_helper_path() - if path is not None: - return HelperCtrlReleasePoller(on_release, interval_ms, path) - return X11CtrlReleasePoller(on_release, interval_ms) - - -def probe() -> bool: - if _working_helper_path() is not None: - return True - # Under native Wayland, XWayland reports Ctrl as released while Sublime has focus, - # which would commit every session immediately. - return not is_native_wayland() and _get_x11() is not None - - -def _working_helper_path() -> Optional[str]: - helper_probe = get_helper_probe() - if helper_probe is None or helper_probe.status is not HelperStatus.OK: - return None - return helper_probe.path +def is_x11_available() -> bool: + return _get_x11() is not None def _keycode_is_down(keymap: ctypes.Array[ctypes.c_ubyte], keycode: int) -> bool: @@ -179,4 +154,3 @@ def _get_x11() -> Optional[ctypes.CDLL]: def plugin_unloaded() -> None: global _x11 _x11 = None - _helper_plugin_unloaded() diff --git a/plugin/setup.py b/plugin/setup.py index 34858f8..a104cb2 100644 --- a/plugin/setup.py +++ b/plugin/setup.py @@ -7,7 +7,7 @@ import sublime_plugin from .ctrl_release import is_native_wayland, refresh_availability -from .ctrl_release.keystate_helper import ( +from .ctrl_release.linux.keystate_helper import ( DEFAULT_HELPER_PATH, HelperProbe, HelperStatus, From b3e4b6fccb8ab72e7af75bb23213608bc5c2807b Mon Sep 17 00:00:00 2001 From: FichteFoll Date: Wed, 14 Oct 2026 23:11:24 +0200 Subject: [PATCH 06/21] Add a JSON schema for the TabStack settings `sublime-package.json` contributes a schema for `TabStack.sublime-settings`, so LSP-json offers completions, hover documentation and validation when editing the settings. Co-Authored-By: Claude Opus 5.5 --- sublime-package.json | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 sublime-package.json diff --git a/sublime-package.json b/sublime-package.json new file mode 100644 index 0000000..857c5b6 --- /dev/null +++ b/sublime-package.json @@ -0,0 +1,26 @@ +{ + "contributions": { + "settings": [ + { + "file_patterns": ["/TabStack.sublime-settings"], + "schema": { + "$id": "sublime://settings/TabStack", + "type": "object", + "properties": { + "keystate_helper_path": { + "type": "string", + "default": "/usr/local/libexec/tabstack-keystate", + "markdownDescription": "Path to the `tabstack-keystate` helper that detects Ctrl releases on Linux.\n\nIt is used whenever it works, and it is required under native Wayland." + }, + "show_wayland_setup_dialog": { + "type": "boolean", + "default": true, + "markdownDescription": "Offer the helper's setup instructions on the first `ctrl+tab` per plugin load when Ctrl-release detection is unavailable under native Wayland.\n\nThe dialog's \"Don't Ask Again\" button sets this to `false`." + } + }, + "additionalProperties": false + } + } + ] + } +} From 94e1130fd005a3e1b29665662bed0410dff6e0df Mon Sep 17 00:00:00 2001 From: FichteFoll Date: Wed, 14 Oct 2026 23:33:00 +0200 Subject: [PATCH 07/21] Add a Command Palette entry to open the TabStack settings `Preferences: TabStack Settings` opens the default and user settings side by side via `edit_settings`. Co-Authored-By: Claude Opus 5.5 --- Default.sublime-commands | 8 ++++++++ README.md | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/Default.sublime-commands b/Default.sublime-commands index 982d89f..2f5a365 100644 --- a/Default.sublime-commands +++ b/Default.sublime-commands @@ -1,4 +1,12 @@ [ + { + "caption": "Preferences: TabStack Settings", + "command": "edit_settings", + "args": { + "base_file": "${packages}/TabStack/TabStack.sublime-settings", + "default": "{\n\t$0\n}\n", + }, + }, { "caption": "TabStack: Set Up Ctrl Detection for Wayland", "command": "tab_stack_setup_keystate_helper" diff --git a/README.md b/README.md index 442ce0b..b19d305 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,8 @@ In that case, build and install it again with the commands above. ### Settings -In `TabStack.sublime-settings`: +In `TabStack.sublime-settings`, +which `Preferences: TabStack Settings` opens from the Command Palette: - `keystate_helper_path`: the path of the installed helper, From d1e5d9d6616cf514a49903b3114df428935e0dbd Mon Sep 17 00:00:00 2001 From: FichteFoll Date: Wed, 14 Oct 2026 23:33:00 +0200 Subject: [PATCH 08/21] Add TabStack entries to the Preferences > Package Settings menu The menu mirrors the Command Palette entries: opening the settings and setting up Ctrl detection for Wayland. Co-Authored-By: Claude Opus 5.5 --- Main.sublime-menu | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 Main.sublime-menu diff --git a/Main.sublime-menu b/Main.sublime-menu new file mode 100644 index 0000000..f0b9399 --- /dev/null +++ b/Main.sublime-menu @@ -0,0 +1,34 @@ +[ + { + "caption": "Preferences", + "mnemonic": "n", + "id": "preferences", + "children": [ + { + "caption": "Package Settings", + "mnemonic": "P", + "id": "package-settings", + "children": [ + { + "caption": "TabStack", + "children": [ + { + "caption": "Settings", + "command": "edit_settings", + "args": { + "base_file": "${packages}/TabStack/TabStack.sublime-settings", + "default": "{\n\t$0\n}\n", + }, + }, + { "caption": "-" }, + { + "caption": "Set Up Ctrl Detection for Wayland", + "command": "tab_stack_setup_keystate_helper" + }, + ], + }, + ], + }, + ], + }, +] From 0208bafdda337bb4552b5e8c15315366e22357da Mon Sep 17 00:00:00 2001 From: FichteFoll Date: Wed, 14 Oct 2026 23:35:05 +0200 Subject: [PATCH 09/21] Resolve relative imports in `main.py` for ty Sublime Text imports packages from `Packages/`, so `main.py` is loaded as `TabStack.main`. Point ty's first-party root at the parent directory so that `from .plugin import *` resolves as it does at runtime. Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 3d1faf4..249c20a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,3 +28,7 @@ reportMissingModuleSource = false # `typing.override` requires 3.12 and the plugin host provides no # typing_extensions, so the decorator cannot be used at runtime. reportImplicitOverride = false + +[tool.ty.environment] +# Sublime Text imports packages from `Packages/`, so `main.py` is `TabStack.main`. +root = [".."] From b03e87bedae1f1d0bf8ac0c4fcfa378266e71de0 Mon Sep 17 00:00:00 2001 From: FichteFoll Date: Wed, 14 Oct 2026 23:39:50 +0200 Subject: [PATCH 10/21] Defer annotation evaluation and enforce `X | None` Require `from __future__ import annotations` in every Python file through Ruff's isort `required-imports` and stop ignoring `UP045`. Annotations are no longer evaluated at runtime, so `Optional[X]` is rewritten to `X | None` despite the Python 3.8 target. Co-Authored-By: Claude Sonnet 5.5 --- main.py | 2 ++ plugin/__init__.py | 2 ++ plugin/constants.py | 2 ++ plugin/ctrl_release/_availability.py | 3 +-- plugin/ctrl_release/linux/__init__.py | 3 +-- plugin/ctrl_release/linux/keystate_helper.py | 12 ++++++------ plugin/ctrl_release/linux/x11.py | 6 +++--- plugin/ctrl_release/macos.py | 6 +++--- plugin/ctrl_release/windows.py | 6 +++--- plugin/history.py | 16 +++++++--------- plugin/listener.py | 6 +++--- plugin/models.py | 10 +++++----- plugin/mru.py | 8 +++----- plugin/sheets.py | 6 +++--- plugin/state.py | 10 +++++----- pyproject.toml | 4 +++- tests/keystate_smoke.py | 4 ++-- 17 files changed, 54 insertions(+), 52 deletions(-) diff --git a/main.py b/main.py index 0135057..ad3458c 100644 --- a/main.py +++ b/main.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import sys # Clear sys.modules cache if package is reloaded, manually or after an update. diff --git a/plugin/__init__.py b/plugin/__init__.py index ec0de5a..5a2571c 100644 --- a/plugin/__init__.py +++ b/plugin/__init__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from .commands import * # noqa: F401,F403 from .ctrl_release import plugin_loaded as _ctrl_release_plugin_loaded from .ctrl_release import plugin_unloaded as _ctrl_release_plugin_unloaded diff --git a/plugin/constants.py b/plugin/constants.py index 7938deb..ca6d82c 100644 --- a/plugin/constants.py +++ b/plugin/constants.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import sublime TAB_HISTORY_LIMIT = 20 diff --git a/plugin/ctrl_release/_availability.py b/plugin/ctrl_release/_availability.py index ee5abd5..3d34adb 100644 --- a/plugin/ctrl_release/_availability.py +++ b/plugin/ctrl_release/_availability.py @@ -1,9 +1,8 @@ from __future__ import annotations from collections.abc import Callable -from typing import Optional -_available: Optional[bool] = None +_available: bool | None = None def set_unavailable() -> None: diff --git a/plugin/ctrl_release/linux/__init__.py b/plugin/ctrl_release/linux/__init__.py index 42a1407..830ffb5 100644 --- a/plugin/ctrl_release/linux/__init__.py +++ b/plugin/ctrl_release/linux/__init__.py @@ -2,7 +2,6 @@ import os from collections.abc import Callable -from typing import Optional from ...state import CtrlPoller from .keystate_helper import HelperCtrlReleasePoller, HelperStatus, get_helper_probe @@ -30,7 +29,7 @@ def probe() -> bool: return not is_native_wayland() and is_x11_available() -def _working_helper_path() -> Optional[str]: +def _working_helper_path() -> str | None: helper_probe = get_helper_probe() if helper_probe is None or helper_probe.status is not HelperStatus.OK: return None diff --git a/plugin/ctrl_release/linux/keystate_helper.py b/plugin/ctrl_release/linux/keystate_helper.py index ee72604..3fa72ef 100644 --- a/plugin/ctrl_release/linux/keystate_helper.py +++ b/plugin/ctrl_release/linux/keystate_helper.py @@ -6,7 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass from enum import Enum -from typing import IO, Optional, final +from typing import IO, final import sublime @@ -37,10 +37,10 @@ class HelperProbe: detail: str -_probe: Optional[HelperProbe] = None +_probe: HelperProbe | None = None _probe_generation = 0 # The path of the probe that is running or finished, as `_probe` may still be `None`. -_probed_path: Optional[str] = None +_probed_path: str | None = None def helper_path() -> str: @@ -94,7 +94,7 @@ def _parse_probe_output(path: str, lines: list[str]) -> HelperProbe: return HelperProbe(path, HelperStatus.FAILED, f"{path} reported no Ctrl key state.") -def get_helper_probe() -> Optional[HelperProbe]: +def get_helper_probe() -> HelperProbe | None: """Return the result of the last probe, or `None` while it is still running.""" return _probe @@ -157,7 +157,7 @@ def __init__(self, on_release: Callable[[], None], interval_ms: int, path: str) self._path = path self._stop_event = threading.Event() self._lock = threading.Lock() - self._process: Optional[subprocess.Popen[str]] = None + self._process: subprocess.Popen[str] | None = None # Ctrl counts as held until the helper reports otherwise, # so that a slow helper start keeps the session instead of dropping it. self._ctrl_down = True @@ -188,7 +188,7 @@ def run(self) -> None: def is_ctrl_down(self) -> bool: return self._ctrl_down - def _spawn(self) -> Optional[subprocess.Popen[str]]: + def _spawn(self) -> subprocess.Popen[str] | None: with self._lock: if self._stop_event.is_set(): return None diff --git a/plugin/ctrl_release/linux/x11.py b/plugin/ctrl_release/linux/x11.py index adc1195..53ed930 100644 --- a/plugin/ctrl_release/linux/x11.py +++ b/plugin/ctrl_release/linux/x11.py @@ -14,7 +14,7 @@ _XK_Control_L = 0xFFE3 _XK_Control_R = 0xFFE4 -_x11: Optional[ctypes.CDLL] = None +_x11: ctypes.CDLL | None = None @dataclass @@ -85,7 +85,7 @@ def _ctrl_down(self) -> bool: def is_ctrl_down(self) -> bool: return self._ctrl_down() - def _open_x11_state(self) -> Optional[_X11State]: + def _open_x11_state(self) -> _X11State | None: display_name = os.environ.get("DISPLAY") if not display_name: return None @@ -125,7 +125,7 @@ def _keycode_is_down(keymap: ctypes.Array[ctypes.c_ubyte], keycode: int) -> bool return bool(keymap_byte & (1 << bit)) -def _get_x11() -> Optional[ctypes.CDLL]: +def _get_x11() -> ctypes.CDLL | None: global _x11 if _x11 is not None: return _x11 diff --git a/plugin/ctrl_release/macos.py b/plugin/ctrl_release/macos.py index 0b123d0..00664fc 100644 --- a/plugin/ctrl_release/macos.py +++ b/plugin/ctrl_release/macos.py @@ -4,7 +4,7 @@ import ctypes.util import threading from collections.abc import Callable -from typing import Optional, cast, final +from typing import cast, final import sublime @@ -13,7 +13,7 @@ _CG_EVENT_SOURCE_STATE_COMBINED_SESSION_STATE = 0 _K_VK_CONTROL = 59 _K_VK_RIGHT_CONTROL = 62 -_key_state: Optional[Callable[[int, int], bool]] = None +_key_state: Callable[[int, int], bool] | None = None @final @@ -66,7 +66,7 @@ def probe() -> bool: return _load_key_state() is not None -def _load_key_state() -> Optional[Callable[[int, int], bool]]: +def _load_key_state() -> Callable[[int, int], bool] | None: global _key_state if _key_state is not None: return _key_state diff --git a/plugin/ctrl_release/windows.py b/plugin/ctrl_release/windows.py index b155e0f..ee4eff1 100644 --- a/plugin/ctrl_release/windows.py +++ b/plugin/ctrl_release/windows.py @@ -3,14 +3,14 @@ import ctypes import threading from collections.abc import Callable -from typing import Optional, cast, final +from typing import cast, final import sublime from ._availability import set_unavailable _VK_CONTROL = 0x11 -_get_async_key_state: Optional[Callable[[int], int]] = None +_get_async_key_state: Callable[[int], int] | None = None @final @@ -57,7 +57,7 @@ def probe() -> bool: return _load_key_state() is not None -def _load_key_state() -> Optional[Callable[[int], int]]: +def _load_key_state() -> Callable[[int], int] | None: global _get_async_key_state if _get_async_key_state is not None: return _get_async_key_state diff --git a/plugin/history.py b/plugin/history.py index 16a52ee..68f99e8 100644 --- a/plugin/history.py +++ b/plugin/history.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional, cast, final +from typing import TYPE_CHECKING, cast, final import sublime @@ -55,9 +55,7 @@ def _poll(self) -> None: self._schedule() -def current_group_selection_state( - window: sublime.Window, group: int -) -> Optional[GroupSelectionState]: +def current_group_selection_state(window: sublime.Window, group: int) -> GroupSelectionState | None: selected_sheets = list(window.selected_sheets_in_group(group)) selected_identities = [ sheet_identity(sheet, window) for sheet in selected_sheets if sheet.group() is not None @@ -190,7 +188,7 @@ def prune_group_state_for_live_sheets( window: sublime.Window, group: int, group_state: GroupSelectionState, -) -> Optional[GroupSelectionState]: +) -> GroupSelectionState | None: selected_sheets = [ identity for identity in group_state["selected_sheets"] @@ -210,10 +208,10 @@ def prune_group_state_for_live_sheets( def prune_active_sheet_index( - active_sheet_index: Optional[int], + active_sheet_index: int | None, original_selected_sheets: list[SheetIdentity], pruned_selected_sheets: list[SheetIdentity], -) -> Optional[int]: +) -> int | None: if active_sheet_index is None: return None if active_sheet_index < 0 or active_sheet_index >= len(original_selected_sheets): @@ -229,7 +227,7 @@ def prune_active_sheet_index( def prune_group_state( group_state: GroupSelectionState, seen_identities: list[SheetIdentity], -) -> Optional[GroupSelectionState]: +) -> GroupSelectionState | None: selected_sheets = [ identity for identity in group_state["selected_sheets"] if identity not in seen_identities ] @@ -249,7 +247,7 @@ def prune_group_state( def prune_history_stack( group_state_stack: list[GroupSelectionState], - removed_identities: Optional[list[SheetIdentity]] = None, + removed_identities: list[SheetIdentity] | None = None, ) -> list[GroupSelectionState]: updated_stack: list[GroupSelectionState] = [] seen_identities: list[SheetIdentity] = list(removed_identities or []) diff --git a/plugin/listener.py b/plugin/listener.py index b793634..ab4f468 100644 --- a/plugin/listener.py +++ b/plugin/listener.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING import sublime import sublime_plugin @@ -84,12 +84,12 @@ def on_deactivated(self, view: sublime.View) -> None: def on_query_context( self, # `view` is widened because ST may query contexts without a view. - view: Optional[sublime.View], + view: sublime.View | None, key: str, operator: sublime.QueryOperator, operand: Value, match_all: bool, - ) -> Optional[bool]: + ) -> bool | None: if key == "tab_stack.ctrl_release_available": value = is_available() elif key in { diff --git a/plugin/models.py b/plugin/models.py index 307ad01..109ec6c 100644 --- a/plugin/models.py +++ b/plugin/models.py @@ -7,20 +7,20 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Optional, TypedDict +from typing import TypedDict class SheetIdentity(TypedDict): - name: Optional[str] + name: str | None """The sheet name is `None` on ST builds <4205 because the required API method does not exist there.""" occurrence: int - kind: Optional[str] - path: Optional[str] + kind: str | None + path: str | None class GroupSelectionState(TypedDict): - active_sheet_index: Optional[int] + active_sheet_index: int | None selected_sheets: list[SheetIdentity] diff --git a/plugin/mru.py b/plugin/mru.py index 4e429f5..9533ba6 100644 --- a/plugin/mru.py +++ b/plugin/mru.py @@ -1,7 +1,5 @@ from __future__ import annotations -from typing import Optional - import sublime from .captions import caption_for_sheet_identities @@ -13,7 +11,7 @@ def collect_entries(window: sublime.Window, history: SheetSelectionHistory) -> list[Entry]: entries: list[Entry] = [] active_group = window.active_group() - seen_identities: set[tuple[Optional[str], int, Optional[str], Optional[str]]] = set() + seen_identities: set[tuple[str | None, int, str | None, str | None]] = set() group_state_stack = history["groups"].get(str(active_group)) if group_state_stack: @@ -62,13 +60,13 @@ def _non_transient_sheets_in_group( def _selection_identity_keys( selection: GroupSelectionState, -) -> set[tuple[Optional[str], int, Optional[str], Optional[str]]]: +) -> set[tuple[str | None, int, str | None, str | None]]: return {_identity_key(identity) for identity in selection["selected_sheets"]} def _identity_key( identity: SheetIdentity, -) -> tuple[Optional[str], int, Optional[str], Optional[str]]: +) -> tuple[str | None, int, str | None, str | None]: return ( identity["name"], identity["occurrence"], diff --git a/plugin/sheets.py b/plugin/sheets.py index e19854e..d04e9d4 100644 --- a/plugin/sheets.py +++ b/plugin/sheets.py @@ -7,7 +7,7 @@ from .models import GroupSelectionState, SheetIdentity -def active_sheet_identity(window: sublime.Window) -> Optional[SheetIdentity]: +def active_sheet_identity(window: sublime.Window) -> SheetIdentity | None: sheet = window.active_sheet() if sheet is None: return None @@ -18,7 +18,7 @@ def find_sheet_by_identity_and_group( window: sublime.Window, identity: SheetIdentity, group: int, -) -> Optional[sublime.Sheet]: +) -> sublime.Sheet | None: matching = [ sheet for sheet in window.sheets_in_group(group) @@ -62,7 +62,7 @@ def sheet_occurrence(sheet: sublime.Sheet, window: sublime.Window) -> int: return 0 -def sheet_name(sheet: sublime.Sheet) -> Optional[str]: +def sheet_name(sheet: sublime.Sheet) -> str | None: # `Sheet.name` only exists on ST builds >=4205. get_name = cast(Optional[Callable[[], str]], getattr(sheet, "name", None)) return get_name() if get_name else None diff --git a/plugin/state.py b/plugin/state.py index 1c506ed..8c76134 100644 --- a/plugin/state.py +++ b/plugin/state.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Optional, Protocol +from typing import Protocol import sublime @@ -26,13 +26,13 @@ class TabStackWindowState: session_pending: bool = False session_pending_token: int = 0 """Monotonically incremented token to track and implicitly cancel set_timeout callbacks.""" - session_origin_selection: Optional[GroupSelectionState] = None + session_origin_selection: GroupSelectionState | None = None session_selected_index: int = 0 session_group: int = 0 - session_entries: Optional[list[Entry]] = None + session_entries: list[Entry] | None = None session_panel_reopening: bool = False - selection_poller: Optional[Poller] = None - ctrl_release_poller: Optional[CtrlPoller] = None + selection_poller: Poller | None = None + ctrl_release_poller: CtrlPoller | None = None overlay_views: set[int] = field(default_factory=set) """Ids of the currently focused overlay/quick-panel widget views. diff --git a/pyproject.toml b/pyproject.toml index 249c20a..596b381 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,9 @@ target-version = "py38" [tool.ruff.lint] select = ["E", "F", "I", "UP"] -ignore = ["UP045"] + +[tool.ruff.lint.isort] +required-imports = ["from __future__ import annotations"] [tool.basedpyright] pythonVersion = "3.8" diff --git a/tests/keystate_smoke.py b/tests/keystate_smoke.py index 32545f7..fe813d0 100644 --- a/tests/keystate_smoke.py +++ b/tests/keystate_smoke.py @@ -21,7 +21,7 @@ import sys import tempfile import time -from typing import Optional, cast +from typing import cast HANDSHAKE = "tabstack-keystate 1" LINE_TIMEOUT = 2.0 @@ -105,7 +105,7 @@ def __init__(self, path: str, user: pwd.struct_passwd) -> None: _ = self._selector.register(self._stdout_fd, selectors.EVENT_READ) self._buffer: bytes = b"" - def read_line(self, timeout: float) -> Optional[str]: + def read_line(self, timeout: float) -> str | None: """Return the next line, or `None` on timeout or EOF.""" deadline = time.monotonic() + timeout while b"\n" not in self._buffer: From a33cc2b0ee3825c5b9eafafe18b13fe8886f73b5 Mon Sep 17 00:00:00 2001 From: FichteFoll Date: Wed, 14 Oct 2026 23:41:51 +0200 Subject: [PATCH 11/21] Update locked dev dependencies Re-lock with `uv lock --upgrade`: `ruff` 0.15.17 to 0.17.0, `basedpyright` 1.39.9 to 1.39.10 and `nodejs-wheel-binaries` 24.16.0 to 24.19.0. Co-Authored-By: Claude Sonnet 5.5 --- uv.lock | 91 ++++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 57 insertions(+), 34 deletions(-) diff --git a/uv.lock b/uv.lock index 64fcaa1..7ec7a0e 100644 --- a/uv.lock +++ b/uv.lock @@ -1,58 +1,80 @@ version = 1 -revision = 3 +revision = 5 requires-python = ">=3.8" +resolution-markers = [ + "python_full_version >= '3.10'", + "python_full_version < '3.10'", +] [[package]] name = "basedpyright" -version = "1.39.9" +version = "1.39.10" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10'", +] dependencies = [ { name = "nodejs-wheel-binaries" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7a/4b/c1f4e211e50389304d6af32b9280e026a7133e3ad59bbdf8f7a3250f8bee/basedpyright-1.39.9.tar.gz", hash = "sha256:32cbea5fc8273e89df3db20daea56cb7286e419ccdfdc479c64759d2dc071901", size = 24412216, upload-time = "2026-06-27T02:19:49.834Z" } +sdist = { url = "https://files.pythonhosted.org/packages/68/43/ad2999f3b09eb2b1e59931d88fac0f7bcc9c17fc18c903268779bd10cc97/basedpyright-1.39.10.tar.gz", hash = "sha256:c8eaf5302f3265e275c7df4fba194d7afa7c1cb53fbfd448e90098360aca2c2e", size = 24740347, upload-time = "2026-08-13T17:09:02.51Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2a/d4/e1fa108710d0498a18c77b1e13897f31eab47c69aa8cfe2d2a4df746541e/basedpyright-1.39.9-py3-none-any.whl", hash = "sha256:6b0837b9eba972c71895167ab9b127e6afdbc17abc92312e3f8d15ca82a5611c", size = 13374276, upload-time = "2026-06-27T02:19:54.431Z" }, + { url = "https://files.pythonhosted.org/packages/be/2a/a224054d75a58786c482f63b8ff2a09fc3362268bd1ebd0a61fb3f982153/basedpyright-1.39.10-py3-none-any.whl", hash = "sha256:cbd75d83c0be841329bcfef2d2f1182f152a6d975b8eb199e75cf5b8e9a3de78", size = 13482322, upload-time = "2026-08-13T17:08:59.074Z" }, +] + +[[package]] +name = "basedpyright" +version = "1.40.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.10'", +] +dependencies = [ + { name = "nodejs-wheel-binaries" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/36/1cf637b15704e4c1f09f8bcc48ab3bfaff089e7466565bd34d8d1dcc03e9/basedpyright-1.40.2.tar.gz", hash = "sha256:d4db36d095230379acc66c7e5fba7e4b50ca902f6e803d696782dd031cb8d7d7", size = 25559682, upload-time = "2026-10-04T11:28:30.415Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/53/0031b22e118264a57a3b64dfc0c8f0659b81af5bccd419ac62933013c601/basedpyright-1.40.2-py3-none-any.whl", hash = "sha256:3a53aeb93a86675ded0e12bc27bd8476c1742356de07ad8cab67bff18110c690", size = 13694487, upload-time = "2026-10-04T11:28:26.271Z" }, ] [[package]] name = "nodejs-wheel-binaries" -version = "24.16.0" +version = "24.19.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/22/2a5beb4e21417c73233d9f65cf6f3e96e891b80d2f550a8f630ebc6b88c6/nodejs_wheel_binaries-24.16.0.tar.gz", hash = "sha256:c973cb69dc5fd16e6f6dc6e579e2c3d5534e2a1f57619dddf5ba070efa7dde37", size = 8056, upload-time = "2026-05-30T16:52:09.807Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/76/7e97195e14346598565a0de4ca8bdbd5e634b3fb5b1ba590b7b1b89f8a63/nodejs_wheel_binaries-24.19.0.tar.gz", hash = "sha256:db217eef8cab8551667863379b08db4d9067403f6cbbe87481eb40edceb8aa9b", size = 8058, upload-time = "2026-08-19T21:47:19.671Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/d1/68b43b53cd0fa83ae6fd406705023ca988d9e0ca41c724d82e66fbeb2ef6/nodejs_wheel_binaries-24.16.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:d9f8f677dcf30e37ac244f07869726abe043f01eb0f45722b1df31cc2af7093c", size = 55666374, upload-time = "2026-05-30T16:51:39.588Z" }, - { url = "https://files.pythonhosted.org/packages/e9/b2/40a989159599080da485de966c4c2d207e852ac7aa7864702626d96c8bf5/nodejs_wheel_binaries-24.16.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:3d0370fe7120ce9697a4f60d40480d2bd8808d9f30131458d5afc0040d4e5a51", size = 55838487, upload-time = "2026-05-30T16:51:43.383Z" }, - { url = "https://files.pythonhosted.org/packages/d7/a7/cd42174fb5ff6faff7fa8d326a18914d8f232098ab5de055b57c16fa13ca/nodejs_wheel_binaries-24.16.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:85dc92bbb79c851569c5925dcc2a4c915a034efab375f99e4e7e6bbe9cca8342", size = 60179540, upload-time = "2026-05-30T16:51:47.036Z" }, - { url = "https://files.pythonhosted.org/packages/2b/95/c8a1f9ae140aa28df8744d984d01d4b3af7cdd6555af12127f40ceb45a7d/nodejs_wheel_binaries-24.16.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:2f3036292811514ba847b3708492644764f88a833ac425c5f55007014308ddfd", size = 60716262, upload-time = "2026-05-30T16:51:50.711Z" }, - { url = "https://files.pythonhosted.org/packages/64/c9/7c35b3737f59e36d0249c265397b7bff570519b95301d6e16ea361e904ad/nodejs_wheel_binaries-24.16.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:db8a8a76ebd2b28ecbfc9ad464baa3707241b9e050a30e2efdf6f60c0f886502", size = 62230592, upload-time = "2026-05-30T16:51:55Z" }, - { url = "https://files.pythonhosted.org/packages/04/96/d931255cf9d11a84d6b54d882dba7434646467d568ccf070ea3418638df3/nodejs_wheel_binaries-24.16.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f1a3d8f7b4491cbbd023ba3fc4e901fcca2d9fb80d57f24ba3890de8b1dbac03", size = 62841759, upload-time = "2026-05-30T16:51:59.407Z" }, - { url = "https://files.pythonhosted.org/packages/a2/7b/8b7a3f41bc255411be30b6d7d288aab8ffd9ea2055db8555ced3548007b9/nodejs_wheel_binaries-24.16.0-py2.py3-none-win_amd64.whl", hash = "sha256:bb136be9944f0662dcf1120f45193a6b75b13fac378971a95cc42c9f879a81aa", size = 42027734, upload-time = "2026-05-30T16:52:03.348Z" }, - { url = "https://files.pythonhosted.org/packages/17/66/1ed71f1f529b8ca727d42c7ceb9db0bef145ce4a13dfc86fb50aa44f3be6/nodejs_wheel_binaries-24.16.0-py2.py3-none-win_arm64.whl", hash = "sha256:8308940b5edd0a50dc5267ea36ba21c9f668e83fe0d9f293937174d3a7e31c36", size = 39714528, upload-time = "2026-05-30T16:52:06.421Z" }, + { url = "https://files.pythonhosted.org/packages/8c/52/0774b52c7be8151ad9d5aff44edc100c3f13d6d8eb3765f63ffa40e69fe8/nodejs_wheel_binaries-24.19.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:e12cbfd69089504e42fb14194ce734a9dcf3eb38c820ca63dd511d36fb964e9c", size = 56047203, upload-time = "2026-08-19T21:46:43.448Z" }, + { url = "https://files.pythonhosted.org/packages/67/3a/4fdbbfecf2c23d52c0e3f68de7f7c1b3c97a26d328c69c5f6c49c48e340e/nodejs_wheel_binaries-24.19.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:1c890adf4b7e6556ccc1ca66c866bb81884b6c9a581dee4e530dc7f78fb9d514", size = 56219459, upload-time = "2026-08-19T21:46:48.45Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a8/0147149415195c59b8a72a594916bfb80d6be4d586f9fbfda313889e0efc/nodejs_wheel_binaries-24.19.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4e029dadfae1295876063c96b236f673487e8c27379fe146c1e2250283520227", size = 60588256, upload-time = "2026-08-19T21:46:53.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/89/6631d0982353da1bb7bc00bb1988f702822c62b42634f57999ee53b5c337/nodejs_wheel_binaries-24.19.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:4196a947bcc883f2003ab101762d729f3e99b5e86b75bd09151563403e2eceb8", size = 61123607, upload-time = "2026-08-19T21:46:58.117Z" }, + { url = "https://files.pythonhosted.org/packages/32/a2/fa30f0841e4602995782e124359f9b910c7b481d98decf61ef0b2fc3ebfb/nodejs_wheel_binaries-24.19.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:352e048ab4dd35e7de5f338d1cc4fcbf77a0e93da30bf7336a8217ee246b31d7", size = 62632842, upload-time = "2026-08-19T21:47:03.42Z" }, + { url = "https://files.pythonhosted.org/packages/18/01/22d97ca72213f66cc386ee638db30c2e62757fdf761c6029074bced83d1c/nodejs_wheel_binaries-24.19.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:28d078b2ced9e2069516e652dc4b1380e7a1a7f2d3934eccd1611586d283ba4c", size = 63250653, upload-time = "2026-08-19T21:47:07.938Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/e3be8fa327a795bcaf7a19cd84299e338a7bce32ff0665fdce9cfa22573c/nodejs_wheel_binaries-24.19.0-py2.py3-none-win_amd64.whl", hash = "sha256:67e3abeb9c3830cae8c8487ae8a2af7cc27dfa75af06145cee5ca7d1857c81bd", size = 42448503, upload-time = "2026-08-19T21:47:12.093Z" }, + { url = "https://files.pythonhosted.org/packages/1d/37/34cf28ba1691a060174948a9927fe61091982d6048b2e403071a9acce443/nodejs_wheel_binaries-24.19.0-py2.py3-none-win_arm64.whl", hash = "sha256:d9074c665ea68b04e183d82482c86dc907d3a9bd15eb6cf85542cb785266bb36", size = 40090155, upload-time = "2026-08-19T21:47:16.032Z" }, ] [[package]] name = "ruff" -version = "0.15.17" +version = "0.17.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/a7/70debb024dfacda67b8e560cc7511f52b34b9348a8cc8c1ec23036dcd51d/ruff-0.17.0.tar.gz", hash = "sha256:5cd03240d8208a557c2a9655a5cb07ebe36aa6bb35065f97d48c1f6adef5a322", size = 5320554, upload-time = "2026-10-09T19:47:29.248Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" }, - { url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" }, - { url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" }, - { url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" }, - { url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" }, - { url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" }, - { url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" }, - { url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" }, - { url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" }, - { url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" }, - { url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" }, - { url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" }, - { url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" }, - { url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" }, - { url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" }, - { url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" }, - { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f8/ee5ab9da6089eae2a33e6008b01deb1eda19992c1c8e10661e98cee1640f/ruff-0.17.0-py3-none-linux_armv6l.whl", hash = "sha256:0e271826af9a20d18c6cfae8c51e82959167c24859686ddd3eb9a7f0842ce81e", size = 10148907, upload-time = "2026-10-09T19:46:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d9/2f81fb5a9d580afbb11b1c8ff915233a11f2a1b27405d7991f183c5e1976/ruff-0.17.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5f0ca4a40f81403689c04f12966e22f44e329ae362072d8f1587b7bda87f603b", size = 10224208, upload-time = "2026-10-09T19:46:41.711Z" }, + { url = "https://files.pythonhosted.org/packages/a7/20/643f3c8f75594f937b2bf74801241c56a2e2b8e139d24dff8b66b28cdd7f/ruff-0.17.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cbf7149e0927dc3295d5d64679a4765576eef71b00782b2ae969ef82274d6bb9", size = 9273324, upload-time = "2026-10-09T19:46:44.323Z" }, + { url = "https://files.pythonhosted.org/packages/ec/91/627700b233d367736cb274f1bd0b47d1f2b12f68878192812bd875adadc3/ruff-0.17.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13ee90156522998c3037059d8f66885c8adeeaf7643bdce2caceee196ecd23e0", size = 9953190, upload-time = "2026-10-09T19:46:47.155Z" }, + { url = "https://files.pythonhosted.org/packages/cd/92/91f7b5ed39490f89d6cbf56e1f543c383667a725efa8e2c2dee0f01f5591/ruff-0.17.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3d8e4a002a94cd9d0dc48b51dc69d807a172b5b9bf2b668e656424dc5b55ead1", size = 9961746, upload-time = "2026-10-09T19:46:50.098Z" }, + { url = "https://files.pythonhosted.org/packages/87/c5/7310f9fc63ce11ff6394edbd5e85433dfb0e14c9fbf6ccc97f1538491bc7/ruff-0.17.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0b8a60c06a218c337e1161638d34757f83449243e2db161483ddf948e53ad14", size = 10625483, upload-time = "2026-10-09T19:46:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8d/97443f0dca4a03a0bc7629fd396fd494a1cb6666121e38c5075acb217d8f/ruff-0.17.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a330178bdffc4205dbf3bda11d93e059e388fd6546f8cdd304501a9160363c0d", size = 11350129, upload-time = "2026-10-09T19:46:56.486Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0a/c525efd9777be4b6b012e6969a3012648468e7e6c4b3e5b46af69f46e8eb/ruff-0.17.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7bb08489e234876fa2da67ae3ea938e9a2156da80293e0e4365abd6973d98329", size = 10906223, upload-time = "2026-10-09T19:47:00.263Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3c/4a01195d93420cad1175bedad13a515dc8a56f95a6e39789b92e582682f5/ruff-0.17.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc73e7c133e82d55b5f15897b2a442d72c0cb4a0c886c46801ce3c247150b60c", size = 10386314, upload-time = "2026-10-09T19:47:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/c7/72/1a3951665485a921f6375f91e754a1854d5a645d41acc3642668064ff64d/ruff-0.17.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:db4f74c533403ab70fe4007873f6ae0c9f94a8b03158cf48d78788e47cdbe399", size = 10565464, upload-time = "2026-10-09T19:47:05.831Z" }, + { url = "https://files.pythonhosted.org/packages/90/8c/539b4d8c082f57e18db8ae2be85a460d77861c79dcd5798e32b536a6a06f/ruff-0.17.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3d8cc360e666d1914e47b0777c6906d70cf18891a55532bd0a16844195d70859", size = 10079468, upload-time = "2026-10-09T19:47:08.617Z" }, + { url = "https://files.pythonhosted.org/packages/e0/b8/84286966db79434e8c26b585b0a0f6897cb3ab1c51a4aa4df10c28488b62/ruff-0.17.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d66de796b726c4801e05fa99a2a8d7a780e107be222486c304ab61765561e866", size = 9958556, upload-time = "2026-10-09T19:47:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/69/50/27b6eed27b83fcdd5bfa0d52b83231e29094754374698da404d094487ae3/ruff-0.17.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c3f268baf004aea944f040623327119527ea231af15f7fb7890e82cea0679589", size = 10352173, upload-time = "2026-10-09T19:47:14.188Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/955399fd13044cd827862044117d784a59e3196f6cce7424908ac9a7f914/ruff-0.17.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:864b6c1acb6b0bccf94b5a3938a1531fd09aaca5e5659a2e7bf0f3cf2a685540", size = 10791761, upload-time = "2026-10-09T19:47:16.931Z" }, + { url = "https://files.pythonhosted.org/packages/ae/bf/024e01e1f6aec87768696725e648ed5b438941341eea8f8100beb681961f/ruff-0.17.0-py3-none-win32.whl", hash = "sha256:5e50aa5b84decd9fe5b0bb0e6f71c3b592f1767ed09faa4b7207d933961e35cd", size = 9904112, upload-time = "2026-10-09T19:47:19.75Z" }, + { url = "https://files.pythonhosted.org/packages/cc/77/1ee73df41dcc8d1cdb686ee4bc46ea29704ea175feb6b95c78420f631ab8/ruff-0.17.0-py3-none-win_amd64.whl", hash = "sha256:8ab76bcda86dfd28e13776cb5de3c7bcdcf1ae3d37ed761113d1a5a415dc134c", size = 10115585, upload-time = "2026-10-09T19:47:22.698Z" }, + { url = "https://files.pythonhosted.org/packages/fd/71/eb4f0ccc844aece56963e8578df9c95d4c00f580547d52329d4035d3af18/ruff-0.17.0-py3-none-win_arm64.whl", hash = "sha256:c154c73ff43f9854395e24cac507af13078962e53d2b511605058d22af1fdb88", size = 9872035, upload-time = "2026-10-09T19:47:26.306Z" }, ] [[package]] @@ -71,7 +93,8 @@ source = { virtual = "." } [package.dev-dependencies] dev = [ - { name = "basedpyright" }, + { name = "basedpyright", version = "1.39.10", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "basedpyright", version = "1.40.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "ruff" }, { name = "sublime-text-stubs" }, ] From 5298d17184563d7e50532c92654479c977e19b13 Mon Sep 17 00:00:00 2001 From: FichteFoll Date: Wed, 14 Oct 2026 23:54:45 +0200 Subject: [PATCH 12/21] Show the helper setup instructions next to the extracted source The setup command now selects the instructions view and the extracted `tabstack-keystate.c` together, so Sublime Text shows them side by side with the instructions on the left. The instructions view is moved in front of the source tab, since the source may already be open, possibly in another group. Multi-sheet selection exists since build 4083, below the package's minimum of 4100, so no new-window fallback is needed. Co-Authored-By: Claude Opus 5.5 --- plugin/setup.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/plugin/setup.py b/plugin/setup.py index a104cb2..e4144a0 100644 --- a/plugin/setup.py +++ b/plugin/setup.py @@ -56,7 +56,7 @@ def _on_probed(self) -> None: return window = self.window - _ = window.open_file(source_path) + source_view = window.open_file(source_path) view = window.new_file(syntax="Packages/Markdown/Markdown.sublime-syntax") view.set_name("TabStack Setup") view.set_scratch(True) @@ -64,6 +64,7 @@ def _on_probed(self) -> None: "append", {"characters": _instructions(probe, extraction_dir, source_path)} ) view.set_read_only(True) + _show_side_by_side(window, view, source_view) def offer_wayland_setup(window: sublime.Window) -> bool: @@ -104,6 +105,21 @@ def _extract_source(extraction_dir: str) -> str: return source_path +def _show_side_by_side(window: sublime.Window, left: sublime.View, right: sublime.View) -> None: + """Select both views' sheets so that Sublime Text shows them side by side. + + The source may have been open already, possibly in another group, + so the new `left` view is moved in front of it instead of the other way around. + """ + left_sheet = left.sheet() + right_sheet = right.sheet() + if left_sheet is None or right_sheet is None: + return + group, index = window.get_sheet_index(right_sheet) + window.set_sheet_index(left_sheet, group, index) + window.select_sheets([left_sheet, right_sheet]) + + def _instructions(probe: HelperProbe, extraction_dir: str, source_path: str) -> str: quoted_path = shlex.quote(probe.path) custom_path_note = _CUSTOM_PATH_NOTE if probe.path != DEFAULT_HELPER_PATH else "" @@ -163,7 +179,7 @@ def _instructions(probe: HelperProbe, extraction_dir: str, source_path: str) -> ## Security implications Review the helper's source before building it. -It was extracted to `{source_path}` and opened in another tab. +It was extracted to `{source_path}` and opened next to these instructions. - The input devices are only readable by root (and usually the `input` group). The helper is therefore granted the `cap_dac_read_search` file capability, From 367d57aaab842f74b76d917519ef201ddc718c82 Mon Sep 17 00:00:00 2001 From: FichteFoll Date: Wed, 14 Oct 2026 23:55:42 +0200 Subject: [PATCH 13/21] Reuse an open setup instructions view Running the setup command repeatedly used to open another instructions view each time. The view is now marked with the `tab_stack_setup_instructions` setting, so later runs locate it in any window, replace its contents via `tab_stack_replace_contents`, and show it next to the source again. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 1 + plugin/setup.py | 47 ++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a9e7b47..88acd4b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,7 @@ - Without a working helper, Linux falls back to the X11 key state API (`plugin/ctrl_release/linux/x11.py`), but not on native Wayland (`is_native_wayland()` in `plugin/ctrl_release/linux/__init__.py`), because XWayland reports Ctrl as released while a Wayland client has focus; Sublime exposes no key-release API. - On native Wayland without a working helper, the first `ctrl+tab` per plugin load shows a dialog offering the setup (`offer_wayland_setup` in `plugin/setup.py`), unless `show_wayland_setup_dialog` is `false`. - `tab_stack_setup_keystate_helper` (Command Palette: `TabStack: Set Up Ctrl Detection for Wayland`) re-probes, extracts `keystate/tabstack-keystate.c` to `sublime.cache_path()/TabStack/keystate/`, and shows the build, install and `setcap` commands. + It reuses an open instructions view (marked by the `tab_stack_setup_instructions` view setting) and selects it together with the source, instructions on the left. The plugin never runs `sudo`, a compiler or `setcap`; the user installs the helper to `keystate_helper_path` (default `/usr/local/libexec/tabstack-keystate`) with `cap_dac_read_search`, which the helper drops right after opening the input devices. - `tab_stack_open` arms a pending session, starts a Ctrl-release poller, and opens the quick panel after a short delay unless Ctrl is released first. - Wrap-around cycling is implemented by intentionally closing and reopening the quick panel at the opposite boundary. diff --git a/plugin/setup.py b/plugin/setup.py index e4144a0..f56b6eb 100644 --- a/plugin/setup.py +++ b/plugin/setup.py @@ -14,7 +14,7 @@ get_helper_probe, ) -__all__ = ["TabStackSetupKeystateHelperCommand"] +__all__ = ["TabStackReplaceContentsCommand", "TabStackSetupKeystateHelperCommand"] assert __package__ is not None # This module lives in the `plugin` subpackage; resources are addressed by the top-level package. @@ -23,6 +23,7 @@ _SOURCE_NAME = "tabstack-keystate.c" _SETUP_COMMAND = "tab_stack_setup_keystate_helper" _SETUP_CAPTION = "TabStack: Set Up Ctrl Detection for Wayland" +_INSTRUCTIONS_VIEW_SETTING = "tab_stack_setup_instructions" _wayland_dialog_shown = False @@ -55,16 +56,28 @@ def _on_probed(self) -> None: sublime.error_message(f"TabStack: could not extract the helper source: {exc}") return - window = self.window + view = _find_instructions_view() + window = self.window if view is None else view.window() or self.window source_view = window.open_file(source_path) - view = window.new_file(syntax="Packages/Markdown/Markdown.sublime-syntax") - view.set_name("TabStack Setup") - view.set_scratch(True) + if view is None: + view = _new_instructions_view(window) + view.set_read_only(False) view.run_command( - "append", {"characters": _instructions(probe, extraction_dir, source_path)} + "tab_stack_replace_contents", + {"characters": _instructions(probe, extraction_dir, source_path)}, ) view.set_read_only(True) _show_side_by_side(window, view, source_view) + window.bring_to_front() + + +class TabStackReplaceContentsCommand(sublime_plugin.TextCommand): + def run(self, edit: sublime.Edit, characters: str) -> None: + _ = self.view.replace(edit, sublime.Region(0, self.view.size()), characters) + # A caret at the replaced region's start grows into a selection of the new text. + selection = self.view.sel() + selection.clear() + selection.add(0) def offer_wayland_setup(window: sublime.Window) -> bool: @@ -105,17 +118,37 @@ def _extract_source(extraction_dir: str) -> str: return source_path +def _find_instructions_view() -> sublime.View | None: + for window in sublime.windows(): + for view in window.views(): + if view.settings().get(_INSTRUCTIONS_VIEW_SETTING, False): + return view + return None + + +def _new_instructions_view(window: sublime.Window) -> sublime.View: + view = window.new_file(syntax="Packages/Markdown/Markdown.sublime-syntax") + view.set_name("TabStack Setup") + view.set_scratch(True) + view.settings().set(_INSTRUCTIONS_VIEW_SETTING, True) + return view + + def _show_side_by_side(window: sublime.Window, left: sublime.View, right: sublime.View) -> None: """Select both views' sheets so that Sublime Text shows them side by side. The source may have been open already, possibly in another group, - so the new `left` view is moved in front of it instead of the other way around. + so `left` is moved directly in front of it instead of the other way around. """ left_sheet = left.sheet() right_sheet = right.sheet() if left_sheet is None or right_sheet is None: return group, index = window.get_sheet_index(right_sheet) + left_group, left_index = window.get_sheet_index(left_sheet) + if left_group == group and left_index < index: + # Moving `left` out of the group shifts `right` one position to the front. + index -= 1 window.set_sheet_index(left_sheet, group, index) window.select_sheets([left_sheet, right_sheet]) From c0abb05f6e6ea44e4a6a73795283a685163ceffd Mon Sep 17 00:00:00 2001 From: FichteFoll Date: Thu, 15 Oct 2026 00:13:56 +0200 Subject: [PATCH 14/21] Skip input devices without a Ctrl key before opening them Closing an evdev device waits for an RCU grace period, so opening and closing every non-keyboard device delayed the initial state report by about 250 ms on a machine with 19 input devices. A Ctrl release was only noticed after that, which postponed the tab switch. The helper now reads each device's key capabilities from `/sys/class/input//device/capabilities/key` and opens only the devices with a Ctrl key, falling back to opening a device when sysfs cannot be read. A full run now takes about 26 ms instead of 258 ms. Co-Authored-By: Claude Opus 5.5 --- keystate/tabstack-keystate.c | 47 ++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/keystate/tabstack-keystate.c b/keystate/tabstack-keystate.c index 0fcc9ac..6dfefe8 100644 --- a/keystate/tabstack-keystate.c +++ b/keystate/tabstack-keystate.c @@ -67,6 +67,49 @@ static bool has_ctrl(const unsigned long *keys) { return false; } +/* + * Reads the key capabilities that sysfs exposes for `name`, so that devices + * without a Ctrl key need not be opened: closing an evdev device waits for an + * RCU grace period, which adds up to hundreds of milliseconds before the + * initial state could be reported. The file holds hex longs, most significant + * first, with leading zero longs omitted. Returns false if it cannot be read. + */ +static bool read_sysfs_keys(const char *name, unsigned long *keys) { + char path[128]; + int length = snprintf(path, sizeof path, "/sys/class/input/%s/device/capabilities/key", name); + if (length < 0 || (size_t)length >= sizeof path) { + return false; + } + FILE *file = fopen(path, "re"); + if (file == NULL) { + return false; + } + char line[KEY_LONGS * 17 + 2]; + bool ok = fgets(line, sizeof line, file) != NULL; + fclose(file); + if (!ok) { + return false; + } + + unsigned long words[KEY_LONGS]; + size_t count = 0; + for (char *token = strtok(line, " \n"); token != NULL; token = strtok(NULL, " \n")) { + if (count == KEY_LONGS) { + return false; + } + char *end; + errno = 0; + words[count++] = strtoul(token, &end, 16); + if (errno != 0 || *end != '\0') { + return false; + } + } + for (size_t i = 0; i < count; i++) { + keys[i] = words[count - 1 - i]; + } + return true; +} + /* Opens every device with a Ctrl key. Returns whether any open was denied. */ static bool open_keyboards(void) { DIR *dir = opendir("/dev/input"); @@ -79,6 +122,10 @@ static bool open_keyboards(void) { if (strncmp(entry->d_name, "event", 5) != 0) { continue; } + unsigned long sysfs_keys[KEY_LONGS] = {0}; + if (read_sysfs_keys(entry->d_name, sysfs_keys) && !has_ctrl(sysfs_keys)) { + continue; + } int fd = openat(dirfd(dir), entry->d_name, O_RDONLY | O_NONBLOCK | O_CLOEXEC); if (fd < 0) { denied = denied || errno == EACCES || errno == EPERM; From 91f7289422209194a6e0027af786d302997f5f9c Mon Sep 17 00:00:00 2001 From: FichteFoll Date: Thu, 15 Oct 2026 00:13:56 +0200 Subject: [PATCH 15/21] Keep a session pending while the Ctrl state is unknown The helper poller assumed Ctrl held until the helper reported the initial state. When that took longer than the panel delay, a quick Ctrl+Tab showed the panel and closed it again right away. `is_ctrl_down` now returns `None` until the state is known, and the pending session keeps waiting for it, up to one second, before it opens the panel. A release reported meanwhile commits the session directly. Co-Authored-By: Claude Opus 5.5 --- plugin/ctrl_release/linux/keystate_helper.py | 8 ++++---- plugin/session.py | 18 +++++++++++++++--- plugin/state.py | 4 +++- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/plugin/ctrl_release/linux/keystate_helper.py b/plugin/ctrl_release/linux/keystate_helper.py index 3fa72ef..c1a611b 100644 --- a/plugin/ctrl_release/linux/keystate_helper.py +++ b/plugin/ctrl_release/linux/keystate_helper.py @@ -158,9 +158,9 @@ def __init__(self, on_release: Callable[[], None], interval_ms: int, path: str) self._stop_event = threading.Event() self._lock = threading.Lock() self._process: subprocess.Popen[str] | None = None - # Ctrl counts as held until the helper reports otherwise, - # so that a slow helper start keeps the session instead of dropping it. - self._ctrl_down = True + # Unknown until the helper reports the initial state, which can take longer + # than the panel delay; assuming Ctrl held would flash the panel on a quick press. + self._ctrl_down: bool | None = None def start(self) -> None: if self.is_alive(): @@ -185,7 +185,7 @@ def run(self) -> None: finally: self._terminate(process) - def is_ctrl_down(self) -> bool: + def is_ctrl_down(self) -> bool | None: return self._ctrl_down def _spawn(self) -> subprocess.Popen[str] | None: diff --git a/plugin/session.py b/plugin/session.py index e816b03..fc5bcae 100644 --- a/plugin/session.py +++ b/plugin/session.py @@ -8,6 +8,9 @@ from .state import TabStackWindowState SHOW_PANEL_DELAY_MS = 150 +# How long to keep a session pending past the panel delay while the Ctrl state is unknown. +CTRL_STATE_TIMEOUT_MS = 1000 +CTRL_STATE_RETRY_MS = 10 def _ensure_ctrl_release_poller(window: sublime.Window, state: TabStackWindowState) -> None: @@ -66,10 +69,19 @@ def on_highlight(index: int) -> None: def schedule_panel(window: sublime.Window, state: TabStackWindowState, *, token: int) -> None: _ensure_ctrl_release_poller(window, state) - def open_if_still_pending() -> None: + def open_if_still_pending(waited_ms: int) -> None: if not state.session_pending or state.session_pending_token != token: return - if state.ctrl_release_poller is not None and not state.ctrl_release_poller.is_ctrl_down(): + poller = state.ctrl_release_poller + ctrl_down = poller.is_ctrl_down() if poller is not None else True + if ctrl_down is None and waited_ms < CTRL_STATE_TIMEOUT_MS: + # A release reported meanwhile commits the pending session through the poller. + sublime.set_timeout( + lambda: open_if_still_pending(waited_ms + CTRL_STATE_RETRY_MS), + CTRL_STATE_RETRY_MS, + ) + return + if ctrl_down is False: # Ctrl was released before the panel appeared; switch like a quick press would. _commit_pending_session(window, state) return @@ -77,7 +89,7 @@ def open_if_still_pending() -> None: state.session_pending = False show_panel(window, state) - sublime.set_timeout(open_if_still_pending, SHOW_PANEL_DELAY_MS) + sublime.set_timeout(lambda: open_if_still_pending(0), SHOW_PANEL_DELAY_MS) def preview_entry(window: sublime.Window, state: TabStackWindowState) -> None: diff --git a/plugin/state.py b/plugin/state.py index 8c76134..afcde31 100644 --- a/plugin/state.py +++ b/plugin/state.py @@ -17,7 +17,9 @@ def stop(self) -> None: ... class CtrlPoller(Poller, Protocol): def start(self) -> None: ... - def is_ctrl_down(self) -> bool: ... + def is_ctrl_down(self) -> bool | None: + """Return whether Ctrl is held, or `None` while the state is not known yet.""" + ... @dataclass From 633cad5ae9105e6ad349e0557bb89087a72b531c Mon Sep 17 00:00:00 2001 From: FichteFoll Date: Thu, 15 Oct 2026 00:19:52 +0200 Subject: [PATCH 16/21] Pin CI actions to their latest releases by SHA `actions/setup-python@v5` still runs on Node 20, which triggers a deprecation warning. Update all actions to their latest releases, which run on Node 24, and pin them by commit SHA with the version as a comment. Add a Dependabot configuration that updates all actions weekly in a single grouped pull request. Co-Authored-By: Claude Opus 5.5 --- .github/dependabot.yml | 10 ++++++++++ .github/workflows/ci.yml | 8 ++++---- 2 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..6c5049e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1f3ae01..4418576 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,11 +8,11 @@ jobs: check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6.0.3 - - uses: actions/setup-python@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.8" - - uses: astral-sh/setup-uv@v8.2.0 + - uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true - run: uv sync --group dev --no-install-project @@ -22,7 +22,7 @@ jobs: keystate-helper: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6.0.3 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - run: sudo modprobe uinput - run: mkdir -p build - run: cc -std=c11 -O2 -Wall -Wextra -Werror -o build/tabstack-keystate keystate/tabstack-keystate.c From 3125034238688dd511b5a3431b4c6c7b37f9e168 Mon Sep 17 00:00:00 2001 From: FichteFoll Date: Thu, 15 Oct 2026 01:09:53 +0200 Subject: [PATCH 17/21] Ignore Ctrl releases reported by a replaced poller A poller queues its release callback on the main thread. If the panel timer commits the session first and Ctrl+Tab starts a new one before the queued callback runs, the stale callback would commit the new session. Act only while the reporting poller is still the session's. Co-Authored-By: Claude Opus 5.5 --- plugin/session.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugin/session.py b/plugin/session.py index fc5bcae..de260e1 100644 --- a/plugin/session.py +++ b/plugin/session.py @@ -18,6 +18,9 @@ def _ensure_ctrl_release_poller(window: sublime.Window, state: TabStackWindowSta return def on_release() -> None: + # A release queued before this poller was stopped must not commit a newer session. + if state.ctrl_release_poller is not poller: + return if state.session_active: _commit_session(window, state) elif state.session_pending: From d5776f5869d0138e2097941634ceaf0a97a4e932 Mon Sep 17 00:00:00 2001 From: FichteFoll Date: Thu, 15 Oct 2026 01:10:22 +0200 Subject: [PATCH 18/21] Re-probe the helper after it fails during a session The helper was only probed on plugin load and on a change of `keystate_helper_path`. A helper that stopped working afterwards, for example after a reinstall dropped its file capability, stayed cached as available, so every later Ctrl+Tab committed immediately. A failing poller now invalidates the probe it was created from, unless a newer probe superseded it, which refreshes the availability. Later sessions then fall back to X11 or offer the setup. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 1 + plugin/ctrl_release/__init__.py | 15 ++++--- plugin/ctrl_release/linux/__init__.py | 14 +++--- plugin/ctrl_release/linux/keystate_helper.py | 45 ++++++++++++++++---- 4 files changed, 52 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 88acd4b..eb8a44d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,7 @@ - Under Linux, Ctrl-release detection uses the `tabstack-keystate` helper (`plugin/ctrl_release/linux/keystate_helper.py`) when its probe succeeds. `plugin_loaded` probes it on the async thread, because running it can take up to 2 s; until the result arrives, Linux behaves as if the helper were missing, but without the setup dialog. The probe records the helper path it ran with, the poller runs exactly that path, and a change of `keystate_helper_path` triggers a new probe. + A helper that fails during a session (spawn error, bad handshake, `error` line, early EOF) still commits that session, but triggers a new probe unless a newer one has superseded it. The helper reads evdev key state with `cap_dac_read_search`, is spawned once per session, and reports Ctrl state changes on stdout. - Without a working helper, Linux falls back to the X11 key state API (`plugin/ctrl_release/linux/x11.py`), but not on native Wayland (`is_native_wayland()` in `plugin/ctrl_release/linux/__init__.py`), because XWayland reports Ctrl as released while a Wayland client has focus; Sublime exposes no key-release API. - On native Wayland without a working helper, the first `ctrl+tab` per plugin load shows a dialog offering the setup (`offer_wayland_setup` in `plugin/setup.py`), unless `show_wayland_setup_dialog` is `false`. diff --git a/plugin/ctrl_release/__init__.py b/plugin/ctrl_release/__init__.py index fa96eec..f8294af 100644 --- a/plugin/ctrl_release/__init__.py +++ b/plugin/ctrl_release/__init__.py @@ -14,8 +14,8 @@ def _start_platform_probe(on_done: Callable[[], None]) -> None: on_done() - def _watch_platform_settings(on_change: Callable[[], None]) -> None: - _ = on_change + def _watch_platform(on_invalidated: Callable[[], None]) -> None: + _ = on_invalidated def is_native_wayland() -> bool: return False @@ -28,8 +28,8 @@ def is_native_wayland() -> bool: def _start_platform_probe(on_done: Callable[[], None]) -> None: on_done() - def _watch_platform_settings(on_change: Callable[[], None]) -> None: - _ = on_change + def _watch_platform(on_invalidated: Callable[[], None]) -> None: + _ = on_invalidated def is_native_wayland() -> bool: return False @@ -38,7 +38,7 @@ def is_native_wayland() -> bool: from .linux import create_poller, is_native_wayland, probe from .linux import plugin_unloaded as _platform_plugin_unloaded from .linux.keystate_helper import HelperProbe, start_helper_probe - from .linux.keystate_helper import watch_helper_path as _watch_platform_settings + from .linux.keystate_helper import watch_helper as _watch_platform def _start_platform_probe(on_done: Callable[[], None]) -> None: def on_helper_probed(_: HelperProbe) -> None: @@ -68,8 +68,9 @@ def on_probed() -> None: def plugin_loaded() -> None: refresh_availability() - # The probe result only holds for the settings it was made with. - _watch_platform_settings(refresh_availability) + # The probe result only holds for the settings it was made with, + # and only until the probed helper fails. + _watch_platform(refresh_availability) def plugin_unloaded() -> None: diff --git a/plugin/ctrl_release/linux/__init__.py b/plugin/ctrl_release/linux/__init__.py index 830ffb5..9179f78 100644 --- a/plugin/ctrl_release/linux/__init__.py +++ b/plugin/ctrl_release/linux/__init__.py @@ -4,7 +4,7 @@ from collections.abc import Callable from ...state import CtrlPoller -from .keystate_helper import HelperCtrlReleasePoller, HelperStatus, get_helper_probe +from .keystate_helper import HelperCtrlReleasePoller, HelperProbe, HelperStatus, get_helper_probe from .keystate_helper import plugin_unloaded as _helper_plugin_unloaded from .x11 import X11CtrlReleasePoller, is_x11_available from .x11 import plugin_unloaded as _x11_plugin_unloaded @@ -15,25 +15,25 @@ def is_native_wayland() -> bool: def create_poller(on_release: Callable[[], None], interval_ms: int) -> CtrlPoller: - path = _working_helper_path() - if path is not None: - return HelperCtrlReleasePoller(on_release, interval_ms, path) + helper_probe = _working_helper_probe() + if helper_probe is not None: + return HelperCtrlReleasePoller(on_release, interval_ms, helper_probe) return X11CtrlReleasePoller(on_release, interval_ms) def probe() -> bool: - if _working_helper_path() is not None: + if _working_helper_probe() is not None: return True # Under native Wayland, XWayland reports Ctrl as released while Sublime has focus, # which would commit every session immediately. return not is_native_wayland() and is_x11_available() -def _working_helper_path() -> str | None: +def _working_helper_probe() -> HelperProbe | None: helper_probe = get_helper_probe() if helper_probe is None or helper_probe.status is not HelperStatus.OK: return None - return helper_probe.path + return helper_probe def plugin_unloaded() -> None: diff --git a/plugin/ctrl_release/linux/keystate_helper.py b/plugin/ctrl_release/linux/keystate_helper.py index c1a611b..dc8a50e 100644 --- a/plugin/ctrl_release/linux/keystate_helper.py +++ b/plugin/ctrl_release/linux/keystate_helper.py @@ -41,6 +41,8 @@ class HelperProbe: _probe_generation = 0 # The path of the probe that is running or finished, as `_probe` may still be `None`. _probed_path: str | None = None +# Called on the main thread once the probe result no longer holds. +_on_invalidated: Callable[[], None] | None = None def helper_path() -> str: @@ -137,24 +139,40 @@ def reset_helper_probe() -> None: _probed_path = None -def watch_helper_path(on_change: Callable[[], None]) -> None: - """Call `on_change` whenever `keystate_helper_path` no longer matches the probed path.""" +def watch_helper(on_invalidated: Callable[[], None]) -> None: + """Call `on_invalidated` whenever the probe result no longer holds. + + That is when `keystate_helper_path` no longer matches the probed path, + or when the probed helper fails during a session. + """ + global _on_invalidated + _on_invalidated = on_invalidated def on_settings_changed() -> None: if helper_path() != _probed_path: - on_change() + on_invalidated() sublime.load_settings(_SETTINGS_FILE).add_on_change(_SETTINGS_WATCH_KEY, on_settings_changed) +def _invalidate_probe(probe: HelperProbe) -> None: + # A newer probe or a reset already superseded the failed one. + if probe is not _probe or _on_invalidated is None: + return + _on_invalidated() + + @final class HelperCtrlReleasePoller(threading.Thread): - def __init__(self, on_release: Callable[[], None], interval_ms: int, path: str) -> None: + def __init__( + self, on_release: Callable[[], None], interval_ms: int, probe: HelperProbe + ) -> None: super().__init__(daemon=True) # The helper reports state changes as they happen, so there is nothing to poll. _ = interval_ms self._on_release = on_release - self._path = path + self._probe = probe + self._path = probe.path self._stop_event = threading.Event() self._lock = threading.Lock() self._process: subprocess.Popen[str] | None = None @@ -221,7 +239,7 @@ def _read_states(self, stdout: IO[str]) -> None: self._ctrl_down = True elif line == "0": self._ctrl_down = False - self._fire_release() + _ = self._fire_release() return elif line.startswith("error "): print(f"TabStack: {self._path} reported: {line}") @@ -234,16 +252,23 @@ def _read_states(self, stdout: IO[str]) -> None: def _fail(self) -> None: self._ctrl_down = False - self._fire_release() + if not self._fire_release(): + return + # The helper worked when probed; probe it again so that later sessions + # fall back to X11 or offer the setup instead of committing immediately. + probe = self._probe + sublime.set_timeout(lambda: _invalidate_probe(probe)) - def _fire_release(self) -> None: + def _fire_release(self) -> bool: + """Schedule the release callback; return whether it was scheduled.""" # The lock makes the check-and-set atomic with respect to `stop`, # so that no release is scheduled once the session stopped the poller. with self._lock: if self._stop_event.is_set(): - return + return False self._stop_event.set() sublime.set_timeout(self._on_release) + return True def _close_stdin(self) -> None: with self._lock: @@ -268,5 +293,7 @@ def _terminate(self, process: subprocess.Popen[str]) -> None: def plugin_unloaded() -> None: + global _on_invalidated sublime.load_settings(_SETTINGS_FILE).clear_on_change(_SETTINGS_WATCH_KEY) + _on_invalidated = None reset_helper_probe() From 2b68debc7dbb6e0bc2f4bf39196adfc365242669 Mon Sep 17 00:00:00 2001 From: FichteFoll Date: Thu, 15 Oct 2026 01:11:19 +0200 Subject: [PATCH 19/21] Let the helper count allowlisted modifiers as Ctrl The helper only watched the physical Ctrl keys, so with XKB options such as `ctrl:nocaps` or `ctrl:swapcaps` it reported Ctrl as released while the remapped key was still held, and every session committed at once. The helper now takes the keys that count as Ctrl as arguments. They default to both Ctrl keys and are restricted to the modifiers that the XKB `ctrl:*` options remap. The helper runs with its capability for any local user, so accepting arbitrary keys would turn it into a keylogger. Bump the protocol to version 2, so that an older helper, which ignores the arguments, is reported as outdated. Co-Authored-By: Claude Opus 5.5 --- keystate/tabstack-keystate.c | 82 ++++++++++++++++---- plugin/ctrl_release/linux/keystate_helper.py | 2 +- tests/keystate_smoke.py | 41 +++++++++- 3 files changed, 106 insertions(+), 19 deletions(-) diff --git a/keystate/tabstack-keystate.c b/keystate/tabstack-keystate.c index 6dfefe8..a23680a 100644 --- a/keystate/tabstack-keystate.c +++ b/keystate/tabstack-keystate.c @@ -12,9 +12,15 @@ * the devices, before it reads any input or reports anything that depends on * them. * - * Protocol, version 1: ASCII lines on stdout, each flushed. - * "tabstack-keystate 1" first, then either "error " and - * exit status 1, or the state ("1" while a Ctrl key is held, else "0") + * Usage: tabstack-keystate [key...] + * The keys that count as Ctrl, by default "leftctrl rightctrl". Only the + * names in `allowed_keys` are accepted, the keys that XKB `ctrl:*` options + * can turn into Ctrl. Since any local user can run the helper with its + * capability, reporting arbitrary keys would make it a keylogger. + * + * Protocol, version 2: ASCII lines on stdout, each flushed. + * "tabstack-keystate 2" first, then either "error " and + * exit status 1, or the state ("1" while a watched key is held, else "0") * followed by one line per change. Exits 0 when stdin reaches EOF. * * Build: cc -std=c11 -O2 -Wall -Wextra -o tabstack-keystate tabstack-keystate.c @@ -41,6 +47,25 @@ #define BITS_PER_LONG (8 * sizeof(unsigned long)) #define KEY_LONGS ((KEY_CNT + BITS_PER_LONG - 1) / BITS_PER_LONG) +struct named_key { + const char *name; + unsigned int code; +}; + +static const struct named_key allowed_keys[] = { + {"leftctrl", KEY_LEFTCTRL}, + {"rightctrl", KEY_RIGHTCTRL}, + {"capslock", KEY_CAPSLOCK}, + {"leftalt", KEY_LEFTALT}, + {"rightalt", KEY_RIGHTALT}, + {"leftmeta", KEY_LEFTMETA}, + {"rightmeta", KEY_RIGHTMETA}, + {"compose", KEY_COMPOSE}, +}; +#define ALLOWED_KEY_COUNT (sizeof allowed_keys / sizeof allowed_keys[0]) + +static unsigned int watched_keys[ALLOWED_KEY_COUNT]; +static size_t watched_key_count; static int devices[MAX_DEVICES]; static int device_count; @@ -57,10 +82,29 @@ static void fail(const char *code, const char *message) { exit(1); } -static bool has_ctrl(const unsigned long *keys) { - const unsigned int codes[] = {KEY_LEFTCTRL, KEY_RIGHTCTRL}; - for (size_t i = 0; i < sizeof codes / sizeof codes[0]; i++) { - if ((keys[codes[i] / BITS_PER_LONG] >> (codes[i] % BITS_PER_LONG)) & 1UL) { +/* Adds the allowed key `name` to the watched keys, or fails. */ +static void watch_key(const char *name) { + for (size_t i = 0; i < ALLOWED_KEY_COUNT; i++) { + if (strcmp(name, allowed_keys[i].name) != 0) { + continue; + } + for (size_t j = 0; j < watched_key_count; j++) { + if (watched_keys[j] == allowed_keys[i].code) { + fail("usage", "duplicate key"); + } + } + watched_keys[watched_key_count++] = allowed_keys[i].code; + return; + } + char message[128]; + snprintf(message, sizeof message, "key \"%.32s\" is not allowed", name); + fail("usage", message); +} + +static bool has_watched_key(const unsigned long *keys) { + for (size_t i = 0; i < watched_key_count; i++) { + unsigned int code = watched_keys[i]; + if ((keys[code / BITS_PER_LONG] >> (code % BITS_PER_LONG)) & 1UL) { return true; } } @@ -69,7 +113,7 @@ static bool has_ctrl(const unsigned long *keys) { /* * Reads the key capabilities that sysfs exposes for `name`, so that devices - * without a Ctrl key need not be opened: closing an evdev device waits for an + * without a watched key need not be opened: closing an evdev device waits for an * RCU grace period, which adds up to hundreds of milliseconds before the * initial state could be reported. The file holds hex longs, most significant * first, with leading zero longs omitted. Returns false if it cannot be read. @@ -110,7 +154,7 @@ static bool read_sysfs_keys(const char *name, unsigned long *keys) { return true; } -/* Opens every device with a Ctrl key. Returns whether any open was denied. */ +/* Opens every device with a watched key. Returns whether any open was denied. */ static bool open_keyboards(void) { DIR *dir = opendir("/dev/input"); if (dir == NULL) { @@ -123,7 +167,7 @@ static bool open_keyboards(void) { continue; } unsigned long sysfs_keys[KEY_LONGS] = {0}; - if (read_sysfs_keys(entry->d_name, sysfs_keys) && !has_ctrl(sysfs_keys)) { + if (read_sysfs_keys(entry->d_name, sysfs_keys) && !has_watched_key(sysfs_keys)) { continue; } int fd = openat(dirfd(dir), entry->d_name, O_RDONLY | O_NONBLOCK | O_CLOEXEC); @@ -132,7 +176,7 @@ static bool open_keyboards(void) { continue; } unsigned long keys[KEY_LONGS] = {0}; - if (ioctl(fd, EVIOCGBIT(EV_KEY, sizeof keys), keys) < 0 || !has_ctrl(keys)) { + if (ioctl(fd, EVIOCGBIT(EV_KEY, sizeof keys), keys) < 0 || !has_watched_key(keys)) { close(fd); continue; } @@ -153,7 +197,7 @@ static int drop_capabilities(void) { static int ctrl_state(void) { for (int i = 0; i < device_count; i++) { unsigned long keys[KEY_LONGS] = {0}; - if (ioctl(devices[i], EVIOCGKEY(sizeof keys), keys) >= 0 && has_ctrl(keys)) { + if (ioctl(devices[i], EVIOCGKEY(sizeof keys), keys) >= 0 && has_watched_key(keys)) { return 1; } } @@ -182,13 +226,21 @@ static bool stdin_open(short revents) { return n > 0 || (n < 0 && (errno == EINTR || errno == EAGAIN)); } -int main(void) { +int main(int argc, char **argv) { signal(SIGPIPE, SIG_IGN); if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) { perror("tabstack-keystate: prctl"); return 1; } - emit("tabstack-keystate 1"); + emit("tabstack-keystate 2"); + + if (argc < 2) { + watch_key("leftctrl"); + watch_key("rightctrl"); + } + for (int i = 1; i < argc; i++) { + watch_key(argv[i]); + } bool denied = open_keyboards(); if (drop_capabilities() != 0) { @@ -198,7 +250,7 @@ int main(void) { if (denied) { fail("no-access", "cannot open /dev/input/event* (is cap_dac_read_search set?)"); } - fail("no-keyboards", "no input device has a Ctrl key"); + fail("no-keyboards", "no input device has a watched key"); } int state = ctrl_state(); diff --git a/plugin/ctrl_release/linux/keystate_helper.py b/plugin/ctrl_release/linux/keystate_helper.py index dc8a50e..07c25d0 100644 --- a/plugin/ctrl_release/linux/keystate_helper.py +++ b/plugin/ctrl_release/linux/keystate_helper.py @@ -10,7 +10,7 @@ import sublime -HANDSHAKE = "tabstack-keystate 1" +HANDSHAKE = "tabstack-keystate 2" DEFAULT_HELPER_PATH = "/usr/local/libexec/tabstack-keystate" _HANDSHAKE_PREFIX = "tabstack-keystate " diff --git a/tests/keystate_smoke.py b/tests/keystate_smoke.py index fe813d0..ecd23f6 100644 --- a/tests/keystate_smoke.py +++ b/tests/keystate_smoke.py @@ -23,7 +23,7 @@ import time from typing import cast -HANDSHAKE = "tabstack-keystate 1" +HANDSHAKE = "tabstack-keystate 2" LINE_TIMEOUT = 2.0 SILENCE_TIMEOUT = 0.3 EXIT_TIMEOUT = 1.0 @@ -36,6 +36,7 @@ SYN_REPORT = 0 KEY_LEFTCTRL = 29 KEY_A = 30 +KEY_CAPSLOCK = 58 KEY_RIGHTCTRL = 97 BUS_USB = 0x03 @@ -65,7 +66,7 @@ class VirtualKeyboard: def __init__(self) -> None: self._fd: int = os.open("/dev/uinput", os.O_WRONLY | os.O_NONBLOCK) _ = fcntl.ioctl(self._fd, UI_SET_EVBIT, EV_KEY) - for key in (KEY_LEFTCTRL, KEY_RIGHTCTRL, KEY_A): + for key in (KEY_LEFTCTRL, KEY_RIGHTCTRL, KEY_A, KEY_CAPSLOCK): _ = fcntl.ioctl(self._fd, UI_SET_KEYBIT, key) setup = UINPUT_SETUP.pack(BUS_USB, 0x1234, 0x5678, 1, b"tabstack-keystate smoke test", 0) _ = fcntl.ioctl(self._fd, UI_DEV_SETUP, setup) @@ -85,7 +86,7 @@ def destroy(self) -> None: class Helper: - def __init__(self, path: str, user: pwd.struct_passwd) -> None: + def __init__(self, path: str, user: pwd.struct_passwd, *keys: str) -> None: command = [ "setpriv", "--reuid", @@ -94,6 +95,7 @@ def __init__(self, path: str, user: pwd.struct_passwd) -> None: str(user.pw_gid), "--init-groups", path, + *keys, ] self.process: subprocess.Popen[bytes] = subprocess.Popen( command, stdin=subprocess.PIPE, stdout=subprocess.PIPE @@ -124,6 +126,12 @@ def expect_line(self, expected: str, step: str) -> None: if line != expected: raise SmokeFailure(f"{step}: expected {expected!r}, got {line!r}") + def expect_error(self, code: str, step: str) -> None: + line = self.read_line(LINE_TIMEOUT) + if line is None or not line.startswith(f"error {code} "): + raise SmokeFailure(f"{step}: expected 'error {code} ...', got {line!r}") + self.expect_exit(1, f"{step}: after error") + def expect_silence(self, step: str) -> None: line = self.read_line(SILENCE_TIMEOUT) if line is not None: @@ -177,6 +185,31 @@ def run_capable(path: str, user: pwd.struct_passwd, keyboard: VirtualKeyboard) - helper.close() +def run_capable_remapped(path: str, user: pwd.struct_passwd, keyboard: VirtualKeyboard) -> None: + helper = Helper(path, user, "capslock") + try: + helper.expect_line(HANDSHAKE, "remapped: handshake") + helper.expect_line("0", "remapped: initial state") + keyboard.press(KEY_LEFTCTRL, 1) + keyboard.press(KEY_LEFTCTRL, 0) + helper.expect_silence("remapped: unwatched Ctrl down/up") + keyboard.press(KEY_CAPSLOCK, 1) + helper.expect_line("1", "remapped: Caps Lock down") + keyboard.press(KEY_CAPSLOCK, 0) + helper.expect_line("0", "remapped: Caps Lock up") + finally: + helper.close() + + +def run_disallowed_key(path: str, user: pwd.struct_passwd) -> None: + helper = Helper(path, user, "a") + try: + helper.expect_line(HANDSHAKE, "disallowed key: handshake") + helper.expect_error("usage", "disallowed key") + finally: + helper.close() + + def run_incapable(path: str, user: pwd.struct_passwd) -> None: helper = Helper(path, user) try: @@ -235,6 +268,8 @@ def main() -> int: try: capable, incapable = install_copies(helper_path, directory) run_capable(capable, user, keyboard) + run_capable_remapped(capable, user, keyboard) + run_disallowed_key(capable, user) run_incapable(incapable, user) finally: shutil.rmtree(directory) From 04f60b7243bdabf9ce205e1d970c54de3c19dfbf Mon Sep 17 00:00:00 2001 From: FichteFoll Date: Thu, 15 Oct 2026 01:12:30 +0200 Subject: [PATCH 20/21] Add the `keystate_ctrl_keys` setting Pass the keys that count as Ctrl to the helper, so that layouts which remap Ctrl, such as `ctrl:nocaps` or `ctrl:swapcaps`, keep the panel open while the remapped key is held. The probe records the keys along with the path, the poller runs exactly the probed command, and a change of either setting triggers a new probe. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 3 +- README.md | 16 +++- TabStack.sublime-settings | 6 ++ plugin/ctrl_release/linux/keystate_helper.py | 79 +++++++++++++------- sublime-package.json | 19 +++++ 5 files changed, 94 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index eb8a44d..041900f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,8 @@ - Ctrl-release detection lives in `plugin/ctrl_release/`. - Under Linux, Ctrl-release detection uses the `tabstack-keystate` helper (`plugin/ctrl_release/linux/keystate_helper.py`) when its probe succeeds. `plugin_loaded` probes it on the async thread, because running it can take up to 2 s; until the result arrives, Linux behaves as if the helper were missing, but without the setup dialog. - The probe records the helper path it ran with, the poller runs exactly that path, and a change of `keystate_helper_path` triggers a new probe. + The probe records the helper path and the `keystate_ctrl_keys` it ran with, the poller runs exactly that command, and a change of either setting triggers a new probe. + The helper only accepts the modifier names in its `allowed_keys` table as arguments, so that it cannot serve as a keylogger; keep it that way. A helper that fails during a session (spawn error, bad handshake, `error` line, early EOF) still commits that session, but triggers a new probe unless a newer one has superseded it. The helper reads evdev key state with `cap_dac_read_search`, is spawned once per session, and reports Ctrl state changes on stdout. - Without a working helper, Linux falls back to the X11 key state API (`plugin/ctrl_release/linux/x11.py`), but not on native Wayland (`is_native_wayland()` in `plugin/ctrl_release/linux/__init__.py`), because XWayland reports Ctrl as released while a Wayland client has focus; Sublime exposes no key-release API. diff --git a/README.md b/README.md index b19d305..df6d871 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,16 @@ which `Preferences: TabStack Settings` opens from the Command Palette: - `keystate_helper_path`: the path of the installed helper, `/usr/local/libexec/tabstack-keystate` by default. +- `keystate_ctrl_keys`: + the keys the helper treats as Ctrl, + `["leftctrl", "rightctrl"]` by default. + The helper reads physical keys + and does not see the keyboard layout, + so set this if your layout remaps Ctrl, + for example `["leftctrl", "rightctrl", "capslock"]` for the XKB option `ctrl:nocaps`, + or `["capslock", "rightctrl"]` for `ctrl:swapcaps`. + Allowed are `leftctrl`, `rightctrl`, `capslock`, + `leftalt`, `rightalt`, `leftmeta`, `rightmeta` and `compose`. - `show_wayland_setup_dialog`: set to `false` to suppress the dialog that offers the setup on native Wayland. @@ -122,13 +132,15 @@ which `Preferences: TabStack Settings` opens from the Command Palette: which allows reading any file regardless of permissions. - The helper sets `no_new_privs` at startup, opens every `/dev/input/event*` device read-only - and keeps only those that advertise a Ctrl key. + and keeps only those that advertise one of the watched keys. It then drops all capabilities immediately, before it reports any state. Review the source to confirm this. - Anyone who can execute the helper can run it, so any local user can read the Ctrl state of your keyboards through it. - It reports nothing but whether a Ctrl key is held. + It reports nothing but whether one of the watched keys is held, + and it only accepts the modifier keys listed under `keystate_ctrl_keys`, + so it cannot be used to log typed characters. - Installing it as `root:root` with mode `0755` in `/usr/local/libexec` prevents the user's own processes from modifying it. Replacing the binary drops the capability, diff --git a/TabStack.sublime-settings b/TabStack.sublime-settings index e42f314..a60582e 100644 --- a/TabStack.sublime-settings +++ b/TabStack.sublime-settings @@ -3,6 +3,12 @@ // It is used whenever it works, and it is required under native Wayland. "keystate_helper_path": "/usr/local/libexec/tabstack-keystate", + // The keys the helper treats as Ctrl, for keyboard layouts that remap Ctrl, + // e.g. `["leftctrl", "rightctrl", "capslock"]` for the XKB option `ctrl:nocaps`. + // Allowed: "leftctrl", "rightctrl", "capslock", "leftalt", "rightalt", + // "leftmeta", "rightmeta", "compose". + "keystate_ctrl_keys": ["leftctrl", "rightctrl"], + // Offer the helper's setup instructions on the first Ctrl+Tab per plugin load // when Ctrl-release detection is unavailable under native Wayland. "show_wayland_setup_dialog": true, diff --git a/plugin/ctrl_release/linux/keystate_helper.py b/plugin/ctrl_release/linux/keystate_helper.py index 07c25d0..dc851b4 100644 --- a/plugin/ctrl_release/linux/keystate_helper.py +++ b/plugin/ctrl_release/linux/keystate_helper.py @@ -6,16 +6,17 @@ from collections.abc import Callable from dataclasses import dataclass from enum import Enum -from typing import IO, final +from typing import IO, cast, final import sublime HANDSHAKE = "tabstack-keystate 2" DEFAULT_HELPER_PATH = "/usr/local/libexec/tabstack-keystate" +DEFAULT_CTRL_KEYS = ("leftctrl", "rightctrl") _HANDSHAKE_PREFIX = "tabstack-keystate " _SETTINGS_FILE = "TabStack.sublime-settings" -_SETTINGS_WATCH_KEY = "tab_stack_keystate_helper_path" +_SETTINGS_WATCH_KEY = "tab_stack_keystate_helper" _PROBE_TIMEOUT_S = 2 _EXIT_TIMEOUT_S = 1 @@ -30,17 +31,22 @@ class HelperStatus(Enum): @dataclass(frozen=True) class HelperProbe: - # The probed path; the poller runs exactly this one, so both stay consistent - # until the next probe even if `keystate_helper_path` changes in between. + # The probed path and keys; the poller runs exactly these, so both stay consistent + # until the next probe even if the settings change in between. path: str + ctrl_keys: tuple[str, ...] status: HelperStatus detail: str + @property + def command(self) -> list[str]: + return [self.path, *self.ctrl_keys] + _probe: HelperProbe | None = None _probe_generation = 0 -# The path of the probe that is running or finished, as `_probe` may still be `None`. -_probed_path: str | None = None +# The path and keys of the probe that is running or finished, as `_probe` may still be `None`. +_probed_config: tuple[str, tuple[str, ...]] | None = None # Called on the main thread once the probe result no longer holds. _on_invalidated: Callable[[], None] | None = None @@ -52,48 +58,70 @@ def helper_path() -> str: return os.path.expanduser(path) -def probe_helper(path: str) -> HelperProbe: +def helper_ctrl_keys() -> tuple[str, ...]: + keys: object = sublime.load_settings(_SETTINGS_FILE).get("keystate_ctrl_keys") + if not isinstance(keys, list) or not keys: + return DEFAULT_CTRL_KEYS + # The helper validates the names against its allowlist and reports a usage error. + return tuple(str(key) for key in cast(list[object], keys)) + + +def _helper_config() -> tuple[str, tuple[str, ...]]: + return helper_path(), helper_ctrl_keys() + + +def probe_helper(path: str, ctrl_keys: tuple[str, ...]) -> HelperProbe: try: # Empty stdin makes the helper exit right after reporting the initial state. - result = subprocess.run([path], input=b"", capture_output=True, timeout=_PROBE_TIMEOUT_S) + result = subprocess.run( + [path, *ctrl_keys], input=b"", capture_output=True, timeout=_PROBE_TIMEOUT_S + ) except FileNotFoundError: - return HelperProbe(path, HelperStatus.MISSING, f"{path} does not exist.") + return HelperProbe(path, ctrl_keys, HelperStatus.MISSING, f"{path} does not exist.") except subprocess.TimeoutExpired: return HelperProbe( - path, HelperStatus.FAILED, f"{path} did not exit within {_PROBE_TIMEOUT_S} seconds." + path, + ctrl_keys, + HelperStatus.FAILED, + f"{path} did not exit within {_PROBE_TIMEOUT_S} seconds.", ) except OSError as exc: - return HelperProbe(path, HelperStatus.FAILED, f"{path} could not be run: {exc}") + return HelperProbe(path, ctrl_keys, HelperStatus.FAILED, f"{path} could not be run: {exc}") lines = result.stdout.decode("ascii", errors="replace").splitlines() - return _parse_probe_output(path, lines) + return _parse_probe_output(path, ctrl_keys, lines) -def _parse_probe_output(path: str, lines: list[str]) -> HelperProbe: +def _parse_probe_output(path: str, ctrl_keys: tuple[str, ...], lines: list[str]) -> HelperProbe: handshake = lines[0] if lines else "" if handshake != HANDSHAKE: if handshake.startswith(_HANDSHAKE_PREFIX): return HelperProbe( path, + ctrl_keys, HelperStatus.OUTDATED, f"{path} speaks protocol {handshake!r}, but {HANDSHAKE!r} is required.", ) return HelperProbe( - path, HelperStatus.FAILED, f"{path} did not identify itself as `tabstack-keystate`." + path, + ctrl_keys, + HelperStatus.FAILED, + f"{path} did not identify itself as `tabstack-keystate`.", ) report = lines[1] if len(lines) > 1 else "" if report in ("0", "1"): - return HelperProbe(path, HelperStatus.OK, f"{path} reports the Ctrl key state.") + return HelperProbe(path, ctrl_keys, HelperStatus.OK, f"{path} reports the Ctrl key state.") if report.split(" ")[:2] == ["error", "no-access"]: return HelperProbe( path, + ctrl_keys, HelperStatus.NO_ACCESS, f"{path} cannot open the keyboard devices: {report}", ) if report.startswith("error "): - return HelperProbe(path, HelperStatus.FAILED, f"{path} reported: {report}") - return HelperProbe(path, HelperStatus.FAILED, f"{path} reported no Ctrl key state.") + return HelperProbe(path, ctrl_keys, HelperStatus.FAILED, f"{path} reported: {report}") + return HelperProbe(path, ctrl_keys, HelperStatus.FAILED, f"{path} reported no Ctrl key state.") def get_helper_probe() -> HelperProbe | None: @@ -106,16 +134,15 @@ def start_helper_probe(on_done: Callable[[HelperProbe], None]) -> None: Running the helper can take up to `_PROBE_TIMEOUT_S`, which must not block the UI. """ - global _probe, _probe_generation, _probed_path + global _probe, _probe_generation, _probed_config _probe = None _probe_generation += 1 generation = _probe_generation # Resolved here because the Sublime API is only safe on the main thread. - path = helper_path() - _probed_path = path + path, ctrl_keys = _probed_config = _helper_config() def probe() -> None: - result = probe_helper(path) + result = probe_helper(path, ctrl_keys) sublime.set_timeout(lambda: _finish_probe(generation, result, on_done)) sublime.set_timeout_async(probe) @@ -133,23 +160,23 @@ def _finish_probe( def reset_helper_probe() -> None: - global _probe, _probe_generation, _probed_path + global _probe, _probe_generation, _probed_config _probe = None _probe_generation += 1 - _probed_path = None + _probed_config = None def watch_helper(on_invalidated: Callable[[], None]) -> None: """Call `on_invalidated` whenever the probe result no longer holds. - That is when `keystate_helper_path` no longer matches the probed path, + That is when `keystate_helper_path` or `keystate_ctrl_keys` no longer match the probe, or when the probed helper fails during a session. """ global _on_invalidated _on_invalidated = on_invalidated def on_settings_changed() -> None: - if helper_path() != _probed_path: + if _helper_config() != _probed_config: on_invalidated() sublime.load_settings(_SETTINGS_FILE).add_on_change(_SETTINGS_WATCH_KEY, on_settings_changed) @@ -212,7 +239,7 @@ def _spawn(self) -> subprocess.Popen[str] | None: return None try: self._process = subprocess.Popen( - [self._path], + self._probe.command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, diff --git a/sublime-package.json b/sublime-package.json index 857c5b6..69c7cad 100644 --- a/sublime-package.json +++ b/sublime-package.json @@ -12,6 +12,25 @@ "default": "/usr/local/libexec/tabstack-keystate", "markdownDescription": "Path to the `tabstack-keystate` helper that detects Ctrl releases on Linux.\n\nIt is used whenever it works, and it is required under native Wayland." }, + "keystate_ctrl_keys": { + "type": "array", + "items": { + "enum": [ + "leftctrl", + "rightctrl", + "capslock", + "leftalt", + "rightalt", + "leftmeta", + "rightmeta", + "compose" + ] + }, + "minItems": 1, + "uniqueItems": true, + "default": ["leftctrl", "rightctrl"], + "markdownDescription": "The keys the `tabstack-keystate` helper treats as Ctrl, for keyboard layouts that remap Ctrl, e.g. `[\"leftctrl\", \"rightctrl\", \"capslock\"]` for the XKB option `ctrl:nocaps`.\n\nOnly modifier keys are allowed, so that the helper cannot be used to log keystrokes." + }, "show_wayland_setup_dialog": { "type": "boolean", "default": true, From f638011d73753bbc9cb29f4a2677365e0a3898d1 Mon Sep 17 00:00:00 2001 From: FichteFoll Date: Thu, 15 Oct 2026 01:28:46 +0200 Subject: [PATCH 21/21] Report a broken helper in the console Only the setup dialog surfaced a helper that is installed but does not work, for example an outdated one after a package update, and only on native Wayland. Under X11 the fallback took over silently. Print the probe result for any failure other than a missing helper, which is the normal state without a setup. Co-Authored-By: Claude Opus 5.5 --- plugin/ctrl_release/linux/keystate_helper.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/plugin/ctrl_release/linux/keystate_helper.py b/plugin/ctrl_release/linux/keystate_helper.py index dc851b4..2930582 100644 --- a/plugin/ctrl_release/linux/keystate_helper.py +++ b/plugin/ctrl_release/linux/keystate_helper.py @@ -156,9 +156,21 @@ def _finish_probe( if generation != _probe_generation: return _probe = result + _report_broken_helper(result) on_done(result) +def _report_broken_helper(result: HelperProbe) -> None: + # A missing helper is the normal state without a setup; any other failure + # means that a set-up helper stopped working, e.g. after a package update. + if result.status in (HelperStatus.OK, HelperStatus.MISSING): + return + print( + f"TabStack: the tabstack-keystate helper does not work ({result.status.value}):" + + f" {result.detail} Run `TabStack: Set Up Ctrl Detection for Wayland` for instructions." + ) + + def reset_helper_probe() -> None: global _probe, _probe_generation, _probed_config _probe = None