From 0fcf91e9aed44c4c129fb9de01cb85491aeb37b6 Mon Sep 17 00:00:00 2001 From: FichteFoll Date: Fri, 10 Jul 2026 01:05:54 +0200 Subject: [PATCH 1/2] Delay showing the popup for quick tap mode If only one ctrl+tab is intended and ctrl is quickly released, showing the popup for that brief period of time can be annoying. Instead, do what FireFox does and delay opening of the preview panel for a bit. If the key was released before the timeout expires, do not show the panel at all. Additionally, when ctrl+tab is pressed a second time we will force-open the panel because at this point the user clearly wants to do more than just quickly go to the previous tab. The only downside of this method is that now there is some "flicker" when the popup opens after the tab has already been focused. --- AGENTS.md | 3 ++- Default.sublime-keymap | 12 +++++++++- plugin/commands.py | 28 +++++++++++++++++++---- plugin/ctrl_release/linux.py | 3 +++ plugin/ctrl_release/macos.py | 3 +++ plugin/ctrl_release/windows.py | 3 +++ plugin/listener.py | 7 ++++-- plugin/session.py | 42 +++++++++++++++++++++++++++++----- plugin/state.py | 5 ++++ 9 files changed, 91 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1bb2dd7..d75ea71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,10 +16,11 @@ - 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. -- `tab_stack_open` opens the quick panel, starts a Ctrl-release poller, and commits when Ctrl is released. +- `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. - `tab_stack.session_active` is the quick-panel session context key. +- `tab_stack.session_pending` is the arming context before the panel appears. - Boundary-only contexts are `tab_stack.quick_panel_at_top` and `tab_stack.quick_panel_at_bottom`. - When `tab_stack.session_active` is `false`, `ctrl+tab` starts `tab_stack_open`. `ctrl+shift+tab` is bound as a no-op to prevent accidental presses. diff --git a/Default.sublime-keymap b/Default.sublime-keymap index fae951e..e5342c0 100644 --- a/Default.sublime-keymap +++ b/Default.sublime-keymap @@ -3,14 +3,24 @@ { "keys": ["ctrl+tab"], "command": "tab_stack_open", - "args": {"forward": true}, + "args": {"forward": true, "selected_index": 1}, "context": [ + {"key": "tab_stack.session_active", "operator": "equal", "operand": false}, + {"key": "tab_stack.session_pending", "operator": "equal", "operand": false}, // This context cannot be evaluated on non-view sheets. // Window settings cannot be queried via `settings.*` either. // https://github.com/sublimehq/sublime_text/issues/5377 // {"key": "tab_stack.ctrl_release_available", "operator": "equal", "operand": true} ] }, + { + "keys": ["ctrl+tab"], + "command": "tab_stack_open", + "args": {"forward": true, "selected_index": 2}, + "context": [ + {"key": "tab_stack.session_pending", "operator": "equal", "operand": true} + ] + }, // We just override this binding to prevent accidental presses of it. { "keys": ["ctrl+shift+tab"], diff --git a/plugin/commands.py b/plugin/commands.py index 2dc68ec..1766df2 100644 --- a/plugin/commands.py +++ b/plugin/commands.py @@ -4,12 +4,18 @@ from .ctrl_release import is_available from .history import current_group_selection_state, sync_selection_history from .mru import collect_entries -from .session import cancel_session, reopen_panel_at_index, show_panel +from .session import ( + cancel_session, + preview_entry, + reopen_panel_at_index, + schedule_panel, + show_panel, +) from .state import get_state class TabStackOpenCommand(sublime_plugin.WindowCommand): - def run(self, *, forward=True) -> None: + def run(self, *, forward=True, selected_index: int = 1) -> None: window = self.window if window is None: return @@ -21,6 +27,16 @@ def run(self, *, forward=True) -> None: return state = get_state(window) + + if state.session_pending: + state.session_pending = False + state.session_pending_token += 1 + state.session_active = True + state.session_selected_index = selected_index + preview_entry(window, state) + show_panel(window, state) + return + active_group = window.active_group() state.session_origin_selection = current_group_selection_state(window, active_group) history = sync_selection_history(window, prune_removed_sheets=True) @@ -28,11 +44,13 @@ def run(self, *, forward=True) -> None: if not entries: return - state.session_active = True + state.session_pending = True + state.session_pending_token += 1 state.session_group = active_group state.session_entries = entries - state.session_selected_index = 1 if len(entries) > 1 else 0 - show_panel(window, state) + state.session_selected_index = selected_index + preview_entry(window, state) + schedule_panel(window, state, token=state.session_pending_token) class TabStackCancelCommand(sublime_plugin.WindowCommand): diff --git a/plugin/ctrl_release/linux.py b/plugin/ctrl_release/linux.py index b748069..d574475 100644 --- a/plugin/ctrl_release/linux.py +++ b/plugin/ctrl_release/linux.py @@ -73,6 +73,9 @@ def _ctrl_down(self) -> bool: state.keycode_right, ) + def is_ctrl_down(self) -> bool: + return self._ctrl_down() + def _open_x11_state(self) -> Optional[_X11State]: display_name = os.environ.get("DISPLAY") if not display_name: diff --git a/plugin/ctrl_release/macos.py b/plugin/ctrl_release/macos.py index ffd4d38..b1c1a56 100644 --- a/plugin/ctrl_release/macos.py +++ b/plugin/ctrl_release/macos.py @@ -59,6 +59,9 @@ def _ctrl_down(self) -> bool: ) ) + def is_ctrl_down(self) -> bool: + return self._ctrl_down() + def _open_core_graphics(self) -> Optional[Any]: return _get_core_graphics() diff --git a/plugin/ctrl_release/windows.py b/plugin/ctrl_release/windows.py index 8ebb4aa..30eced8 100644 --- a/plugin/ctrl_release/windows.py +++ b/plugin/ctrl_release/windows.py @@ -44,6 +44,9 @@ def _fire_release(self) -> None: def _ctrl_down(self) -> bool: return bool(self._user32.GetAsyncKeyState(_VK_CONTROL) & 0x8000) + def is_ctrl_down(self) -> bool: + return self._ctrl_down() + def _open_user32(self) -> Optional[Any]: return _get_user32() diff --git a/plugin/listener.py b/plugin/listener.py index 8b3558e..c85b116 100644 --- a/plugin/listener.py +++ b/plugin/listener.py @@ -20,10 +20,12 @@ def _ensure_selection_poller(window, state) -> None: def _session_boundary_value(window, key: str) -> bool: state = get_state(window) + if key == "tab_stack.session_pending": + return state.session_pending if not state.session_active: return False if key == "tab_stack.session_active": - return True + return state.session_active entries = state.session_entries if not entries or len(entries) < 2: @@ -47,7 +49,7 @@ def on_activated(self, view) -> None: state.overlay_depth += 1 return - if state.session_active or state.overlay_active: + if state.session_active or state.session_pending or state.overlay_active: return _ensure_selection_poller(window, state) @@ -69,6 +71,7 @@ def on_query_context(self, view, key, operator, operand, match_all): value = is_available() elif key in { "tab_stack.session_active", + "tab_stack.session_pending", "tab_stack.quick_panel_at_top", "tab_stack.quick_panel_at_bottom", }: diff --git a/plugin/session.py b/plugin/session.py index b8585c7..2452bac 100644 --- a/plugin/session.py +++ b/plugin/session.py @@ -5,6 +5,22 @@ from .sheets import apply_group_selection from .state import TabStackWindowState +SHOW_PANEL_DELAY_MS = 150 + + +def _ensure_ctrl_release_poller(window, state) -> None: + if state.ctrl_release_poller: + return + + def on_release() -> None: + if state.session_active: + _commit_session(window, state) + elif state.session_pending: + state.clear_session() + + state.ctrl_release_poller = CtrlReleasePoller(on_release, 25) + state.ctrl_release_poller.start() + def show_panel(window, state) -> None: if not state.session_active or not state.session_entries: @@ -33,12 +49,23 @@ def on_highlight(index: int) -> None: placeholder="Release ctrl to close; Hit ctrl+escape to abort", ) - if not state.ctrl_release_poller: - state.ctrl_release_poller = CtrlReleasePoller( - lambda: _commit_session(window, state), - 25, - ) - state.ctrl_release_poller.start() + _ensure_ctrl_release_poller(window, state) + + +def schedule_panel(window, state, *, token: int) -> None: + _ensure_ctrl_release_poller(window, state) + + 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() + return + state.session_active = True + state.session_pending = False + show_panel(window, state) + + sublime.set_timeout(open_if_still_pending, SHOW_PANEL_DELAY_MS) def preview_entry(window, state: TabStackWindowState) -> None: @@ -71,6 +98,9 @@ def _commit_session(window, state) -> None: def cancel_session(window, state) -> None: if not state.session_active: + if not state.session_pending: + return + state.clear_session() return origin = state.session_origin_selection diff --git a/plugin/state.py b/plugin/state.py index adea741..0de50cb 100644 --- a/plugin/state.py +++ b/plugin/state.py @@ -22,6 +22,9 @@ class SheetSelectionHistory(TypedDict): @dataclass class TabStackWindowState: session_active: bool = False + 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_selected_index: int = 0 session_group: int = 0 @@ -43,11 +46,13 @@ def overlay_active(self) -> bool: def clear_session(self) -> None: self.session_active = False + self.session_pending = False self.session_origin_selection = None self.session_selected_index = 0 self.session_group = 0 self.session_entries = None self.session_panel_reopening = False + self.session_pending_token += 1 if self.ctrl_release_poller is not None: self.ctrl_release_poller.stop() self.ctrl_release_poller = None From cfd78d5f4d062e9036a719ce090c5eb2df91bbd9 Mon Sep 17 00:00:00 2001 From: FichteFoll Date: Fri, 10 Jul 2026 01:11:17 +0200 Subject: [PATCH 2/2] Focus the tab together with opening the panel This removes the flicker. It may make the tab switching feel less snappy because we are deliberately waiting a bit but both the tab and the hold version feel much better this way. --- plugin/commands.py | 10 +--------- plugin/session.py | 14 +++++++++++++- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/plugin/commands.py b/plugin/commands.py index 1766df2..0c441ad 100644 --- a/plugin/commands.py +++ b/plugin/commands.py @@ -4,13 +4,7 @@ from .ctrl_release import is_available from .history import current_group_selection_state, sync_selection_history from .mru import collect_entries -from .session import ( - cancel_session, - preview_entry, - reopen_panel_at_index, - schedule_panel, - show_panel, -) +from .session import cancel_session, reopen_panel_at_index, schedule_panel, show_panel from .state import get_state @@ -33,7 +27,6 @@ def run(self, *, forward=True, selected_index: int = 1) -> None: state.session_pending_token += 1 state.session_active = True state.session_selected_index = selected_index - preview_entry(window, state) show_panel(window, state) return @@ -49,7 +42,6 @@ def run(self, *, forward=True, selected_index: int = 1) -> None: state.session_group = active_group state.session_entries = entries state.session_selected_index = selected_index - preview_entry(window, state) schedule_panel(window, state, token=state.session_pending_token) diff --git a/plugin/session.py b/plugin/session.py index 2452bac..acc8790 100644 --- a/plugin/session.py +++ b/plugin/session.py @@ -16,7 +16,7 @@ def on_release() -> None: if state.session_active: _commit_session(window, state) elif state.session_pending: - state.clear_session() + _commit_pending_session(window, state) state.ctrl_release_poller = CtrlReleasePoller(on_release, 25) state.ctrl_release_poller.start() @@ -77,6 +77,18 @@ def preview_entry(window, state: TabStackWindowState) -> None: apply_group_selection(window, entry.selection, state.session_group) +def _commit_pending_session(window, state) -> None: + entries = state.session_entries + if not entries: + state.clear_session() + return + + index = clamp_index(state.session_selected_index, len(entries)) + selection = entries[index].selection + apply_group_selection(window, selection, state.session_group) + state.clear_session() + + def _commit_session(window, state) -> None: if not state.session_active: return