"""
Command palette — Ctrl-K searchable action list.
Every navigable app, every preference toggle, every menu action gets
registered as a :class:`Command` and the palette lets users jump to
it by typing a few characters. Modelled on VS Code / Slack / Linear.
Public API::
from spacr.qt.command_palette import CommandPalette
CommandPalette(window).exec()
The window is inspected on show — no need to preregister anything.
Commands are:
* every entry in :data:`spacr.qt.app.APPS`
* every recent run journal entry (jump straight into its app + load
the settings via ``AppScreen.apply_settings_dict``)
* every action in the menu bar
* an "Open Preferences…" shortcut
* an "Open Providers…" shortcut
Fuzzy match is a plain substring case-insensitive scan — good
enough for the ~30 commands the palette will ever hold.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Callable, List, Optional
from PySide6.QtCore import Qt
from PySide6.QtGui import QKeyEvent
from PySide6.QtWidgets import (
QDialog, QLineEdit, QListWidget, QListWidgetItem, QMainWindow,
QVBoxLayout,
)
LOG = logging.getLogger("spacr.qt.command_palette")
@dataclass
[docs]
class Command:
"""One entry in the palette.
:ivar label: human-readable text shown in the list.
:ivar section: category badge (``"Apps"`` / ``"Recent"`` / …).
:ivar action: callable invoked when the user hits Enter.
:ivar keywords: extra strings the fuzzy match will search
alongside the label (e.g. "mask cellpose" so typing "cellpose"
finds the Mask app).
"""
label: str
section: str
action: Callable[[], None]
keywords: List[str] = field(default_factory=list)
[docs]
class CommandPalette(QDialog):
"""Modal dialog with a live-filtering command list.
:param window: the MainWindow the palette should operate on.
"""
def __init__(self, window: QMainWindow):
"""Build the command palette over the main window.
:param window: the window every command acts on.
"""
from .i18n import tr
super().__init__(window)
self._window = window
self.setWindowTitle(tr("spaCR — Command palette"))
self.setModal(True)
from .preferences import scaled_px
self.setMinimumWidth(scaled_px(560))
self.setMinimumHeight(420)
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(0)
self._input = QLineEdit()
self._input.setPlaceholderText(tr(
"Type to filter — Enter to run, Esc to cancel"))
self._input.setObjectName("CommandInput")
from .theme import font_px
self._input.setStyleSheet(
"QLineEdit#CommandInput {"
" border: none;"
" padding: 14px 18px;"
" font-family: 'Open Sans', sans-serif;"
f" font-size: {font_px(15)}px;"
"}"
)
outer.addWidget(self._input)
self._list = QListWidget()
self._list.setStyleSheet(
"QListWidget { border: none; padding: 6px 0; }"
"QListWidget::item { padding: 8px 18px; }"
)
outer.addWidget(self._list, 1)
self._commands: List[Command] = []
self._collect_commands()
self._render(self._commands)
self._input.textChanged.connect(self._on_filter)
self._input.returnPressed.connect(self._on_activate)
self._list.itemActivated.connect(lambda _i: self._on_activate())
def _collect_commands(self) -> None:
"""Gather everything the palette can run: modules, recent runs and actions.
The app registry is imported here rather than at module level and its
failure is tolerated: with no registry the app loop simply does not run,
and only the visibility check needs a stand-in.
"""
from .i18n import tr
try:
from .app import app_is_visible, app_stage, visible_apps
apps = visible_apps()
except Exception:
apps = []
def app_is_visible(_key):
"""Fallback that treats every app as visible."""
return True
for key, name, desc, section in apps:
localized_name = tr(name)
localized_section = tr(section)
words = [
key, desc, section, name.lower(), localized_name.lower(),
localized_section.lower(), app_stage(key),
]
self._commands.append(Command(
label=tr("Go to {name}", name=localized_name),
section=tr("Apps · {section}", section=localized_section),
action=lambda k=key: self._nav(k),
keywords=words,
))
self._commands.append(Command(
label=tr("Go to {name}", name=tr("Home")),
section=tr("Navigation"),
action=lambda: self._nav("__home__"),
keywords=["home", "start", "landing"],
))
self._commands.append(Command(
label=tr("Open Preferences…"),
section=tr("Actions"),
action=self._open_preferences,
keywords=["preferences", "settings", "theme", "font",
"colour", "color", "accessibility"],
))
self._commands.append(Command(
label=tr("Open AI Providers…"),
section=tr("Actions"),
action=self._open_providers,
keywords=["providers", "ai", "claude", "chatgpt",
"gemini", "llm"],
))
self._commands.append(Command(
label=tr("Keyboard shortcuts…"),
section=tr("Help"),
action=self._open_shortcuts,
keywords=["shortcuts", "keyboard", "help", "cheat",
"hotkeys"],
))
try:
from ..run_journal import recent_runs
for r in recent_runs(limit=8):
if not app_is_visible(str(r.get("app_key", ""))):
continue
dur = f"{r.get('elapsed_s', 0) or 0:.1f}s"
status = r.get("status", "?")
dir_name = r["dir"].name
self._commands.append(Command(
label=f"Recent · {r['app_key']} ({status}, {dur})",
section="Recent runs",
action=lambda run=r: self._open_run(run),
keywords=[r["app_key"], dir_name, status,
"recent", "run", "journal"],
))
except Exception as e:
LOG.debug("recent_runs unavailable: %s", e)
self._collect_settings_commands()
try:
from PySide6.QtWidgets import QMenu
bar = self._window.menuBar()
for menu in (bar.findChildren(QMenu) if bar is not None else []):
menu_title = menu.title().replace("&", "")
if not menu_title:
continue
for act in menu.actions():
if act.isSeparator() or act.menu() is not None:
continue
label = act.text().replace("&", "")
if not label:
continue
self._commands.append(Command(
label=f"{menu_title} → {label}",
section="Menu",
action=lambda a=act: a.trigger(),
keywords=[label.lower(), menu_title.lower()],
))
except Exception:
LOG.debug("menu actions unavailable for the palette",
exc_info=True)
def _collect_settings_commands(self, limit: int = 400) -> None:
"""One command per setting of the module currently on screen.
Activating it opens that setting: the module's search strip is
filtered to the key and the section holding it is expanded, so the
palette lands the user *on* the control rather than merely naming
it.
Scoped to the current module on purpose. Every setting of every
module is 1,022 rows, and a palette in which "diameter" returns
eleven identically-named entries from six modules is a worse answer
than no entry at all.
:param limit: safety cap, so an unusually large module cannot make
opening the palette feel slow.
"""
try:
screen = self._window._stack.currentWidget()
except Exception:
return
model = getattr(screen, "_settings_model", None)
widgets = getattr(model, "_widgets", None) if model is not None else None
if not widgets:
return
app_key = str(getattr(screen, "app_key", "") or "")
section = f"Settings · {app_key}" if app_key else "Settings"
for key in list(widgets)[:limit]:
try:
label = model._label_for(key)
hint = model.plain_tooltip_for(key)
except Exception:
label, hint = key, ""
self._commands.append(Command(
label=f"{label} ({key})",
section=section,
action=lambda k=key: self._reveal_setting(k),
keywords=[key.lower(), label.lower(), hint.lower(),
"setting", app_key.lower()],
))
def _reveal_setting(self, key: str) -> None:
"""Filter the current module's settings strip down to ``key``.
Falls back to expanding the section and focusing the widget when the
strip is not installed, so the command still lands somewhere useful
on a screen the search bar could not reach.
"""
try:
screen = self._window._stack.currentWidget()
except Exception:
return
bar = getattr(screen, "_settings_search", None)
if bar is not None:
try:
bar.set_modified_only(False)
bar.set_level("all")
bar.set_query(key)
except Exception:
LOG.debug("could not reveal %r through the strip", key,
exc_info=True)
opener = getattr(screen, "_open_the_heading_of", None)
if callable(opener):
try:
opener(key)
except Exception:
LOG.debug("could not open the category of %r", key,
exc_info=True)
widget = (getattr(screen, "_settings_model", None)
and screen._settings_model._widgets.get(key))
if widget is None:
return
for section in getattr(screen, "_settings_sections", []) or []:
try:
holds = getattr(section, "_holds", None)
if (holds(widget) if callable(holds)
else section.isAncestorOf(widget)):
section.set_expanded(True)
break
except (AttributeError, RuntimeError):
continue
try:
widget.setFocus()
except Exception:
pass
def _render(self, cmds: List[Command]) -> None:
"""Fill the list with commands, grouped under section headers.
The headers are not selectable, and the auto-selection skips past the
first one -- otherwise Return on a freshly opened palette would activate
a heading. Every command follows a heading, so the first command is
always the second row.
:param cmds: the commands to show, in section order.
"""
self._list.clear()
current_section = None
for cmd in cmds:
if cmd.section != current_section:
header = QListWidgetItem(cmd.section.upper())
header.setFlags(Qt.NoItemFlags)
header.setForeground(Qt.gray)
self._list.addItem(header)
current_section = cmd.section
item = QListWidgetItem(cmd.label)
item.setData(Qt.UserRole, cmd)
self._list.addItem(item)
if self._list.count() > 1:
self._list.setCurrentRow(1)
def _on_filter(self, needle: str) -> None:
"""Narrow the list to commands matching the typed text.
Matched case-insensitively against the label and the keywords, so a
command can be found by what it does as well as by what it is called.
:param needle: the typed text; empty shows everything.
"""
needle = (needle or "").strip().lower()
if not needle:
self._render(self._commands)
return
filtered = [
c for c in self._commands
if needle in c.label.lower()
or any(needle in k.lower() for k in c.keywords)
]
self._render(filtered)
def _on_activate(self) -> None:
"""Run the selected command and close the palette.
The palette is dismissed before the command runs, so a command that
opens a dialog does not open it behind this one. A command that raises
is logged rather than propagated -- the palette has already closed, and
there is nothing left to show an exception on.
"""
item = self._list.currentItem()
if item is None:
return
cmd: Optional[Command] = item.data(Qt.UserRole)
if cmd is None:
return
self.accept()
try:
cmd.action()
except Exception as e:
LOG.warning("command failed: %s (%s)", cmd.label, e)
[docs]
def keyPressEvent(self, event: QKeyEvent) -> None:
"""Move through the results, or run the highlighted command.
:param event: the Qt key event.
"""
if event.key() == Qt.Key_Down:
row = self._list.currentRow()
for i in range(row + 1, self._list.count()):
if self._list.item(i).flags() != Qt.NoItemFlags:
self._list.setCurrentRow(i); break
return
if event.key() == Qt.Key_Up:
row = self._list.currentRow()
for i in range(row - 1, -1, -1):
if self._list.item(i).flags() != Qt.NoItemFlags:
self._list.setCurrentRow(i); break
return
super().keyPressEvent(event)
def _nav(self, key: str) -> None:
"""Navigate the window to a module.
:param key: the module to open. A window without the navigation slot --
a test, or a bare dialog -- is tolerated.
"""
try:
self._window._on_nav_selected(key)
except Exception:
pass
def _open_preferences(self) -> None:
"""Open the Preferences dialog."""
try:
from .preferences import PreferencesDialog
PreferencesDialog(self._window).exec()
except Exception:
pass
def _open_providers(self) -> None:
"""Open the AI providers dialog."""
try:
from .widgets.ai_chat_panel import _ProvidersDialog
_ProvidersDialog(self._window).exec()
except Exception:
pass
def _open_shortcuts(self) -> None:
"""Open the keyboard shortcut cheat sheet."""
try:
from .shortcuts import show_cheat_sheet
show_cheat_sheet(self._window)
except Exception:
pass
def _open_run(self, run: dict) -> None:
"""Navigate to the run's app + load its settings CSV."""
try:
app_key = run["app_key"]
self._nav(app_key)
screen = self._window._screens.get(app_key)
if screen is None or not hasattr(screen, "apply_settings_dict"):
return
from ..run_journal import load_run_settings
settings = load_run_settings(run["dir"])
screen.apply_settings_dict(settings)
except Exception as e:
LOG.warning("failed to open run %s: %s", run.get("dir"), e)