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..0c441ad 100644 --- a/plugin/commands.py +++ b/plugin/commands.py @@ -4,12 +4,12 @@ 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, 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 +21,15 @@ 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 + 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 +37,12 @@ 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 + 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..acc8790 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: + _commit_pending_session(window, state) + + 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: @@ -50,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 @@ -71,6 +110,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