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:
@@ -2076,8 +2076,7 @@ class AppController:
|
|||||||
self.ui_separate_tool_calls_panel = _uip.separate_tool_calls_panel
|
self.ui_separate_tool_calls_panel = _uip.separate_tool_calls_panel
|
||||||
self.ui_auto_switch_layout = gui_cfg.get("auto_switch_layout", False)
|
self.ui_auto_switch_layout = gui_cfg.get("auto_switch_layout", False)
|
||||||
self.ui_tier_layout_bindings = gui_cfg.get("tier_layout_bindings", {"Tier 1": "", "Tier 2": "", "Tier 3": "", "Tier 4": ""})
|
self.ui_tier_layout_bindings = gui_cfg.get("tier_layout_bindings", {"Tier 1": "", "Tier 2": "", "Tier 3": "", "Tier 4": ""})
|
||||||
from src.gui_2 import get_bg
|
self.bg_shader_enabled = gui_cfg.get("bg_shader_enabled", False)
|
||||||
get_bg().enabled = gui_cfg.get("bg_shader_enabled", False)
|
|
||||||
|
|
||||||
_default_windows = {
|
_default_windows = {
|
||||||
"Project Settings": True,
|
"Project Settings": True,
|
||||||
@@ -3018,7 +3017,6 @@ class AppController:
|
|||||||
self.config["rag"] = self.rag_config.to_dict()
|
self.config["rag"] = self.rag_config.to_dict()
|
||||||
|
|
||||||
self.config["projects"] = {"paths": self.project_paths, "active": self.active_project_path}
|
self.config["projects"] = {"paths": self.project_paths, "active": self.active_project_path}
|
||||||
from src.gui_2 import get_bg
|
|
||||||
# Update gui section while preserving other keys like bg_shader_enabled
|
# Update gui section while preserving other keys like bg_shader_enabled
|
||||||
gui_cfg = self.config.get("gui", {})
|
gui_cfg = self.config.get("gui", {})
|
||||||
gui_cfg.update({
|
gui_cfg.update({
|
||||||
@@ -3033,7 +3031,7 @@ class AppController:
|
|||||||
"separate_tier2": self.ui_separate_tier2,
|
"separate_tier2": self.ui_separate_tier2,
|
||||||
"separate_tier3": self.ui_separate_tier3,
|
"separate_tier3": self.ui_separate_tier3,
|
||||||
"separate_tier4": self.ui_separate_tier4,
|
"separate_tier4": self.ui_separate_tier4,
|
||||||
"bg_shader_enabled": get_bg().enabled
|
"bg_shader_enabled": getattr(self, "bg_shader_enabled", False)
|
||||||
})
|
})
|
||||||
self.config["gui"] = gui_cfg
|
self.config["gui"] = gui_cfg
|
||||||
|
|
||||||
|
|||||||
@@ -1,208 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from imgui_bundle import imgui
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Optional, Callable, List, Dict, Any
|
|
||||||
|
|
||||||
from src.result_types import ErrorInfo, ErrorKind, Result
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@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:
|
|
||||||
"""Close the palette and reset all per-open state."""
|
|
||||||
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:
|
|
||||||
"""Run a command and close the palette. Catches exceptions to keep the modal clean."""
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def render_palette_modal(app: Any, commands: List[Command]) -> None:
|
|
||||||
"""Renders the interactive Command Palette modal. Exposes a text input query bar
|
|
||||||
and lists matching commands with fuzzy matching, supporting keyboard navigation (Up/Down/Enter/Esc).
|
|
||||||
|
|
||||||
SSDL: `[I:query_input] -> [B:results_list] => [B:execute_command]`
|
|
||||||
|
|
||||||
ASCII Layout Map:
|
|
||||||
+==================== Command Palette ===================+
|
|
||||||
| |query_text| |
|
|
||||||
| +-----------------------------------------------------+ |
|
|
||||||
| | > [category-a] Command Title A (selected) | |
|
|
||||||
| | [category-b] Command Title B | |
|
|
||||||
| +-----------------------------------------------------+ |
|
|
||||||
+========================================================+
|
|
||||||
"""
|
|
||||||
if not getattr(app, "show_command_palette", False):
|
|
||||||
return
|
|
||||||
|
|
||||||
viewport = imgui.get_main_viewport()
|
|
||||||
center = viewport.get_center()
|
|
||||||
imgui.set_next_window_pos((center.x - 300, center.y - 200), imgui.Cond_.always)
|
|
||||||
imgui.set_next_window_size((600, 400), imgui.Cond_.always)
|
|
||||||
|
|
||||||
if not hasattr(app, "_command_palette_query"): app._command_palette_query = ""
|
|
||||||
if not hasattr(app, "_command_palette_selected"): app._command_palette_selected = 0
|
|
||||||
if not hasattr(app, "_command_palette_focused"): app._command_palette_focused = False
|
|
||||||
|
|
||||||
# Set focus on the window + input field ONCE per open.
|
|
||||||
if not app._command_palette_focused:
|
|
||||||
imgui.set_next_window_focus()
|
|
||||||
app._command_palette_focused = True
|
|
||||||
|
|
||||||
# Escape closes the palette.
|
|
||||||
if imgui.is_key_pressed(imgui.Key.escape):
|
|
||||||
_close_palette(app)
|
|
||||||
return
|
|
||||||
|
|
||||||
expanded, opened = imgui.begin("Command Palette##manual_slop", True, imgui.WindowFlags_.no_collapse)
|
|
||||||
if not expanded or not opened:
|
|
||||||
app.show_command_palette = False
|
|
||||||
app._command_palette_focused = False
|
|
||||||
imgui.end()
|
|
||||||
return
|
|
||||||
|
|
||||||
# After the window is drawn, the input gets focus.
|
|
||||||
if not getattr(app, '_command_palette_input_focused', False):
|
|
||||||
imgui.set_keyboard_focus_here()
|
|
||||||
app._command_palette_input_focused = True
|
|
||||||
|
|
||||||
# Process Up/Down/Enter BEFORE input_text so we see the keys before the
|
|
||||||
# input field consumes them for cursor movement / text editing.
|
|
||||||
results = fuzzy_match(app._command_palette_query, commands, top_n=20)
|
|
||||||
if results: app._command_palette_selected = max(0, min(app._command_palette_selected, len(results) - 1))
|
|
||||||
else: app._command_palette_selected = 0
|
|
||||||
|
|
||||||
if imgui.is_key_pressed(imgui.Key.down_arrow):
|
|
||||||
if results:
|
|
||||||
app._command_palette_selected = min(app._command_palette_selected + 1, len(results) - 1)
|
|
||||||
if imgui.is_key_pressed(imgui.Key.up_arrow):
|
|
||||||
if results:
|
|
||||||
app._command_palette_selected = max(app._command_palette_selected - 1, 0)
|
|
||||||
if imgui.is_key_pressed(imgui.Key.enter) or imgui.is_key_pressed(imgui.Key.keypad_enter):
|
|
||||||
if results and 0 <= app._command_palette_selected < len(results):
|
|
||||||
_execute(app, results[app._command_palette_selected].command)
|
|
||||||
|
|
||||||
imgui.set_next_item_width(-1)
|
|
||||||
_, app._command_palette_query = imgui.input_text("##query", app._command_palette_query)
|
|
||||||
|
|
||||||
if imgui.begin_child("##results", (0, -1)):
|
|
||||||
for i, scored in enumerate(results):
|
|
||||||
is_selected = (i == app._command_palette_selected)
|
|
||||||
label = f"[{scored.command.category}] {scored.command.title}"
|
|
||||||
clicked, _ = imgui.selectable(label, is_selected)
|
|
||||||
if clicked:
|
|
||||||
app._command_palette_selected = i
|
|
||||||
_execute(app, scored.command)
|
|
||||||
if not results:
|
|
||||||
imgui.text_disabled("No matching commands.")
|
|
||||||
imgui.end_child()
|
|
||||||
|
|
||||||
imgui.end()
|
|
||||||
+131
-28
@@ -2,12 +2,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import webbrowser
|
import webbrowser
|
||||||
|
|
||||||
from pathlib import Path
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING, Any, Callable
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
from src import models
|
from src import models
|
||||||
from src import theme_2
|
from src import theme_2
|
||||||
from src.module_loader import _require_warmed
|
|
||||||
|
|
||||||
from src.hot_reloader import HotReloader
|
from src.hot_reloader import HotReloader
|
||||||
from src.result_types import ErrorInfo, ErrorKind, Result
|
from src.result_types import ErrorInfo, ErrorKind, Result
|
||||||
@@ -15,25 +15,138 @@ from src.result_types import ErrorInfo, ErrorKind, Result
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from src.gui_2 import App
|
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
|
# Command data classes + registry (moved from src/command_palette.py in
|
||||||
# to defer the actual CommandRegistry creation (and the underlying
|
# module_taxonomy_refactor_20260627 Phase 1.3; the *rendering* function
|
||||||
# src.command_palette import, ~244ms) until the palette is actually used.
|
# `render_palette_modal` lives in src/gui_2.py because it owns ImGui state)
|
||||||
# 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.
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@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] = []
|
_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:
|
class _EagerCommandRegistry:
|
||||||
"""Proxy that defers CommandRegistry instantiation.
|
"""Eager registry proxy. @registry.register queues until first .all/.get,
|
||||||
|
then materializes the real CommandRegistry and replays the queue.
|
||||||
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
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def register(self, command_or_callable: Any) -> Any:
|
def register(self, command_or_callable: Any) -> Any:
|
||||||
@@ -44,17 +157,7 @@ class _LazyCommandRegistry:
|
|||||||
return getattr(_get_real_registry(), name)
|
return getattr(_get_real_registry(), name)
|
||||||
|
|
||||||
|
|
||||||
def _get_real_registry() -> Any:
|
registry = _EagerCommandRegistry()
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|||||||
+69
-12
@@ -24,7 +24,8 @@ if _thirdparty not in sys.path:
|
|||||||
|
|
||||||
from contextlib import ExitStack, nullcontext
|
from contextlib import ExitStack, nullcontext
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any, Callable, Dict, List
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from imgui_bundle import imgui, hello_imgui, immapp, imgui_node_editor as ed, imgui_color_text_edit as ced
|
from imgui_bundle import imgui, hello_imgui, immapp, imgui_node_editor as ed, imgui_color_text_edit as ced
|
||||||
|
|
||||||
# Lazy proxies (startup_speedup_20260606 Phase 5D)
|
# Lazy proxies (startup_speedup_20260606 Phase 5D)
|
||||||
@@ -1093,9 +1094,9 @@ class App:
|
|||||||
pushed_prior_tint = False
|
pushed_prior_tint = False
|
||||||
|
|
||||||
# Render background shader
|
# Render background shader
|
||||||
bg = get_bg()
|
if getattr(self, 'bg_shader_enabled', False):
|
||||||
ws = imgui.get_io().display_size
|
ws = imgui.get_io().display_size
|
||||||
if bg.enabled: bg.render(ws.x, ws.y)
|
get_bg().render(ws.x, ws.y)
|
||||||
|
|
||||||
theme.render_post_fx(ws.x, ws.y, self.ai_status, self.ui_crt_filter)
|
theme.render_post_fx(ws.x, ws.y, self.ai_status, self.ui_crt_filter)
|
||||||
|
|
||||||
@@ -6265,13 +6266,14 @@ def render_theme_panel(app: App) -> None:
|
|||||||
ch_ct, ctrans = imgui.slider_float("##ctrans", theme.get_child_transparency(), 0.1, 1.0, "%.2f")
|
ch_ct, ctrans = imgui.slider_float("##ctrans", theme.get_child_transparency(), 0.1, 1.0, "%.2f")
|
||||||
if ch_ct:
|
if ch_ct:
|
||||||
theme.set_child_transparency(ctrans)
|
theme.set_child_transparency(ctrans)
|
||||||
bg = get_bg()
|
bg_enabled = getattr(self, 'bg_shader_enabled', False)
|
||||||
ch_bg, bg.enabled = imgui.checkbox("Animated Background Shader", bg.enabled)
|
ch_bg, new_bg = imgui.checkbox("Animated Background Shader", bg_enabled)
|
||||||
if ch_bg:
|
if ch_bg and new_bg != bg_enabled:
|
||||||
|
self.bg_shader_enabled = new_bg
|
||||||
gui_cfg = app.config.setdefault("gui", {})
|
gui_cfg = app.config.setdefault("gui", {})
|
||||||
gui_cfg["bg_shader_enabled"] = bg.enabled
|
gui_cfg["bg_shader_enabled"] = new_bg
|
||||||
app._flush_to_config()
|
if hasattr(app, "_flush_to_config"): app._flush_to_config()
|
||||||
app.save_config()
|
if hasattr(app, "save_config"): app.save_config()
|
||||||
|
|
||||||
ch_crt, app.ui_crt_filter = imgui.checkbox("CRT Filter", app.ui_crt_filter)
|
ch_crt, app.ui_crt_filter = imgui.checkbox("CRT Filter", app.ui_crt_filter)
|
||||||
if ch_crt:
|
if ch_crt:
|
||||||
@@ -8443,12 +8445,11 @@ _bg: _Optional["BackgroundShader"] = None
|
|||||||
|
|
||||||
class BackgroundShader:
|
class BackgroundShader:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.enabled = False
|
|
||||||
self.start_time = _bg_time.time()
|
self.start_time = _bg_time.time()
|
||||||
self.ctx: _Optional[_Any] = None
|
self.ctx: _Optional[_Any] = None
|
||||||
|
|
||||||
def render(self, width: float, height: float):
|
def render(self, width: float, height: float):
|
||||||
if not self.enabled or width <= 0 or height <= 0:
|
if width <= 0 or height <= 0:
|
||||||
return
|
return
|
||||||
t = _bg_time.time() - self.start_time
|
t = _bg_time.time() - self.start_time
|
||||||
dl = imgui.get_background_draw_list()
|
dl = imgui.get_background_draw_list()
|
||||||
@@ -8506,3 +8507,59 @@ def draw_soft_shadow(draw_list: imgui.ImDrawList, p_min: imgui.ImVec2, p_max: im
|
|||||||
thickness=1.0
|
thickness=1.0
|
||||||
)
|
)
|
||||||
#endregion: Shaders
|
#endregion: Shaders
|
||||||
|
|
||||||
|
#region: Command Palette Modal (rendering only; registry lives in src/commands.py)
|
||||||
|
from src.commands import Command as _CpCommand, fuzzy_match as _cp_fuzzy_match, _close_palette, _execute as _cp_execute
|
||||||
|
|
||||||
|
def render_palette_modal(app: Any, commands: List[Any]) -> None:
|
||||||
|
if not getattr(app, "show_command_palette", False):
|
||||||
|
return
|
||||||
|
viewport = imgui.get_main_viewport()
|
||||||
|
center = viewport.get_center()
|
||||||
|
imgui.set_next_window_pos((center.x - 300, center.y - 200), imgui.Cond_.always)
|
||||||
|
imgui.set_next_window_size((600, 400), imgui.Cond_.always)
|
||||||
|
if not hasattr(app, "_command_palette_query"): app._command_palette_query = ""
|
||||||
|
if not hasattr(app, "_command_palette_selected"): app._command_palette_selected = 0
|
||||||
|
if not hasattr(app, "_command_palette_focused"): app._command_palette_focused = False
|
||||||
|
if not app._command_palette_focused:
|
||||||
|
imgui.set_next_window_focus()
|
||||||
|
app._command_palette_focused = True
|
||||||
|
if imgui.is_key_pressed(imgui.Key.escape):
|
||||||
|
_close_palette(app)
|
||||||
|
return
|
||||||
|
expanded, opened = imgui.begin("Command Palette##manual_slop", True, imgui.WindowFlags_.no_collapse)
|
||||||
|
if not expanded or not opened:
|
||||||
|
app.show_command_palette = False
|
||||||
|
app._command_palette_focused = False
|
||||||
|
imgui.end()
|
||||||
|
return
|
||||||
|
if not getattr(app, '_command_palette_input_focused', False):
|
||||||
|
imgui.set_keyboard_focus_here()
|
||||||
|
app._command_palette_input_focused = True
|
||||||
|
results = _cp_fuzzy_match(app._command_palette_query, commands, top_n=20)
|
||||||
|
if results: app._command_palette_selected = max(0, min(app._command_palette_selected, len(results) - 1))
|
||||||
|
else: app._command_palette_selected = 0
|
||||||
|
if imgui.is_key_pressed(imgui.Key.down_arrow):
|
||||||
|
if results:
|
||||||
|
app._command_palette_selected = min(app._command_palette_selected + 1, len(results) - 1)
|
||||||
|
if imgui.is_key_pressed(imgui.Key.up_arrow):
|
||||||
|
if results:
|
||||||
|
app._command_palette_selected = max(app._command_palette_selected - 1, 0)
|
||||||
|
if imgui.is_key_pressed(imgui.Key.enter) or imgui.is_key_pressed(imgui.Key.keypad_enter):
|
||||||
|
if results and 0 <= app._command_palette_selected < len(results):
|
||||||
|
_cp_execute(app, results[app._command_palette_selected].command)
|
||||||
|
imgui.set_next_item_width(-1)
|
||||||
|
_, app._command_palette_query = imgui.input_text("##query", app._command_palette_query)
|
||||||
|
if imgui.begin_child("##results", (0, -1)):
|
||||||
|
for i, scored in enumerate(results):
|
||||||
|
is_selected = (i == app._command_palette_selected)
|
||||||
|
label = f"[{scored.command.category}] {scored.command.title}"
|
||||||
|
clicked, _ = imgui.selectable(label, is_selected)
|
||||||
|
if clicked:
|
||||||
|
app._command_palette_selected = i
|
||||||
|
_cp_execute(app, scored.command)
|
||||||
|
if not results:
|
||||||
|
imgui.text_disabled("No matching commands.")
|
||||||
|
imgui.end_child()
|
||||||
|
imgui.end()
|
||||||
|
#endregion: Command Palette Modal
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from src.command_palette import Command, ScoredCommand, fuzzy_match
|
from src.commands import Command, ScoredCommand, fuzzy_match
|
||||||
|
|
||||||
|
|
||||||
def _cmd(id: str, title: str) -> Command:
|
def _cmd(id: str, title: str) -> Command:
|
||||||
|
|||||||
@@ -1,26 +1,22 @@
|
|||||||
"""Tests that src/commands.py has NO top-level src.command_palette import.
|
"""Tests for the post-Phase 1.3 architecture.
|
||||||
|
|
||||||
Per spec.md:2.2 Layer 1, the main thread's import chain must not include
|
Per module_taxonomy_refactor_20260627 Phase 1.3, src/command_palette.py was
|
||||||
heavy feature-gated modules. src.command_palette (~244ms) is warmed on
|
deleted and its content split by responsibility:
|
||||||
AppController's _io_pool and accessed via _require_warmed at use sites.
|
- Command / ScoredCommand / CommandRegistry / fuzzy_match (data/ops) -> src/commands.py
|
||||||
|
- render_palette_modal (view) -> src/gui_2.py
|
||||||
|
|
||||||
src/commands.py is a particularly tricky case: it has 32 `@registry.register`
|
src/commands.py is a thin data module and can be imported eagerly (no
|
||||||
decorators on its command functions. The naive "drop the top-level import"
|
require_warmed lazy load is needed; the original lazy-load pattern in
|
||||||
approach would break the decorators (they need a registry at module load time).
|
startup_speedup_20260606 was specifically to defer the heavy src/command_palette
|
||||||
|
which pulled in imgui at module load).
|
||||||
|
|
||||||
Solution: a lazy registry proxy. The @registry.register decorator becomes a
|
These tests run in fresh subprocesses to ensure no warmup state leaks from
|
||||||
no-op that queues the function; the real CommandRegistry is created on first
|
the test runner. We assert:
|
||||||
attribute access to the proxy (e.g. registry.all, registry.get). The 32
|
- src/commands imports cleanly and exposes Command + CommandRegistry
|
||||||
decorated functions get registered at first use, which is the user's first
|
- src/gui_2 exposes render_palette_modal
|
||||||
Ctrl+Shift+P press (or any other access to the palette).
|
- src/commands does NOT import gui_2 at module level (avoids circular)
|
||||||
|
- The static audit detects no top-level command_palette import (since the
|
||||||
These tests run in a fresh subprocess to ensure no warmup state leaks
|
module no longer exists)
|
||||||
from the test runner. We assert:
|
|
||||||
- `src.command_palette` is NOT in `sys.modules` after `import src.commands`
|
|
||||||
- The lazy registry proxy works: `from src.commands import registry` succeeds
|
|
||||||
- Accessing `registry.all()` triggers the real CommandRegistry and
|
|
||||||
returns all 32 registered commands
|
|
||||||
- The static audit script reports NO new violation from src/commands.py
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -42,77 +38,63 @@ def _run_in_subprocess(snippet: str) -> subprocess.CompletedProcess:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_commands_does_not_import_command_palette_at_module_level() -> None:
|
def test_commands_exposes_command_and_registry() -> None:
|
||||||
res = _run_in_subprocess("""
|
res = _run_in_subprocess("""
|
||||||
import sys
|
from src.commands import Command, CommandRegistry, fuzzy_match, ScoredCommand
|
||||||
import src.commands
|
r = CommandRegistry()
|
||||||
print('src.command_palette' in sys.modules)
|
def my_cmd(app): pass
|
||||||
""")
|
r.register(my_cmd)
|
||||||
assert res.returncode == 0, f"stderr: {res.stderr}"
|
print(len(r.all()))
|
||||||
assert res.stdout.strip() == "False", f"src.commands triggered src.command_palette import: {res.stdout}"
|
print(r.all()[0].id)
|
||||||
|
|
||||||
|
|
||||||
def test_commands_lazy_registry_proxies_to_real_registry() -> None:
|
|
||||||
"""Accessing registry.all() should trigger real init and return registered commands."""
|
|
||||||
res = _run_in_subprocess("""
|
|
||||||
from src.commands import registry
|
|
||||||
# Access .all() triggers real CommandRegistry creation
|
|
||||||
all_cmds = registry.all()
|
|
||||||
print(len(list(all_cmds)))
|
|
||||||
# After access, src.command_palette SHOULD be in sys.modules
|
|
||||||
import sys
|
|
||||||
print('src.command_palette' in sys.modules)
|
|
||||||
""")
|
""")
|
||||||
assert res.returncode == 0, f"stderr: {res.stderr}"
|
assert res.returncode == 0, f"stderr: {res.stderr}"
|
||||||
lines = res.stdout.strip().splitlines()
|
lines = res.stdout.strip().splitlines()
|
||||||
# Should have at least 32 commands registered (matches the 32 @registry.register)
|
assert lines[0] == "1"
|
||||||
assert int(lines[0]) >= 32, f"Expected >=32 commands, got {lines[0]}"
|
assert lines[1] == "my_cmd"
|
||||||
assert lines[1] == "True", f"src.command_palette should be loaded after registry access, got {lines[1]}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_commands_register_decorator_is_lazy() -> None:
|
def test_gui_2_exposes_render_palette_modal() -> None:
|
||||||
"""The @registry.register decorator should NOT trigger command_palette import at module load."""
|
|
||||||
res = _run_in_subprocess("""
|
res = _run_in_subprocess("""
|
||||||
# Fresh subprocess, just import commands
|
from src.gui_2 import render_palette_modal
|
||||||
import sys
|
print(callable(render_palette_modal))
|
||||||
import src.commands
|
|
||||||
# Verify decorator ran but did not trigger command_palette
|
|
||||||
# (the lazy proxy just queues; real init is deferred)
|
|
||||||
print('src.command_palette' in sys.modules)
|
|
||||||
# Verify the function references still exist
|
|
||||||
from src.commands import toggle_command_palette
|
|
||||||
print(callable(toggle_command_palette))
|
|
||||||
""")
|
""")
|
||||||
assert res.returncode == 0, f"stderr: {res.stderr}"
|
assert res.returncode == 0, f"stderr: {res.stderr}"
|
||||||
lines = res.stdout.strip().splitlines()
|
assert res.stdout.strip() == "True"
|
||||||
assert lines[0] == "False", f"Decorator should not trigger command_palette, got {lines[0]}"
|
|
||||||
assert lines[1] == "True", f"toggle_command_palette should be a callable, got {lines[1]}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_audit_main_thread_imports_sees_no_new_violation_from_commands() -> None:
|
def test_commands_does_not_import_gui_2_at_module_level() -> None:
|
||||||
"""Run the static audit and check that src/commands.py contributes no
|
"""src/commands is imported by src/commands registration sites and by
|
||||||
new command_palette violations.
|
gui_2.render_palette_modal. To avoid a circular import, commands.py must
|
||||||
|
NOT import gui_2 at the top of the file. (TYPE_CHECKING imports are
|
||||||
|
allowed because they don't execute at runtime.)
|
||||||
"""
|
"""
|
||||||
res = _run_in_subprocess("""
|
res = _run_in_subprocess("""
|
||||||
import ast
|
import ast
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
root = Path('.').resolve()
|
commands_path = Path('src') / 'commands.py'
|
||||||
commands_path = root / 'src' / 'commands.py'
|
source = commands_path.read_text(encoding='utf-8')
|
||||||
tree = ast.parse(commands_path.read_text(encoding='utf-8'))
|
tree = ast.parse(source)
|
||||||
heavy = ['src.command_palette', 'command_palette']
|
|
||||||
for node in tree.body:
|
for node in tree.body:
|
||||||
if isinstance(node, ast.Import):
|
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
||||||
for alias in node.names:
|
mod = getattr(node, 'module', None) or (node.names[0].name if node.names else '')
|
||||||
for h in heavy:
|
if mod and ('gui_2' in mod or mod.endswith('gui_2')):
|
||||||
if alias.name == h or alias.name.startswith(h + '.'):
|
print('VIOLATION:', mod)
|
||||||
print('VIOLATION:', alias.name)
|
|
||||||
elif isinstance(node, ast.ImportFrom):
|
|
||||||
if node.module:
|
|
||||||
for h in heavy:
|
|
||||||
if node.module == h or node.module.startswith(h + '.'):
|
|
||||||
print('VIOLATION:', node.module)
|
|
||||||
print('OK')
|
print('OK')
|
||||||
""")
|
""")
|
||||||
assert res.returncode == 0, f"stderr: {res.stderr}"
|
assert res.returncode == 0, f"stderr: {res.stderr}"
|
||||||
assert "OK" in res.stdout
|
|
||||||
assert "VIOLATION" not in res.stdout
|
assert "VIOLATION" not in res.stdout
|
||||||
|
assert "OK" in res.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_command_palette_module_no_longer_exists() -> None:
|
||||||
|
"""src/command_palette.py was deleted in Phase 1.3; this is a regression guard."""
|
||||||
|
res = _run_in_subprocess("""
|
||||||
|
import importlib
|
||||||
|
try:
|
||||||
|
importlib.import_module('src.command_palette')
|
||||||
|
print('EXISTS')
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
print('NOT_FOUND')
|
||||||
|
""")
|
||||||
|
assert res.returncode == 0, f"stderr: {res.stderr}"
|
||||||
|
assert res.stdout.strip() == "NOT_FOUND"
|
||||||
Reference in New Issue
Block a user