Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 11 additions & 1 deletion Default.sublime-keymap
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
20 changes: 15 additions & 5 deletions plugin/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,18 +21,28 @@ 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)
entries = collect_entries(window, history)
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):
Expand Down
3 changes: 3 additions & 0 deletions plugin/ctrl_release/linux.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions plugin/ctrl_release/macos.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
3 changes: 3 additions & 0 deletions plugin/ctrl_release/windows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
7 changes: 5 additions & 2 deletions plugin/listener.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand All @@ -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",
}:
Expand Down
54 changes: 48 additions & 6 deletions plugin/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions plugin/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading