"""
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
Matching is a case-insensitive substring search over each command's
label and keywords; there is no fuzzy or ranked matching.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Callable, List, Optional
from PySide6.QtCore import (
QAbstractListModel, QItemSelectionModel, QModelIndex, QSignalBlocker, Qt,
Signal,
)
from PySide6.QtGui import QBrush, QKeyEvent
from PySide6.QtWidgets import (
QApplication, QDialog, QLineEdit, QListView, QMainWindow, QVBoxLayout,
)
from shiboken6 import isValid
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)
class _Row:
"""One row of the palette: a section heading or a command.
Read back the way a list-widget item is, so callers can ask a row for its
text, its flags and the command it runs.
:param text: the text the row shows.
:param command: the command the row runs; ``None`` for a heading.
"""
__slots__ = ("_text", "_command")
def __init__(self, text: str, command: Optional[Command] = None):
"""Hold the row's text and command.
:param text: the text the row shows.
:param command: the command the row runs; ``None`` for a heading.
"""
self._text = text
self._command = command
def text(self) -> str:
"""The text the row shows."""
return self._text
def flags(self):
"""``NoItemFlags`` for a heading; enabled and selectable otherwise."""
if self._command is None:
return Qt.NoItemFlags
return Qt.ItemIsEnabled | Qt.ItemIsSelectable
def data(self, role: int = Qt.UserRole):
"""The command for ``Qt.UserRole``, the text for the display role.
:param role: the item data role asked for.
"""
if role == Qt.UserRole:
return self._command
if role == Qt.DisplayRole:
return self._text
return None
class _RowModel(QAbstractListModel):
"""A fixed list of palette rows.
A model is never changed once a view shows it. A new filter builds a new
model and the view is pointed at it in one step, so nothing a view is
showing is ever cleared or deleted while the view still reads it, and no
row is a C++ object that Python has to keep track of.
:param rows: the rows, in display order.
:param parent: the view the model belongs to.
"""
def __init__(self, rows, parent=None):
"""Hold ``rows``; they never change afterwards.
:param rows: the rows, in display order.
:param parent: the view the model belongs to.
"""
super().__init__(parent)
self._rows = tuple(rows)
self._heading = QBrush(Qt.gray)
def rowCount(self, parent=QModelIndex()) -> int:
"""How many rows there are; a row has no children.
:param parent: the parent index; only the root has rows.
"""
return 0 if parent.isValid() else len(self._rows)
def row(self, index: int) -> Optional[_Row]:
"""The row at ``index``, or ``None`` past either end.
:param index: the row number.
"""
if 0 <= index < len(self._rows):
return self._rows[index]
return None
def data(self, index, role=Qt.DisplayRole):
"""The text of a row, and grey for a heading.
:param index: the model index asked about.
:param role: the item data role asked for.
"""
row = self.row(index.row()) if index.isValid() else None
if row is None:
return None
if role == Qt.DisplayRole:
return row.text()
if role == Qt.ForegroundRole and row.data() is None:
return self._heading
return None
def flags(self, index):
"""The row's flags: a heading cannot be selected.
:param index: the model index asked about.
"""
row = self.row(index.row()) if index.isValid() else None
return Qt.NoItemFlags if row is None else row.flags()
class _RowList(QListView):
"""The palette's result list.
A list view that answers the way the list widget it replaced did --
``count``, ``item``, ``currentRow``, ``setCurrentRow``, ``currentItem``,
``currentRowChanged`` and ``itemActivated`` -- and whose rows are only
ever replaced whole by :meth:`show_rows`.
"""
currentRowChanged = Signal(int)
itemActivated = Signal(object)
def __init__(self, parent=None):
"""Start empty.
:param parent: the widget the list sits in.
"""
super().__init__(parent)
self.setModel(_RowModel((), self))
self._watch_current()
self.activated.connect(self._on_activated)
def show_rows(self, rows) -> None:
"""Show ``rows`` in place of every row shown now.
The rows go into a new model that no view has seen, and the view is
pointed at it in one call. The old model and its selection are
released only after the view has let go of them.
:param rows: the rows, in display order.
"""
model = _RowModel(rows, self)
old_model, old_selection = self.model(), self.selectionModel()
self.setModel(model)
self._watch_current()
old_selection.deleteLater()
old_model.deleteLater()
def _watch_current(self) -> None:
"""Report the current row of the selection the view has now."""
self.selectionModel().currentRowChanged.connect(self._on_current)
def _on_current(self, current, _previous) -> None:
"""Pass a new current row on as ``currentRowChanged``.
:param current: the new current index.
:param _previous: the index that was current; unused.
"""
self.currentRowChanged.emit(current.row())
def _on_activated(self, index) -> None:
"""Pass an activated row on as ``itemActivated``.
:param index: the index activated.
"""
self.itemActivated.emit(self.item(index.row()))
def count(self) -> int:
"""How many rows the list shows."""
return self.model().rowCount()
def item(self, index: int) -> Optional[_Row]:
"""The row at ``index``, or ``None`` past either end.
:param index: the row number.
"""
return self.model().row(index)
def currentRow(self) -> int:
"""The current row's number, or -1 when there is none."""
return self.currentIndex().row()
def currentItem(self) -> Optional[_Row]:
"""The current row, or ``None`` when there is none."""
return self.item(self.currentRow())
def setCurrentRow(self, index: int) -> None:
"""Make row ``index`` current and selected, as a list widget does.
A heading can be made current this way, as it can on a list widget;
the arrow keys and the first selection never put it there.
:param index: the row number.
"""
self.selectionModel().setCurrentIndex(
self.model().index(index, 0),
QItemSelectionModel.ClearAndSelect)
def setCurrentItem(self, row: _Row) -> None:
"""Make ``row`` current; a row the list does not show is ignored.
:param row: a row the list shows.
"""
model = self.model()
for index in range(model.rowCount()):
if model.row(index) is row:
self.setCurrentRow(index)
return
[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 = _RowList()
self._list.setStyleSheet(
"QListView { border: none; padding: 6px 0; }"
"QListView::item { padding: 8px 18px; }"
)
outer.addWidget(self._list, 1)
self._commands: List[Command] = []
self._rendering = False
self._pending: Optional[List[Command]] = None
self._closed = False
self._collect_commands()
self._render(self._commands)
self._input.textChanged.connect(self._on_filter)
self._input.returnPressed.connect(self._on_activate)
self.finished.connect(self._stop_rendering)
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:
"""Show ``cmds``, grouped under section headings.
The headings are not selectable, and the first 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.
A render never runs inside another one. A render asked for while one
is running only records its commands, and the running render shows
them once it has finished. Text typed while the rows are being built
reaches no filter, because the input is silent then; the running
render sees that the text changed and shows what it now selects. A
palette that has closed renders nothing.
:param cmds: the commands to show, in section order.
"""
if self._closed:
return
if self._rendering:
self._pending = list(cmds)
return
self._rendering = True
try:
while True:
typed = self._input.text()
self._fill(cmds)
if self._closed:
break
if self._input.text() != typed:
self._pending = self._matching(self._input.text())
if self._pending is None:
break
cmds, self._pending = self._pending, None
finally:
self._pending = None
self._rendering = False
def _rows_for(self, cmds: List[Command]) -> List[_Row]:
"""The rows that show ``cmds``: a heading before each new section.
Plain Python objects; building them touches no widget.
:param cmds: the commands to show, in section order.
"""
rows: List[_Row] = []
current_section = None
for cmd in cmds:
if cmd.section != current_section:
rows.append(_Row(cmd.section.upper()))
current_section = cmd.section
rows.append(_Row(cmd.label, cmd))
return rows
def _fill(self, cmds: List[Command]) -> None:
"""Replace the rows with the rows for ``cmds``, in one step.
The rows are built first, off screen, as plain Python objects. The
input and the list are silent while that happens and while the list
takes the new rows, so a key typed meanwhile only changes the text and
cannot start a filter. A palette destroyed while the rows were being
built counts as closed and touches nothing it held.
:param cmds: the commands to show, in section order.
"""
quiet = QSignalBlocker(self._input)
try:
rows = self._rows_for(cmds)
if not isValid(self._list):
self._closed = True
return
hushed = QSignalBlocker(self._list)
try:
self._list.show_rows(rows)
if self._list.count() > 1:
self._list.setCurrentRow(1)
finally:
hushed.unblock()
finally:
if isValid(self._input):
quiet.unblock()
else:
quiet.dismiss()
def _matching(self, needle: str) -> List[Command]:
"""The commands whose label or keywords hold ``needle``.
Matched case-insensitively, so a command can be found by what it does
as well as by what it is called.
:param needle: the typed text; empty matches every command.
"""
needle = (needle or "").strip().lower()
if not needle:
return list(self._commands)
return [
c for c in self._commands
if needle in c.label.lower()
or any(needle in k.lower() for k in c.keywords)
]
def _stop_rendering(self, _result: int = 0) -> None:
"""Stop the palette from rendering once it has closed.
Typing still queued behind the closing key would otherwise rebuild
the list of a dialog that is going away.
:param _result: the dialog result ``finished`` carries; unused.
"""
self._closed = True
self._pending = None
def _on_filter(self, needle: str) -> None:
"""Narrow the list to commands matching the typed text.
:param needle: the typed text; empty shows everything.
"""
self._render(self._matching(needle))
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
modal = QApplication.activeModalWidget() is self
self.accept()
if modal:
self._pending_command = cmd
return
self._run_command(cmd)
def _run_command(self, cmd: "Command") -> None:
"""Run one command, logging rather than raising its failure.
Called straight away when the palette is not in its own ``exec()``,
and by :func:`spacr.qt.shortcuts._open_palette` after ``exec()`` has
returned when it is: a command that opens a dialog, rebuilds a screen
or quits must not run inside a modal session that is already ending,
which is what crashed in AppKit's modal cleanup on macOS.
:param cmd: the command to run.
"""
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)