Step 109: command palette with fuzzy search
This commit is contained in:
@@ -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. |
|
||||
|
||||
@@ -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)
|
||||
|
||||
89
editor/src/CommandPalette.h
Normal file
89
editor/src/CommandPalette.h
Normal file
@@ -0,0 +1,89 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <unordered_map>
|
||||
#include <algorithm>
|
||||
|
||||
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<CommandEntry> search(const std::string& query) const {
|
||||
std::vector<CommandEntry> 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<CommandEntry> all() const {
|
||||
std::vector<CommandEntry> 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<std::string, CommandEntry> commands_;
|
||||
int useCounter_ = 0;
|
||||
};
|
||||
@@ -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 <map>
|
||||
#include <memory>
|
||||
#include <cstdlib>
|
||||
#include <functional>
|
||||
#include <unordered_map>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -145,6 +148,11 @@ struct EditorState {
|
||||
char refactorNameA[128] = {};
|
||||
char refactorNameB[128] = {};
|
||||
std::string refactorError;
|
||||
CommandPalette commandPalette;
|
||||
std::unordered_map<std::string, std::function<void()>> 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<void()> 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
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
36
editor/tests/step109_test.cpp
Normal file
36
editor/tests/step109_test.cpp
Normal file
@@ -0,0 +1,36 @@
|
||||
// Step 109 TDD Test: Command palette
|
||||
//
|
||||
// Tests:
|
||||
// 1. Fuzzy search matches and orders
|
||||
// 2. MRU ordering when query empty
|
||||
|
||||
#include <cassert>
|
||||
#include <iostream>
|
||||
#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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user