diff --git a/AGENTS.md b/AGENTS.md index 76c988e..b58a2ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,29 +1,29 @@ # TabStack Agent Notes +## Entry Points + - This is a Sublime Text package, not a standalone app. - Runtime target is Sublime Text's Python 3.14 plugin host. -- Root `main.py` is the only plugin entry file. -- `main.py` clears cached `plugin.*` modules before star-importing `plugin`, so keep exposed plugin symbols in `plugin/__init__.py`. -- Real implementation lives under `plugin/`. -- Modifier-release detection lives in `plugin/ctrl_release/`. -- Per-window state is in-memory only, keyed by `window.id()` in `plugin/state.py`. -- `TabStackListener.on_activated` updates MRU order, but ignores activation during an active quick-panel session. -- `TabStackListener.on_close` removes closed views from all window stacks. -- Key bindings are shared among all platforms and live in `Default.sublime-keymap`. -- The quick-panel session context key is `setting.tab_stack_quick_panel`. -- When that setting is `false`, `ctrl+tab`, `ctrl+shift+tab`, `ctrl+up`, and `ctrl+down` start `show_tab_stack`. -- When that setting is `true`, those keys call `tab_stack_move`, and `escape` or `ctrl+escape` call `tab_stack_cancel`. -- `plugin/ctrl_release/linux.py` uses X11/XWayland polling via `ctypes`; native Wayland without XWayland is unsupported. -- `plugin/ctrl_release/windows.py` uses `GetAsyncKeyState` polling via `ctypes`. -- `plugin/ctrl_release/macos.py` uses CoreGraphics polling via `ctypes`. -- `show_tab_stack` starts a Ctrl-release poller and commits when Ctrl is released. -- Tab captions come from `plugin/captions.py`; views that are widgets, panels, or non-tab elements are excluded from the MRU list. -- Keep the MRU list per window and preserve the current preview behavior: preview must not reorder the stack. +- `main.py` is the only entry file; it clears cached `plugin.*` modules before star-importing `plugin`. +- Keep exported plugin symbols in `plugin/__init__.py`; implementation lives under `plugin/`. + +## Behavior + +- State is in-memory per window, keyed by `window.id()` in `plugin/state.py`. +- MRU order is per window. +- `TabStackListener.on_activated` updates history unless a quick-panel session is active. +- `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/`. +- Linux needs X11 or XWayland for Ctrl-release polling; native Wayland is not supported. +- `show_tab_stack` opens the quick panel, starts a Ctrl-release poller, and commits when Ctrl is released. +- The quick-panel context key is `tab_stack.quick_panel`. +- When `tab_stack.quick_panel` is `false`, `ctrl+tab` starts `show_tab_stack`. `ctrl+shift+tab` is bound as a no-op to prevent accidental presses. + start `show_tab_stack`. +- When `tab_stack.quick_panel` is `true`, `ctrl+tab`, `ctrl+shift+tab`, `ctrl+up`, and `ctrl+down` call `move`, and `ctrl+escape` calls `tab_stack_cancel`. ## Verification -- Run dev tools with `uv run`, for example `uv run ruff check .` and `uv run ruff format .`. -- `pyproject.toml` only defines `ruff` settings. -- Use `ruff check .` for linting via `uv run`. -- Use `ruff format .` for formatting via `uv run`. -- No repo-local test runner or task file is currently present. +- Use `uv run ruff check .` for linting. +- Use `uv run ruff format .` for formatting. +- `pyproject.toml` only defines Ruff settings; there is no repo-local test runner. diff --git a/Default.sublime-keymap b/Default.sublime-keymap index ead68a5..34129e4 100644 --- a/Default.sublime-keymap +++ b/Default.sublime-keymap @@ -2,17 +2,21 @@ { "keys": ["ctrl+tab"], "command": "show_tab_stack", + "args": {"forward": true}, "context": [ - {"key": "tab_stack.quick_panel", "operator": "equal", "operand": false}, - {"key": "tab_stack.ctrl_release_available", "operator": "equal", "operand": true} + // 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} ] }, + // We just override this binding to prevent accidental presses of it. { "keys": ["ctrl+shift+tab"], "command": "show_tab_stack", + "args": {"forward": false}, "context": [ - {"key": "tab_stack.quick_panel", "operator": "equal", "operand": false}, - {"key": "tab_stack.ctrl_release_available", "operator": "equal", "operand": true} + // {"key": "tab_stack.ctrl_release_available", "operator": "equal", "operand": true} ] }, { diff --git a/README.md b/README.md index b9b698f..d78b073 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ It keeps tab order per window and previews tabs in the quick panel before committing the switch. It also preserves tab ordering across sessions -by storing each view's last activation time. +and supports multiple selections. When closing a tab, -TabStack focuses the most recently used remaining tab. +TabStack restores the previous selection. [Video Showcase][] @@ -27,6 +27,7 @@ git clone https://github.com/FichteFoll/TabStack To update later, run `git pull` inside the `TabStack` directory and restart Sublime Text. + ## Usage The key bindings are: @@ -44,6 +45,7 @@ Use the same keys to move through the list while it is open. Release `ctrl` to commit the selected tab. Press `ctrl+escape` to cancel. + ## Platform Support TabStack supports **Linux, Windows, and macOS**. diff --git a/plugin/__init__.py b/plugin/__init__.py index 2f7cbd3..f4adc99 100644 --- a/plugin/__init__.py +++ b/plugin/__init__.py @@ -1,13 +1,16 @@ from .commands import * # noqa: F401,F403 from .ctrl_release import plugin_unloaded as _ctrl_release_plugin_unloaded from .listener import * # noqa: F401,F403 -from .state import iter_states +from .state import iter_states as _iter_states def plugin_unloaded() -> None: - for state in iter_states(): - if state.poller is not None: - state.poller.stop() - state.poller = None + for state in _iter_states(): + if state.selection_poller is not None: + state.selection_poller.stop() + state.selection_poller = None + if state.ctrl_release_poller is not None: + state.ctrl_release_poller.stop() + state.ctrl_release_poller = None _ctrl_release_plugin_unloaded() diff --git a/plugin/captions.py b/plugin/captions.py index f34f669..2370d90 100644 --- a/plugin/captions.py +++ b/plugin/captions.py @@ -3,13 +3,20 @@ from pathlib import Path -def caption_for_view(view, window) -> list[str]: - title = view.name() or (Path(view.file_name()).name if view.file_name() else "Untitled") - path = "" - file_name = view.file_name() +def caption_for_sheet_identities(identities, window) -> list[str]: + titles = [identity["name"] for identity in identities] + paths = [ + _relative_path(identity["path"], window) for identity in identities if identity["path"] + ] + + return [" | ".join(titles), " | ".join(paths)] + + +def _title_from_sheet(sheet) -> str: + file_name = sheet.file_name() if file_name: - path = _relative_path(file_name, window) - return [title, path] + return Path(file_name).name + return "Untitled" def _relative_path(file_name: str, window) -> str: diff --git a/plugin/commands.py b/plugin/commands.py index 7ff7b6a..1184962 100644 --- a/plugin/commands.py +++ b/plugin/commands.py @@ -1,50 +1,37 @@ from __future__ import annotations -from dataclasses import dataclass - -from ._compat import sublime, sublime_plugin -from .captions import caption_for_view -from .ctrl_release import CtrlReleasePoller, is_available +from ._compat import sublime_plugin +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, show_panel from .state import get_state -@dataclass(slots=True) -class _Entry: - view_id: int - caption: list[str] - - class ShowTabStackCommand(sublime_plugin.WindowCommand): - def run(self) -> None: + def run(self, *, forward=True) -> None: window = self.window if window is None: return - - if not is_available(): + elif not is_available(): + window.run_command("next_view_in_stack" if forward else "prev_view_in_stack") + return + elif not forward: + # This is a fake binding. return state = get_state(window) - entries = _collect_entries(window, state) + 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_origin_view_id = _active_view_id(window) - state.session_selected_index = _initial_index(state, entries) - _show_panel(window, state, entries) - - state.poller = CtrlReleasePoller(lambda: _commit_on_release(window, state), 25) - state.poller.start() - - -class TabStackCommitCommand(sublime_plugin.WindowCommand): - def run(self) -> None: - window = self.window - if window is None: - return - - state = get_state(window) - _commit_session(window, state) + state.session_group = active_group + state.session_entries = entries + show_panel(window, state) class TabStackCancelCommand(sublime_plugin.WindowCommand): @@ -54,176 +41,4 @@ def run(self) -> None: return state = get_state(window) - _cancel_session(window, state) - - -def _collect_entries(window, state) -> list[_Entry]: - active_group_ids = {view.id() for view in window.views_in_group(window.active_group())} - entries: list[_Entry] = [] - pruned_mru_ids: list[int] = [] - - hydrate_mru_state(state, window) - - for view_id in state.mru_view_ids: - view = _find_view_by_id(window, view_id) - if view is None: - continue - pruned_mru_ids.append(view_id) - if view_id not in active_group_ids: - continue - entries.append(_Entry(view_id=view_id, caption=caption_for_view(view, window))) - - state.mru_view_ids = pruned_mru_ids - return entries - - -def hydrate_mru_state(state, window) -> None: - if state.mru_initialized: - return - - state.mru_view_ids = _initial_view_ids(window.views()) - state.mru_initialized = True - - -def _initial_view_ids(views) -> list[int]: - tab_views = list(enumerate(views)) - tab_views.sort(key=lambda item: _activation_sort_key(item[1], item[0])) - return [view.id() for _, view in tab_views] - - -def _activation_sort_key(view, fallback_index: int) -> tuple[bool, float, int]: - value = view.settings().get("tab_stack.last_activated") - if value is None: - return True, 0.0, fallback_index - return False, -float(value), fallback_index - - -def _active_view_id(window) -> int | None: - view = window.active_view() - if view is None: - return None - return view.id() - - -def _initial_index(state, entries: list[_Entry]) -> int: - origin_id = state.session_origin_view_id - if origin_id is None: - return 0 - for entry in entries: - if entry.view_id == origin_id: - return 1 if len(entries) > 1 else 0 - return 0 - - -def _show_panel(window, state, entries: list[_Entry]) -> None: - items = [entry.caption for entry in entries] - - def on_select(index: int) -> None: - if index < 0: - _handle_panel_closed(window, state) - return - state.session_selected_index = index - _commit_session(window, state) - - def on_highlight(index: int) -> None: - if index < 0 or index >= len(entries): - return - state.session_selected_index = index - _preview_entry(window, state, entries[index]) - - window.show_quick_panel( - items, - on_select, - selected_index=state.session_selected_index, - on_highlight=on_highlight, - placeholder="Release ctrl to close; Hit ctrl+escape to abort", - ) - if entries: - _preview_entry(window, state, entries[state.session_selected_index]) - - -def _preview_entry(window, state, entry: _Entry) -> None: - if state.session_preview_view_id == entry.view_id: - return - - view = _find_view_by_id(window, entry.view_id) - if view is None: - return - - state.session_preview_view_id = entry.view_id - window.focus_view(view) - - -def _commit_on_release(window, state) -> None: - if not state.session_active: - return - _commit_session(window, state) - - -def _commit_session(window, state) -> None: - if not state.session_active: - return - - entries = _collect_entries(window, state) - if not entries: - _cancel_session(window, state) - return - - index = _clamp_index(state.session_selected_index, len(entries)) - view = _find_view_by_id(window, entries[index].view_id) - if view is None: - _cancel_session(window, state) - return - - _promote_view_id(state, view.id()) - _finish_session(window, state) - window.run_command("hide_overlay", {"cancel": True}) - window.focus_view(view) - - -def _cancel_session(window, state) -> None: - if not state.session_active: - return - - origin_id = state.session_origin_view_id - origin_view = _find_view_by_id(window, origin_id) if origin_id is not None else None - _finish_session(window, state) - window.run_command("hide_overlay", {"cancel": True}) - if origin_view is not None: - window.focus_view(origin_view) - - -def _finish_session(window, state) -> None: - state.session_tagged_view_ids.clear() - state.clear_session() - - -def _promote_view_id(state, view_id: int) -> None: - state.mru_view_ids = [item for item in state.mru_view_ids if item != view_id] - state.mru_view_ids.insert(0, view_id) - - -def _clamp_index(index: int, size: int) -> int: - if size <= 0: - return 0 - if index < 0: - return 0 - if index >= size: - return size - 1 - return index - - -def _find_view_by_id(window, view_id: int | None): - if view_id is None: - return None - view = sublime.View(view_id) - view_window = view.window() - if view_window is None: - return None - if view_window.id() != window.id(): - return None - return view - - -def _handle_panel_closed(window, state) -> None: - _cancel_session(window, state) + cancel_session(window, state) diff --git a/plugin/history.py b/plugin/history.py new file mode 100644 index 0000000..8288c00 --- /dev/null +++ b/plugin/history.py @@ -0,0 +1,286 @@ +from __future__ import annotations + +from typing import Any, cast + +from ._compat import sublime +from .sheets import SheetIdentity, find_sheet_by_identity_and_group, sheet_identity +from .state import ( + GroupSelectionState, + SheetSelectionHistory, + TabStackWindowState, + get_state, +) + +_HISTORY_KEY = "tab_stack.sheet_selection_history" + + +class SelectionHistoryPoller: + def __init__(self, state: TabStackWindowState, window, interval_ms: int = 500) -> None: + self._state = state + self._window = window + self._interval_ms = interval_ms + self._active = False + + def start(self) -> None: + if self._active: + return + self._active = True + self._schedule() + + def stop(self) -> None: + self._active = False + + def _schedule(self) -> None: + if not self._active: + return + sublime.set_timeout_async(self._poll, self._interval_ms) + + def _poll(self) -> None: + if not self._active: + return + if not self._state.session_active: + sync_selection_history(self._window) + self._schedule() + + +def current_group_selection_state(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 + ] + if not selected_identities: + return None + + active_sheet_index = None + active_sheet = window.active_sheet_in_group(group) + if active_sheet is not None and active_sheet.group() is not None: + active_identity = sheet_identity(active_sheet, window) + try: + active_sheet_index = selected_identities.index(active_identity) + except ValueError: + active_sheet_index = None + + return { + "active_sheet_index": active_sheet_index, + "selected_sheets": selected_identities, + } + + +def history_for_window(window) -> SheetSelectionHistory: + history = window.settings().get(_HISTORY_KEY) + if isinstance(history, dict) and is_current_history(history): + return cast(SheetSelectionHistory, history) + + history = default_history() + window.settings().set(_HISTORY_KEY, history) + return history + + +def store_history(window, history: SheetSelectionHistory) -> None: + window.settings().set(_HISTORY_KEY, history) + + +def default_history() -> SheetSelectionHistory: + return {"groups": {}} + + +def is_current_history(history: dict[str, Any]) -> bool: + groups = history.get("groups") + if not isinstance(groups, dict): + return False + + for group_state_stack in groups.values(): + if not isinstance(group_state_stack, list): + return False + for group_state in group_state_stack: + if not isinstance(group_state, dict): + return False + if set(group_state) != {"active_sheet_index", "selected_sheets"}: + return False + if group_state["active_sheet_index"] is not None and not isinstance( + group_state["active_sheet_index"], int + ): + return False + if not isinstance(group_state["selected_sheets"], list): + return False + + return True + + +def sync_selection_history( + window, + prune_removed_sheets: bool = False, +) -> SheetSelectionHistory: + history = history_for_window(window) + groups = history["groups"] + changed = False + + if prune_removed_sheets: + for group_key, group_state_stack in list(groups.items()): + try: + group = int(group_key) + except (TypeError, ValueError): + # Unexpected/corrupt key; drop it to avoid crashing. + del groups[group_key] + changed = True + continue + + updated_stack = prune_history_stack_for_live_sheets(window, group, group_state_stack) + if updated_stack != group_state_stack: + changed = True + if updated_stack: + groups[group_key] = updated_stack + else: + del groups[group_key] + + active_group = window.active_group() + current = current_group_selection_state(window, active_group) + if current is not None: + group_key = str(active_group) + existing_stack = groups.get(group_key, []) + updated_stack = prepend_group_state(existing_stack, current) + if updated_stack != existing_stack: + groups[group_key] = updated_stack + changed = True + + if changed: + store_history(window, history) + return history + + +def prepend_group_state( + existing_stack: list[GroupSelectionState], + current: GroupSelectionState, +) -> list[GroupSelectionState]: + return prune_history_stack([current, *existing_stack]) + + +def prune_history_stack_for_live_sheets( + window, + group: int, + group_state_stack: list[GroupSelectionState], +) -> list[GroupSelectionState]: + updated_stack: list[GroupSelectionState] = [] + + for group_state in group_state_stack: + pruned_group_state = prune_group_state_for_live_sheets(window, group, group_state) + if pruned_group_state is not None: + updated_stack.append(pruned_group_state) + + return updated_stack + + +def prune_group_state_for_live_sheets( + window, + group: int, + group_state: GroupSelectionState, +) -> GroupSelectionState | None: + selected_sheets = [ + identity + for identity in group_state["selected_sheets"] + if find_sheet_by_identity_and_group(window, identity, group) is not None + ] + if not selected_sheets: + return None + + return { + "active_sheet_index": prune_active_sheet_index( + group_state["active_sheet_index"], + group_state["selected_sheets"], + selected_sheets, + ), + "selected_sheets": selected_sheets, + } + + +def prune_active_sheet_index( + active_sheet_index: int | None, + original_selected_sheets: list[SheetIdentity], + pruned_selected_sheets: list[SheetIdentity], +) -> int | None: + if active_sheet_index is None: + return None + if active_sheet_index < 0 or active_sheet_index >= len(original_selected_sheets): + return None + + active_identity = original_selected_sheets[active_sheet_index] + try: + return pruned_selected_sheets.index(active_identity) + except ValueError: + return 0 if pruned_selected_sheets else None + + +def prune_group_state( + group_state: GroupSelectionState, + seen_identities: list[SheetIdentity], +) -> GroupSelectionState | None: + selected_sheets = [ + identity for identity in group_state["selected_sheets"] if identity not in seen_identities + ] + if not selected_sheets: + return None + + seen_identities.extend(selected_sheets) + return { + "active_sheet_index": prune_active_sheet_index( + group_state["active_sheet_index"], + group_state["selected_sheets"], + selected_sheets, + ), + "selected_sheets": selected_sheets, + } + + +def prune_history_stack( + group_state_stack: list[GroupSelectionState], + removed_identities: list[SheetIdentity] | None = None, +) -> list[GroupSelectionState]: + updated_stack: list[GroupSelectionState] = [] + seen_identities: list[SheetIdentity] = list(removed_identities or []) + for group_state in group_state_stack: + pruned_group_state = prune_group_state( + group_state, + seen_identities, + ) + if pruned_group_state is not None: + updated_stack.append(pruned_group_state) + return updated_stack + + +def prune_sheet_from_history(window, sheet) -> None: + state = get_state(window) + sheet_id = sheet_identity(sheet, window) + if state.session_origin_selection is not None: + pruned_selected_sheets = [ + identity + for identity in state.session_origin_selection["selected_sheets"] + if identity != sheet_id + ] + if pruned_selected_sheets: + state.session_origin_selection = { + "active_sheet_index": prune_active_sheet_index( + state.session_origin_selection["active_sheet_index"], + state.session_origin_selection["selected_sheets"], + pruned_selected_sheets, + ), + "selected_sheets": pruned_selected_sheets, + } + else: + state.session_origin_selection = None + + history = history_for_window(window) + groups = history["groups"] + changed = False + + for group, group_state_stack in list(groups.items()): + updated_stack = prune_history_stack(group_state_stack, [sheet_id]) + + if updated_stack != group_state_stack: + if updated_stack: + groups[group] = updated_stack + else: + del groups[group] + changed = True + + if changed: + store_history(window, history) diff --git a/plugin/listener.py b/plugin/listener.py index b021bbb..3ff2225 100644 --- a/plugin/listener.py +++ b/plugin/listener.py @@ -1,11 +1,21 @@ from __future__ import annotations -from time import time - from ._compat import sublime, sublime_plugin -from .commands import hydrate_mru_state from .ctrl_release import is_available -from .state import get_state, remove_view_from_all, remove_window_state +from .history import ( + SelectionHistoryPoller, + history_for_window, + prune_sheet_from_history, + sync_selection_history, +) +from .sheets import apply_group_selection +from .state import get_state, remove_window_state + + +def _ensure_selection_poller(window, state) -> None: + if state.selection_poller is None: + state.selection_poller = SelectionHistoryPoller(state, window) + state.selection_poller.start() class TabStackListener(sublime_plugin.EventListener): @@ -15,18 +25,11 @@ def on_activated(self, view) -> None: return state = get_state(window) - if not state.mru_initialized: - hydrate_mru_state(state, window) - return - - hydrate_mru_state(state, window) if state.session_active: return - view_id = view.id() - view.settings().set("tab_stack.last_activated", time()) - state.mru_view_ids = [item for item in state.mru_view_ids if item != view_id] - state.mru_view_ids.insert(0, view_id) + _ensure_selection_poller(window, state) + sync_selection_history(window) def on_query_context(self, view, key, operator, operand, match_all): if key == "tab_stack.ctrl_release_available": @@ -51,13 +54,17 @@ def on_query_context(self, view, key, operator, operand, match_all): return None def on_close(self, view) -> None: - remove_view_from_all(view.id()) window = view.window() + if window is not None: + sheet = view.sheet() + if sheet is not None: + prune_sheet_from_history(window, sheet) if window is not None and not window.views(): remove_window_state(window.id()) def on_pre_close(self, view) -> None: - if view.is_transient(): + sheet = view.sheet() + if sheet is None or sheet.is_transient() or sheet.is_semi_transient(): return window = view.window() @@ -65,15 +72,13 @@ def on_pre_close(self, view) -> None: return state = get_state(window) - if not state.mru_initialized: - hydrate_mru_state(state, window) - - closing_view_id = view.id() - open_views = window.views() - for view_id in state.mru_view_ids: - if view_id == closing_view_id: - continue - for next_view in open_views: - if next_view.id() == view_id: - window.focus_view(next_view) - return + if state.session_active: + return + + history = history_for_window(window) + group_state_stack = history["groups"].get(str(sheet.group())) + if group_state_stack is None or len(group_state_stack) < 2: + return + + previous_selection = group_state_stack[1] + apply_group_selection(window, previous_selection, sheet.group()) diff --git a/plugin/mru.py b/plugin/mru.py new file mode 100644 index 0000000..394565a --- /dev/null +++ b/plugin/mru.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from .captions import caption_for_sheet_identities +from .sheets import SheetIdentity, sheet_identity +from .state import GroupSelectionState, SheetSelectionHistory + + +@dataclass(slots=True) +class Entry: + selection: GroupSelectionState + caption: list[str] + + +def collect_entries(window, history: SheetSelectionHistory) -> list[Entry]: + entries: list[Entry] = [] + active_group = window.active_group() + seen_identities: set[tuple[str, int, str | None, str | None]] = set() + + group_state_stack = history["groups"].get(str(active_group)) + if group_state_stack: + for group_state in group_state_stack: + entries.append( + Entry( + selection=group_state, + caption=caption_for_sheet_identities(group_state["selected_sheets"], window), + ) + ) + seen_identities.update(_selection_identity_keys(group_state)) + + for sheet in _non_transient_sheets_in_group(window, active_group): + identity = _identity_key(sheet_identity(sheet, window)) + if identity in seen_identities: + continue + + entries.append(_entry_for_sheet(sheet, window)) + seen_identities.add(identity) + + return entries + + +def _entry_for_sheet(sheet, window) -> Entry: + identity = sheet_identity(sheet, window) + return Entry( + selection={ + "active_sheet_index": 0, + "selected_sheets": [identity], + }, + caption=caption_for_sheet_identities([identity], window), + ) + + +def _non_transient_sheets_in_group(window, group: int): + return [sheet for sheet in window.sheets_in_group(group) if not sheet.is_transient()] + + +def _selection_identity_keys( + selection: GroupSelectionState, +) -> set[tuple[str, int, str | None, str | None]]: + return {_identity_key(identity) for identity in selection["selected_sheets"]} + + +def _identity_key(identity: SheetIdentity) -> tuple[str, int, str | None, str | None]: + return ( + identity["name"], + identity["occurrence"], + identity["kind"], + identity["path"], + ) diff --git a/plugin/session.py b/plugin/session.py new file mode 100644 index 0000000..a66bc4f --- /dev/null +++ b/plugin/session.py @@ -0,0 +1,86 @@ +from __future__ import annotations + +from .ctrl_release import CtrlReleasePoller +from .sheets import apply_group_selection +from .state import TabStackWindowState + + +def show_panel(window, state) -> None: + items = [entry.caption for entry in state.session_entries] + state.session_selected_index = 1 if len(items) > 1 else 0 + + def on_select(index: int) -> None: + if index < 0: + handle_panel_closed(window, state) + return + state.session_selected_index = index + _commit_session(window, state) + + def on_highlight(index: int) -> None: + if index < 0 or index >= len(state.session_entries): + return + state.session_selected_index = index + preview_entry(window, state) + + window.show_quick_panel( + items, + on_select, + selected_index=state.session_selected_index, + on_highlight=on_highlight, + placeholder="Release ctrl to close; Hit ctrl+escape to abort", + ) + + if state.ctrl_release_poller: + state.ctrl_release_poller.stop() + state.ctrl_release_poller = CtrlReleasePoller( + lambda: _commit_session(window, state), + 25, + ) + state.ctrl_release_poller.start() + + +def preview_entry(window, state: TabStackWindowState) -> None: + if state.session_entries is None: + return + if not 0 <= state.session_selected_index < len(state.session_entries): + return + entry = state.session_entries[state.session_selected_index] + apply_group_selection(window, entry.selection, state.session_group) + + +def _commit_session(window, state) -> None: + if not state.session_active: + return + + entries = state.session_entries + if not entries: + cancel_session(window, state) + return + + index = clamp_index(state.session_selected_index, len(entries)) + selection = entries[index].selection + if not apply_group_selection(window, selection, state.session_group): + cancel_session(window, state) + return + + window.run_command("hide_overlay", {"cancel": True}) + state.clear_session() + + +def cancel_session(window, state) -> None: + if not state.session_active: + return + + origin = state.session_origin_selection + window.run_command("hide_overlay", {"cancel": True}) + if origin is not None: + apply_group_selection(window, origin, state.session_group) + state.clear_session() + + +def clamp_index(index: int, size: int) -> int: + return max(0, min(index, size - 1)) + + +def handle_panel_closed(window, state) -> None: + cancel_session(window, state) diff --git a/plugin/sheets.py b/plugin/sheets.py new file mode 100644 index 0000000..3f45752 --- /dev/null +++ b/plugin/sheets.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, TypedDict + +if TYPE_CHECKING: + from .state import GroupSelectionState + + +class SheetIdentity(TypedDict): + name: str + occurrence: int + kind: str | None + path: str | None + + +def active_sheet_identity(window) -> SheetIdentity | None: + sheet = window.active_sheet() + if sheet is None: + return None + return sheet_identity(sheet, window) + + +def find_sheet_by_identity_and_group(window, identity: SheetIdentity, group: int): + matching = [ + sheet + for sheet in window.sheets_in_group(group) + if (sheet.name() or sheet_title(sheet)) == identity["name"] + and type(sheet).__name__ == identity["kind"] + and sheet.file_name() == identity["path"] + ] + for sheet in matching: + if sheet_occurrence(sheet, window) == identity["occurrence"]: + return sheet + return None + + +def sheet_identity(sheet, window) -> SheetIdentity: + return { + "name": sheet.name() or sheet_title(sheet), + "occurrence": sheet_occurrence(sheet, window), + "kind": type(sheet).__name__, + "path": sheet.file_name(), + } + + +def sheet_occurrence(sheet, window) -> int: + group = sheet.group() + if group is None: + return 0 + name = sheet.name() or sheet_title(sheet) + kind = type(sheet).__name__ + path = sheet.file_name() + + matching_sheet_ids = [ + candidate.id() + for candidate in window.sheets_in_group(group) + if (candidate.name() or sheet_title(candidate)) == name + and type(candidate).__name__ == kind + and candidate.file_name() == path + ] + try: + return matching_sheet_ids.index(sheet.id()) + except ValueError: + return 0 + + +def sheet_title(sheet) -> str: + file_name = sheet.file_name() + if file_name: + return Path(file_name).name + return "Untitled" + + +def find_live_sheets(window, identities: list[SheetIdentity], group: int) -> list[object]: + return [ + live_sheet + for identity in identities + if (live_sheet := find_sheet_by_identity_and_group(window, identity, group)) is not None + ] + + +def apply_group_selection(window, selection: GroupSelectionState, group: int) -> bool: + sheets_to_select = find_live_sheets(window, selection["selected_sheets"], group) + if not sheets_to_select: + return False + + active_sheet_index = selection["active_sheet_index"] + if active_sheet_index is None or not 0 <= active_sheet_index < len(sheets_to_select): + active_sheet_index = 0 + + window.select_sheets(sheets_to_select) + window.focus_sheet(sheets_to_select[active_sheet_index]) + return True diff --git a/plugin/state.py b/plugin/state.py index 0285c92..f8d3ddf 100644 --- a/plugin/state.py +++ b/plugin/state.py @@ -1,29 +1,43 @@ from __future__ import annotations -from dataclasses import dataclass, field -from typing import Any +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, TypedDict + +from .sheets import SheetIdentity + +if TYPE_CHECKING: + from .mru import Entry + + +class GroupSelectionState(TypedDict): + active_sheet_index: int | None + selected_sheets: list[SheetIdentity] + + +class SheetSelectionHistory(TypedDict): + # Only strings may be used as keys in ST settings. + groups: dict[str, list[GroupSelectionState]] @dataclass(slots=True) class TabStackWindowState: - window_id: int - mru_view_ids: list[int] = field(default_factory=list) - mru_initialized: bool = False session_active: bool = False - session_origin_view_id: int | None = None + session_origin_selection: GroupSelectionState | None = None session_selected_index: int = 0 - session_preview_view_id: int | None = None - session_tagged_view_ids: set[int] = field(default_factory=set) - poller: Any = None + session_group: int = 0 + session_entries: list[Entry] | None = None + selection_poller: Any = None + ctrl_release_poller: Any = None def clear_session(self) -> None: self.session_active = False - self.session_origin_view_id = None + self.session_origin_selection = None self.session_selected_index = 0 - self.session_preview_view_id = None - if self.poller is not None: - self.poller.stop() - self.poller = None + self.session_group = 0 + self.session_entries = None + if self.ctrl_release_poller is not None: + self.ctrl_release_poller.stop() + self.ctrl_release_poller = None _WINDOW_STATE: dict[int, TabStackWindowState] = {} @@ -33,25 +47,18 @@ def get_state(window) -> TabStackWindowState: window_id = window.id() state = _WINDOW_STATE.get(window_id) if state is None: - state = TabStackWindowState(window_id=window_id) + state = TabStackWindowState() _WINDOW_STATE[window_id] = state return state def remove_window_state(window_id: int) -> None: state = _WINDOW_STATE.pop(window_id, None) - if state is not None and state.poller is not None: - state.poller.stop() - - -def remove_view_from_all(view_id: int) -> None: - for state in _WINDOW_STATE.values(): - state.mru_view_ids = [item for item in state.mru_view_ids if item != view_id] - state.session_tagged_view_ids.discard(view_id) - if state.session_preview_view_id == view_id: - state.session_preview_view_id = None - if state.session_origin_view_id == view_id: - state.session_origin_view_id = None + if state is not None: + if state.selection_poller is not None: + state.selection_poller.stop() + if state.ctrl_release_poller is not None: + state.ctrl_release_poller.stop() def iter_states() -> list[TabStackWindowState]: