diff --git a/PROGRESS.md b/PROGRESS.md index 409accd..d64fd3c 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -212,6 +212,7 @@ All 38 steps implemented and passing. Each step has a corresponding test (`step1 - [x] Step 107b: **IMPLEMENTED** — Mode-specific UI behavior and feature gating (2/2 tests pass) - [x] Step 107c: **IMPLEMENTED** — Per-buffer mode persistence in recent files (2/2 tests pass) - [x] Step 108: **IMPLEMENTED** — Batch mutation UI with refactor actions and diff preview (3/3 tests pass) +- [x] Step 109: **IMPLEMENTED** — Command palette with fuzzy search and MRU ranking (2/2 tests pass) --- @@ -303,6 +304,7 @@ vcpkg's imgui 1.91.9 removed the `sdl2-binding` feature (only `sdl3-binding` exi **Step 107b:** Compile and pass (2/2) **Step 107c:** Compile and pass (2/2) **Step 108:** Compile and pass (3/3) +**Step 109:** Compile and pass (2/2) --- @@ -424,3 +426,4 @@ Sprint 4 in progress. Step 76 (LayoutManager) done. Next: Step 77 (custom code e | 2026-02-09 | Codex | Step 107b: Mode-specific UI behavior and feature gating. 2/2 tests pass. | | 2026-02-09 | Codex | Step 107c: Per-buffer mode persistence in recent files. 2/2 tests pass. | | 2026-02-09 | Codex | Step 108: Batch mutation UI with refactor actions and diff preview. 3/3 tests pass. | +| 2026-02-09 | Codex | Step 109: Command palette with fuzzy search and MRU ranking. 2/2 tests pass. | diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index da94b64..a7a22e7 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -594,6 +594,10 @@ add_executable(step108_test tests/step108_test.cpp) target_include_directories(step108_test PRIVATE src) target_link_libraries(step108_test PRIVATE nlohmann_json::nlohmann_json) +add_executable(step109_test tests/step109_test.cpp) +target_include_directories(step109_test PRIVATE src) +target_link_libraries(step109_test PRIVATE nlohmann_json::nlohmann_json) + find_package(SDL2 CONFIG REQUIRED) find_package(OpenGL REQUIRED) find_package(glad CONFIG REQUIRED) diff --git a/editor/src/CommandPalette.h b/editor/src/CommandPalette.h new file mode 100644 index 0000000..53a9190 --- /dev/null +++ b/editor/src/CommandPalette.h @@ -0,0 +1,89 @@ +#pragma once +#include +#include +#include +#include + +struct CommandEntry { + std::string id; + std::string label; + std::string shortcut; + int lastUsed = 0; +}; + +class CommandPalette { +public: + void registerCommand(const std::string& id, + const std::string& label, + const std::string& shortcut) { + auto& entry = commands_[id]; + entry.id = id; + entry.label = label; + entry.shortcut = shortcut; + } + + void markUsed(const std::string& id) { + auto it = commands_.find(id); + if (it == commands_.end()) return; + it->second.lastUsed = ++useCounter_; + } + + std::vector search(const std::string& query) const { + std::vector results; + for (const auto& [_, cmd] : commands_) { + if (query.empty()) { + results.push_back(cmd); + } else { + int score = fuzzyScore(cmd.label, query); + if (score > 0) { + CommandEntry ranked = cmd; + ranked.lastUsed = cmd.lastUsed + score * 1000; + results.push_back(ranked); + } + } + } + + std::sort(results.begin(), results.end(), [](const CommandEntry& a, const CommandEntry& b) { + if (a.lastUsed != b.lastUsed) return a.lastUsed > b.lastUsed; + return a.label < b.label; + }); + return results; + } + + std::vector all() const { + std::vector result; + for (const auto& [_, cmd] : commands_) result.push_back(cmd); + return result; + } + +private: + static int fuzzyScore(const std::string& text, const std::string& query) { + if (query.empty()) return 1; + int score = 0; + size_t pos = 0; + for (char qc : query) { + char q = toLower(qc); + bool found = false; + while (pos < text.size()) { + char t = toLower(text[pos]); + if (t == q) { + score += 10; + found = true; + ++pos; + break; + } + ++pos; + } + if (!found) return 0; + } + return score; + } + + static char toLower(char c) { + if (c >= 'A' && c <= 'Z') return char(c - 'A' + 'a'); + return c; + } + + std::unordered_map commands_; + int useCounter_ = 0; +}; diff --git a/editor/src/main.cpp b/editor/src/main.cpp index 7575b3d..8e4adc1 100644 --- a/editor/src/main.cpp +++ b/editor/src/main.cpp @@ -40,6 +40,7 @@ #include "DiffUtils.h" #include "EditorModePolicy.h" #include "RefactorActions.h" +#include "CommandPalette.h" #include "ast/Serialization.h" #include "ast/Generator.h" #include "ast/Annotation.h" @@ -57,6 +58,8 @@ #include #include #include +#include +#include #include // --------------------------------------------------------------------------- @@ -145,6 +148,11 @@ struct EditorState { char refactorNameA[128] = {}; char refactorNameB[128] = {}; std::string refactorError; + CommandPalette commandPalette; + std::unordered_map> commandHandlers; + bool showCommandPalette = false; + char commandQuery[128] = {}; + int commandSelected = 0; struct DiffState { bool active = false; bool preview = false; @@ -332,6 +340,97 @@ struct EditorState { workspaceRoot = std::filesystem::current_path().string(); fileTreeDirty = true; loadRecentFiles(); + registerCommands(); + } + + void registerCommand(const std::string& id, + const std::string& label, + const std::string& shortcut, + std::function fn) { + commandPalette.registerCommand(id, label, shortcut); + commandHandlers[id] = std::move(fn); + } + + void registerCommands() { + commandPalette = CommandPalette(); + commandHandlers.clear(); + + registerCommand("file.new", "File: New File", + keys.getBinding("file.new").toString(), + [this]() { + std::string lang = active() ? active()->language : "python"; + createBuffer(makeUntitledName(), "", lang); + }); + registerCommand("file.open", "File: Open...", + keys.getBinding("file.open").toString(), + [this]() { + std::string path = FileDialog::openFile( + {"Open File", {"*.py","*.cpp","*.h","*.el","*.js","*.ts","*.java","*.rs","*.go"}, + std::string()}); + if (!path.empty()) doOpen(path); + }); + registerCommand("file.save", "File: Save", + keys.getBinding("file.save").toString(), + [this]() { doSave(); }); + registerCommand("edit.undo", "Edit: Undo", + keys.getBinding("edit.undo").toString(), + [this]() { doUndo(); }); + registerCommand("edit.redo", "Edit: Redo", + keys.getBinding("edit.redo").toString(), + [this]() { doRedo(); }); + registerCommand("search.find", "Search: Find/Replace", + keys.getBinding("search.find").toString(), + [this]() { showFind = !showFind; }); + registerCommand("view.whitespace", "View: Toggle Whitespace", "", + [this]() { showWhitespace = !showWhitespace; }); + registerCommand("view.minimap", "View: Toggle Minimap", "", + [this]() { showMinimap = !showMinimap; }); + registerCommand("view.annotations", "View: Toggle Annotations", "", + [this]() { showAnnotations = !showAnnotations; }); + registerCommand("view.lsp", "View: LSP Servers...", "", + [this]() { showLspSettings = !showLspSettings; }); + registerCommand("mode.toggle", "Mode: Toggle Text/Structured", "", + [this]() { + if (!active()) return; + active()->bufferMode = active()->bufferMode == BufferManager::BufferMode::Text ? + BufferManager::BufferMode::Structured : BufferManager::BufferMode::Text; + buffers.setBufferMode(active()->path, active()->bufferMode); + if (active()->bufferMode == BufferManager::BufferMode::Text) { + suggestions.clear(); + whetstoneDiagnostics.clear(); + analysisPending = false; + } else { + onTextChanged(); + } + }); + registerCommand("refactor.rename", "Refactor: Rename Variable", "", + [this]() { + refactorAction = 1; + showRefactorPopup = true; + refactorNameA[0] = '\0'; + refactorNameB[0] = '\0'; + }); + registerCommand("refactor.extract", "Refactor: Extract Function", "", + [this]() { + refactorAction = 2; + showRefactorPopup = true; + refactorNameA[0] = '\0'; + refactorNameB[0] = '\0'; + }); + registerCommand("refactor.inline", "Refactor: Inline Variable", "", + [this]() { + refactorAction = 3; + showRefactorPopup = true; + refactorNameA[0] = '\0'; + refactorNameB[0] = '\0'; + }); + } + + void executeCommand(const std::string& id) { + auto it = commandHandlers.find(id); + if (it == commandHandlers.end()) return; + it->second(); + commandPalette.markUsed(id); } void setLanguage(const std::string& lang) { @@ -1275,6 +1374,12 @@ int main(int, char**) { if (sdlMod & KMOD_SHIFT) mods |= WMOD_SHIFT; if (sdlMod & KMOD_ALT) mods |= WMOD_ALT; + if ((sdlMod & KMOD_CTRL) && (sdlMod & KMOD_SHIFT) && sym == SDLK_p) { + state.showCommandPalette = true; + state.commandQuery[0] = '\0'; + state.commandSelected = 0; + } + if (key != 0 && mods != WMOD_NONE) { KeyCombo combo{key, mods}; std::string action = state.keys.getAction(combo); @@ -1460,8 +1565,10 @@ int main(int, char**) { if (ImGui::BeginMenu("Keybindings")) { for (auto p : KeybindingManager::availableProfiles()) { if (ImGui::MenuItem(KeybindingManager::profileName(p), nullptr, - state.keys.getProfile() == p)) + state.keys.getProfile() == p)) { state.keys.setProfile(p); + state.registerCommands(); + } } ImGui::EndMenu(); } @@ -1664,6 +1771,52 @@ int main(int, char**) { ImGui::EndPopup(); } + // --------------------------------------------------------------- + // Command Palette + // --------------------------------------------------------------- + if (state.showCommandPalette) { + ImGui::Begin("Command Palette", &state.showCommandPalette, + ImGuiWindowFlags_AlwaysAutoResize); + ImGui::SetNextItemWidth(420.0f); + if (ImGui::IsWindowAppearing()) { + ImGui::SetKeyboardFocusHere(); + } + if (ImGui::InputText("##cmdQuery", state.commandQuery, sizeof(state.commandQuery))) { + state.commandSelected = 0; + } + + auto results = state.commandPalette.search(state.commandQuery); + int maxIndex = std::max(0, (int)results.size() - 1); + if (ImGui::IsKeyPressed(ImGuiKey_DownArrow)) { + state.commandSelected = std::min(state.commandSelected + 1, maxIndex); + } + if (ImGui::IsKeyPressed(ImGuiKey_UpArrow)) { + state.commandSelected = std::max(state.commandSelected - 1, 0); + } + bool accept = ImGui::IsKeyPressed(ImGuiKey_Enter); + bool dismiss = ImGui::IsKeyPressed(ImGuiKey_Escape); + + if (dismiss) { + state.showCommandPalette = false; + } + + for (int i = 0; i < (int)results.size(); ++i) { + const auto& cmd = results[i]; + bool selected = (i == state.commandSelected); + std::string label = cmd.label; + if (!cmd.shortcut.empty()) label += " [" + cmd.shortcut + "]"; + if (ImGui::Selectable(label.c_str(), selected)) { + state.commandSelected = i; + accept = true; + } + } + if (accept && !results.empty()) { + state.executeCommand(results[state.commandSelected].id); + state.showCommandPalette = false; + } + ImGui::End(); + } + // --------------------------------------------------------------- // Editor (center) — editable text area // --------------------------------------------------------------- diff --git a/editor/tests/step109_test.cpp b/editor/tests/step109_test.cpp new file mode 100644 index 0000000..544aa1d --- /dev/null +++ b/editor/tests/step109_test.cpp @@ -0,0 +1,36 @@ +// Step 109 TDD Test: Command palette +// +// Tests: +// 1. Fuzzy search matches and orders +// 2. MRU ordering when query empty + +#include +#include +#include "CommandPalette.h" + +int main() { + int passed = 0; + int failed = 0; + + CommandPalette palette; + palette.registerCommand("file.open", "File: Open", "Ctrl+O"); + palette.registerCommand("file.save", "File: Save", "Ctrl+S"); + palette.registerCommand("edit.find", "Edit: Find", "Ctrl+F"); + + auto results = palette.search("op"); + assert(!results.empty()); + assert(results[0].id == "file.open"); + std::cout << "Test 1 PASS: fuzzy search" << std::endl; + ++passed; + + palette.markUsed("edit.find"); + palette.markUsed("file.save"); + auto all = palette.search(""); + assert(all.size() == 3); + assert(all[0].id == "file.save"); + std::cout << "Test 2 PASS: MRU ordering" << std::endl; + ++passed; + + std::cout << "\n=== Step 109 Results: " << passed << " passed, " << failed << " failed ===" << std::endl; + return failed > 0 ? 1 : 0; +} diff --git a/sprint4_plan.md b/sprint4_plan.md index f0f990c..0ac6f61 100644 --- a/sprint4_plan.md +++ b/sprint4_plan.md @@ -275,27 +275,27 @@ Surface the code generation and optimization pipeline in the UI. transform created/modified it. Color-coded by transform type. *Wires:* `IncrementalOptimizer.h` -- [ ] **Step 107: Before/after diff view** +- [x] **Step 107: Before/after diff view** When a transform is applied, show a diff view: original code on left, transformed on right. Changed lines highlighted in green/red. Accept/reject per-transform. "Preview" mode shows diff without applying. *Modifies:* `main.cpp` - - [ ] **Step 107a: Text-Editor Mode toggle** + - [x] **Step 107a: Text-Editor Mode toggle** Add a per-buffer mode selector (Text vs Structured) in status bar or View menu. Text mode disables AST sync, generator/projection, and optimization panels. *Modifies:* `main.cpp`, `TextASTSync.h` (if needed for bypass) - - [ ] **Step 107b: UI behavior by mode** + - [x] **Step 107b: UI behavior by mode** Hide or disable AST/Generated/Transforms/Optimize panels in Text mode. Generated split view collapses to single editor in Text mode. *Modifies:* `main.cpp`, `CodeEditorWidget.h` - - [ ] **Step 107c: Buffer persistence** + - [x] **Step 107c: Buffer persistence** Store mode per buffer and preserve on open/reopen; include in recent files. *Modifies:* `BufferManager.h`, `main.cpp`, `WelcomeScreen` persistence if needed - - [ ] **Step 108: Batch mutation UI** + - [x] **Step 108: Batch mutation UI** "Refactor" menu with batch operations: "Rename Variable", "Extract Function", "Inline Variable". Each uses `BatchMutationAPI.applySequence()` with atomic rollback on failure. Preview changes in diff view before applying. @@ -308,7 +308,7 @@ Surface the code generation and optimization pipeline in the UI. IDE-quality navigation, powered by LSP for standard features and ContextAPI for Whetstone-specific AST navigation. -- [ ] **Step 109: Command palette** +- [x] **Step 109: Command palette** Ctrl+Shift+P opens a fuzzy-searchable command list. All menu actions registered as commands. Type to filter. Shows keybinding next to each command. Most-recently-used commands float to top. Extensible for plugins.