refactor(gui_2): merge command_palette; split registry->commands + render->gui_2; git rm src/command_palette.py

Per spec FR1 + Phase 1.3 + architecture feedback: src/command_palette.py
split by responsibility:
  - Command/ScoredCommand/CommandRegistry/fuzzy_match/_close_palette/_execute (data/ops)
    -> src/commands.py (which already owns _LazyCommandRegistry pattern)
  - render_palette_modal (view/ImGui) -> src/gui_2.py

GUI is a pure view; the registry/data classes are ops; commands.py owns
the registry because commands.py is where @registry.register decorators live.
gui_2.render_palette_modal imports Command from commands.py to type its
parameters.

Also fixes Phase 1.1 (bg_shader) per architecture feedback:
BackgroundShader no longer owns 'enabled' state - the GUI is pure view.
State is now owned by AppController.bg_shader_enabled (read on load from
config, written from gui_2 checkbox via app's __setattr__ delegation).

Tests:
- tests/test_command_palette.py: imports from src.commands (was src.command_palette)
- tests/test_commands_no_top_level_command_palette.py: rewritten for the
  new architecture (eager registry in commands.py; render in gui_2; no
  circular import between commands.py and gui_2)
This commit is contained in:
ed
2026-06-26 06:54:59 -04:00
parent be5607dee8
commit 3dd153f718
6 changed files with 259 additions and 327 deletions
+131 -28
View File
@@ -2,12 +2,12 @@ from __future__ import annotations
import webbrowser
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional
from src import models
from src import theme_2
from src.module_loader import _require_warmed
from src.hot_reloader import HotReloader
from src.result_types import ErrorInfo, ErrorKind, Result
@@ -15,25 +15,138 @@ from src.result_types import ErrorInfo, ErrorKind, Result
if TYPE_CHECKING:
from src.gui_2 import App
# Lazy command registry (startup_speedup_20260606 Phase 5A)
# --------------------------------------------------------------------------
# The @registry.register decorator runs at module import time, but we want
# to defer the actual CommandRegistry creation (and the underlying
# src.command_palette import, ~244ms) until the palette is actually used.
# The proxy below makes @registry.register a no-op that just queues the
# function; the real CommandRegistry is built lazily on first access to
# any other registry attribute (.all, .get, etc.) by gui_2.py or tests.
# Command data classes + registry (moved from src/command_palette.py in
# module_taxonomy_refactor_20260627 Phase 1.3; the *rendering* function
# `render_palette_modal` lives in src/gui_2.py because it owns ImGui state)
# --------------------------------------------------------------------------
@dataclass
class Command:
id: str
title: str
category: str
shortcut: Optional[str] = None
description: str = ""
enabled_when: Optional[str] = None
action: Optional[Callable] = None
@dataclass
class ScoredCommand:
command: Command
score: float
class CommandRegistry:
def __init__(self) -> None:
self._commands: Dict[str, Command] = {}
def register(self, command_or_callable: Any) -> Any:
if isinstance(command_or_callable, Command):
cmd = command_or_callable
else:
cmd = Command(
id=command_or_callable.__name__,
title=command_or_callable.__name__.replace("_", " ").title(),
category="uncategorized",
action=command_or_callable,
)
if cmd.id in self._commands:
raise ValueError(f"Command {cmd.id} already registered")
self._commands[cmd.id] = cmd
return command_or_callable
def all(self) -> List[Command]:
return list(self._commands.values())
def get(self, command_id: str) -> Command:
return self._commands.get(command_id) or Command(id="", title="", category="uncategorized", action=lambda: None)
def fuzzy_match(query: str, candidates: List[Command], top_n: int = 20) -> List[ScoredCommand]:
query_lower = query.lower()
scored: List[ScoredCommand] = []
for cmd in candidates:
title_lower = cmd.title.lower()
if not _is_subsequence(query_lower, title_lower):
continue
score = _compute_score(query_lower, title_lower)
scored.append(ScoredCommand(command=cmd, score=score))
scored.sort(key=lambda r: r.score, reverse=True)
return scored[:top_n]
def _is_subsequence(query: str, target: str) -> bool:
qi = 0
for ch in target:
if qi < len(query) and ch == query[qi]:
qi += 1
return qi == len(query)
def _compute_score(query: str, target: str) -> float:
score = 0.0
if target.startswith(query): score += 1.0
elif _starts_at_word_boundary(query, target): score += 0.5
if _is_contiguous(query, target): score += 0.3
gaps = _count_gaps(query, target)
score -= 0.1 * gaps
return score
def _starts_at_word_boundary(query: str, target: str) -> bool:
if not target.startswith(query):
return False
return len(query) == 0 or not query[0].isalnum() or len(target) == len(query) or not target[len(query)].isalnum()
def _is_contiguous(query: str, target: str) -> bool:
return query in target
def _count_gaps(query: str, target: str) -> int:
qi = 0
gaps = 0
last_match = -1
for ti, ch in enumerate(target):
if qi < len(query) and ch == query[qi]:
if last_match >= 0 and ti - last_match > 1: gaps += ti - last_match - 1
last_match = ti
qi += 1
return gaps
def _close_palette(app: Any) -> None:
app.show_command_palette = False
app._command_palette_query = ""
app._command_palette_selected = 0
app._command_palette_focused = False
app._command_palette_input_focused = False
def _execute(app: Any, command: Command) -> None:
if not command.action:
return
try:
command.action(app)
except (AttributeError, TypeError, ValueError, OSError) as e:
_cmd_err = Result(data=None, errors=[ErrorInfo(kind=ErrorKind.INTERNAL, message=f"Action {command.id} raised: {e}", source="command_palette._execute", original=e)])
print(f"[CommandPalette] Action {command.id} raised: {e}")
_close_palette(app)
# --------------------------------------------------------------------------
# Eager registry (was _LazyCommandRegistry; the lazy pattern is no longer
# needed since src/commands.py is a thin data module, not the heavy
# command_palette.py that previously pulled in imgui at module load time)
# --------------------------------------------------------------------------
_PENDING_REGISTRATIONS: list[Callable] = []
_real_registry: Any = None
_real_registry: CommandRegistry | None = None
def _get_real_registry() -> CommandRegistry:
global _real_registry
if _real_registry is None:
_real_registry = CommandRegistry()
for func in _PENDING_REGISTRATIONS:
_real_registry.register(func)
return _real_registry
class _LazyCommandRegistry:
"""Proxy that defers CommandRegistry instantiation.
Behaves like a CommandRegistry from the caller's perspective:
- @registry.register decorates functions by queuing them
- .all, .get, etc. trigger real initialization on first access
class _EagerCommandRegistry:
"""Eager registry proxy. @registry.register queues until first .all/.get,
then materializes the real CommandRegistry and replays the queue.
"""
def register(self, command_or_callable: Any) -> Any:
@@ -44,17 +157,7 @@ class _LazyCommandRegistry:
return getattr(_get_real_registry(), name)
def _get_real_registry() -> Any:
global _real_registry
if _real_registry is None:
command_palette = _require_warmed("src.command_palette")
_real_registry = command_palette.CommandRegistry()
for func in _PENDING_REGISTRATIONS:
_real_registry.register(func)
return _real_registry
registry = _LazyCommandRegistry()
registry = _EagerCommandRegistry()
# --------------------------------------------------------------------------