From e79e42bab1b9c6002339c2daf59e10bd78b94b4d Mon Sep 17 00:00:00 2001 From: Bill Date: Mon, 16 Feb 2026 09:03:01 -0700 Subject: [PATCH] Step 353: Command Palette (12/12 tests) --- editor/CMakeLists.txt | 4 + editor/src/CommandPalette.h | 128 +++++++++++++++++++++++- editor/tests/step353_test.cpp | 180 ++++++++++++++++++++++++++++++++++ progress.md | 38 +++++++ 4 files changed, 347 insertions(+), 3 deletions(-) create mode 100644 editor/tests/step353_test.cpp diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 4451822..89651f5 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -2106,4 +2106,8 @@ add_executable(step352_test tests/step352_test.cpp) target_include_directories(step352_test PRIVATE src) target_link_libraries(step352_test PRIVATE nlohmann_json::nlohmann_json) +add_executable(step353_test tests/step353_test.cpp) +target_include_directories(step353_test PRIVATE src) +target_link_libraries(step353_test PRIVATE nlohmann_json::nlohmann_json) + # Step 12: Dear ImGui shell scaffolding created (main.cpp exists but not built due to dependencies) diff --git a/editor/src/CommandPalette.h b/editor/src/CommandPalette.h index 2810eee..146565a 100644 --- a/editor/src/CommandPalette.h +++ b/editor/src/CommandPalette.h @@ -2,14 +2,20 @@ #include #include #include +#include #include +#include "KeybindingRegistry.h" +#include "PanelManager.h" +#include "Icons.h" struct CommandEntry { std::string id; std::string label; std::string shortcut; + std::string icon; std::string category; std::string inputHint; + std::vector aliases; int contextMask = 0; bool inlineInput = false; int lastUsed = 0; @@ -32,13 +38,33 @@ enum CommandContext : int { class CommandPalette { public: + void open() { + isOpen_ = true; + selectedIndex_ = 0; + } + + void close() { + isOpen_ = false; + } + + bool isOpen() const { return isOpen_; } + + void setQuery(const std::string& q) { + query_ = q; + selectedIndex_ = 0; + } + + const std::string& query() const { return query_; } + void registerCommand(const std::string& id, const std::string& label, const std::string& shortcut, const std::string& category = "", int contextMask = CommandContext_Any, const std::string& inputHint = "", - bool inlineInput = false) { + bool inlineInput = false, + const std::vector& aliases = {}, + const std::string& icon = "") { auto& entry = commands_[id]; entry.id = id; entry.label = label; @@ -47,6 +73,43 @@ public: entry.contextMask = contextMask; entry.inputHint = inputHint; entry.inlineInput = inlineInput; + entry.aliases = aliases; + entry.icon = icon; + } + + void registerFromKeybindings(const KeybindingRegistry& registry) { + for (const auto& action : registry.getActions()) { + const auto binding = registry.getBinding(action.id); + registerCommand( + action.id, + action.label, + binding.toString(), + action.category, + CommandContext_Any, + "", + false, + {action.id}, + WhetstoneIcons::Search + ); + } + } + + void registerPanelToggles(const PanelManager& panels) { + for (const auto& panel : panels.getPanelList()) { + std::string id = "toggle-" + panel.id; + std::string label = "Toggle " + panel.title; + registerCommand( + id, + label, + "", + "View", + CommandContext_Any, + "", + false, + {panel.id, panel.title}, + panel.icon.empty() ? WhetstoneIcons::Search : panel.icon + ); + } } void markUsed(const std::string& id) { @@ -67,9 +130,44 @@ public: if (query.empty()) { match.score = cmd.lastUsed > 0 ? 50 + cmd.lastUsed : 1; } else { + int bestScore = 0; + std::vector bestIndices; + bool found = false; + int score = 0; - if (!fuzzyMatch(cmd.label, query, score, match.matchIndices)) continue; - match.score = score; + std::vector indices; + if (fuzzyMatch(cmd.label, query, score, indices)) { + found = true; + bestScore = score + 20; // label match bias + bestIndices = indices; + } + if (fuzzyMatch(cmd.id, query, score, indices)) { + found = true; + if (score + 10 > bestScore) { + bestScore = score + 10; + bestIndices = indices; + } + } + if (fuzzyMatch(cmd.category, query, score, indices)) { + found = true; + if (score + 5 > bestScore) { + bestScore = score + 5; + bestIndices = indices; + } + } + for (const auto& alias : cmd.aliases) { + if (fuzzyMatch(alias, query, score, indices)) { + found = true; + if (score + 15 > bestScore) { + bestScore = score + 15; + bestIndices = indices; + } + } + } + + if (!found) continue; + match.score = bestScore; + match.matchIndices = bestIndices; if (contextOk) match.score += 25; else match.score -= 10; } @@ -105,6 +203,27 @@ public: return result; } + void moveSelection(int delta, int resultCount) { + if (resultCount <= 0) { + selectedIndex_ = 0; + return; + } + selectedIndex_ += delta; + if (selectedIndex_ < 0) selectedIndex_ = resultCount - 1; + if (selectedIndex_ >= resultCount) selectedIndex_ = 0; + } + + int selectedIndex() const { return selectedIndex_; } + + std::optional executeSelected(const std::vector& matches) { + if (matches.empty() || selectedIndex_ < 0 || selectedIndex_ >= (int)matches.size()) { + return std::nullopt; + } + const std::string id = matches[selectedIndex_].entry.id; + markUsed(id); + return id; + } + static bool fuzzyMatch(const std::string& text, const std::string& query, int& outScore, @@ -150,4 +269,7 @@ private: std::unordered_map commands_; int useCounter_ = 0; + bool isOpen_ = false; + std::string query_; + int selectedIndex_ = 0; }; diff --git a/editor/tests/step353_test.cpp b/editor/tests/step353_test.cpp new file mode 100644 index 0000000..9d6d4eb --- /dev/null +++ b/editor/tests/step353_test.cpp @@ -0,0 +1,180 @@ +// Step 353: Command Palette (12 tests) + +#include +#include +#include +#include +#include "CommandPalette.h" +#include "KeybindingRegistry.h" +#include "PanelManager.h" + +int main() { + int passed = 0; + + // Test 1: open/close state + { + CommandPalette cp; + assert(!cp.isOpen()); + cp.open(); + assert(cp.isOpen()); + cp.close(); + assert(!cp.isOpen()); + std::cout << "Test 1 PASSED: open/close state\n"; + passed++; + } + + // Test 2: fuzzy search by action label + { + CommandPalette cp; + cp.registerCommand("save-buffer", "Save Buffer", "Ctrl+S", "File"); + auto r = cp.search("svb"); + assert(!r.empty()); + assert(r[0].entry.id == "save-buffer"); + std::cout << "Test 2 PASSED: fuzzy search by label\n"; + passed++; + } + + // Test 3: fuzzy search by alias + { + CommandPalette cp; + cp.registerCommand("run-pipeline", "Run Pipeline", "F5", "Tools", + CommandContext_Any, "", false, {"build", "compile"}); + auto r = cp.search("bld"); + assert(!r.empty()); + assert(r[0].entry.id == "run-pipeline"); + std::cout << "Test 3 PASSED: fuzzy search by alias\n"; + passed++; + } + + // Test 4: register from keybindings + { + KeybindingRegistry reg; + reg.loadDefaults(); + CommandPalette cp; + cp.registerFromKeybindings(reg); + auto all = cp.all(); + assert(all.size() >= 20); + bool hasSave = false; + for (const auto& c : all) { + if (c.id == "save-buffer") { + hasSave = true; + assert(c.shortcut == "Ctrl+S"); + } + } + assert(hasSave); + std::cout << "Test 4 PASSED: register from keybindings\n"; + passed++; + } + + // Test 5: register panel toggles + { + PanelManager pm; + pm.registerDefaults(); + CommandPalette cp; + cp.registerPanelToggles(pm); + auto all = cp.all(); + bool hasFileTree = false; + for (const auto& c : all) { + if (c.id == "toggle-file-tree") { + hasFileTree = true; + assert(c.label.find("File Tree") != std::string::npos); + } + } + assert(hasFileTree); + std::cout << "Test 5 PASSED: register panel toggles\n"; + passed++; + } + + // Test 6: enter executes selected action + { + CommandPalette cp; + cp.registerCommand("open-file", "Open File", "Ctrl+O", "File"); + auto r = cp.search(""); + auto id = cp.executeSelected(r); + assert(id.has_value()); + assert(id.value() == "open-file"); + std::cout << "Test 6 PASSED: execute selected action\n"; + passed++; + } + + // Test 7: arrow navigation wraps + { + CommandPalette cp; + cp.registerCommand("a", "Action A", "", ""); + cp.registerCommand("b", "Action B", "", ""); + auto r = cp.search(""); + assert(!r.empty()); + cp.moveSelection(-1, (int)r.size()); + assert(cp.selectedIndex() == (int)r.size() - 1); + cp.moveSelection(1, (int)r.size()); + assert(cp.selectedIndex() == 0); + std::cout << "Test 7 PASSED: arrow navigation wraps\n"; + passed++; + } + + // Test 8: query reset resets selection + { + CommandPalette cp; + cp.registerCommand("x", "Action X", "", ""); + cp.registerCommand("y", "Action Y", "", ""); + auto r = cp.search(""); + cp.moveSelection(1, (int)r.size()); + assert(cp.selectedIndex() == 1); + cp.setQuery("a"); + assert(cp.selectedIndex() == 0); + std::cout << "Test 8 PASSED: query reset resets selection\n"; + passed++; + } + + // Test 9: recently used actions appear first on empty query + { + CommandPalette cp; + cp.registerCommand("open", "Open", "", ""); + cp.registerCommand("save", "Save", "", ""); + cp.markUsed("save"); + auto r = cp.search(""); + assert(!r.empty()); + assert(r[0].entry.id == "save"); + std::cout << "Test 9 PASSED: recent actions first\n"; + passed++; + } + + // Test 10: strict context filter + { + CommandPalette cp; + cp.registerCommand("editor-only", "Editor Only", "", "", + CommandContext_Editor); + cp.registerCommand("terminal-only", "Terminal Only", "", "", + CommandContext_Terminal); + auto editor = cp.search("", CommandContext_Editor, true); + assert(editor.size() == 1); + assert(editor[0].entry.id == "editor-only"); + std::cout << "Test 10 PASSED: strict context filter\n"; + passed++; + } + + // Test 11: search can match category text + { + CommandPalette cp; + cp.registerCommand("run", "Run Pipeline", "F5", "Tools"); + auto r = cp.search("tls"); + assert(!r.empty()); + assert(r[0].entry.id == "run"); + std::cout << "Test 11 PASSED: category text search\n"; + passed++; + } + + // Test 12: no-match query returns empty results + { + CommandPalette cp; + cp.registerCommand("save", "Save Buffer", "Ctrl+S", "File"); + auto r = cp.search("zzzz"); + assert(r.empty()); + std::cout << "Test 12 PASSED: no-match query returns empty\n"; + passed++; + } + + std::cout << "\nResults: " << passed << "/12\n"; + assert(passed == 12); + return 0; +} diff --git a/progress.md b/progress.md index 9afc787..8c9fd08 100644 --- a/progress.md +++ b/progress.md @@ -1752,6 +1752,44 @@ with shortcut columns, and multi-key chord strings in both text and symbol forms - `step351_test` — PASS (12/12) regression coverage - `step352_test` — PASS (12/12) new step coverage +### Step 353: Command Palette +**Status:** PASS (12/12 tests) + +Extended the command palette into a richer step-353 foundation: stateful open/close, +selection movement, execute-selected behavior, and broadened fuzzy search over labels, +ids, categories, and aliases. Added population helpers that pull actions from +`KeybindingRegistry` and panel toggles from `PanelManager`. + +**Files modified:** +- `editor/src/CommandPalette.h` — added: + - palette state APIs: `open()`, `close()`, `isOpen()`, `setQuery()`, `selectedIndex()` + - command metadata: `icon`, `aliases` + - registration helpers: `registerFromKeybindings(...)`, `registerPanelToggles(...)` + - expanded fuzzy search scoring across label/id/category/aliases + - selection navigation: `moveSelection(...)` + - execution helper: `executeSelected(...)` with recent-use tracking +- `editor/CMakeLists.txt` — `step353_test` target + +**Files created:** +- `editor/tests/step353_test.cpp` — 12 tests covering: + 1. open/close state + 2. fuzzy search by label + 3. fuzzy search by alias + 4. keybinding registry population + 5. panel toggle population + 6. execute selected action + 7. selection wrap behavior + 8. query reset behavior + 9. recent action ranking + 10. strict context filtering + 11. category-based matching + 12. no-match behavior + +**Verification run:** +- `step344_test` — PASS (12/12) command palette integration regression +- `step346_test` — PASS (8/8) layout + palette integration regression +- `step353_test` — PASS (12/12) new step coverage + # Roadmap Planning — Sprints 12-25+ ## Status: Planning Complete (Sprints 12-19 detailed, 20-25 in roadmap.md)