diff --git a/PROGRESS.md b/PROGRESS.md index 8a1895d..94ce4b3 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -491,3 +491,4 @@ Sprint 5 in progress. Step 141 (Emacs daemon with user config) done. Next: Step | 2026-02-09 | Codex | Added LLM tooling + MCP bridge feature request to FEATURE_REQUESTS.md. | | 2026-02-10 | Codex | Step 142: Emacs package browser panel with loaded/available status, load actions, and package queries via emacsclient. 7/7 tests pass. | | 2026-02-10 | Codex | Step 143: Elisp function discovery via apropos/describe-function, Emacs function indexing into ExternalModule + LibraryIndex, and elisp primitive updates. 7/7 tests pass. | +| 2026-02-10 | Codex | Step 144: Emacs keybinding integration (prefix handling, M-x minibuffer, mode line display, key-binding lookup via daemon). 11/11 tests pass. | diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index ca97d94..9761ffd 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -790,6 +790,10 @@ add_executable(step143_test tests/step143_test.cpp) target_include_directories(step143_test PRIVATE src) target_link_libraries(step143_test PRIVATE nlohmann_json::nlohmann_json imgui::imgui) +add_executable(step144_test tests/step144_test.cpp) +target_include_directories(step144_test PRIVATE src) +target_link_libraries(step144_test PRIVATE nlohmann_json::nlohmann_json imgui::imgui) + find_package(SDL2 CONFIG REQUIRED) find_package(OpenGL REQUIRED) find_package(glad CONFIG REQUIRED) diff --git a/editor/src/EditorState.h b/editor/src/EditorState.h index 01d82be..561234e 100644 --- a/editor/src/EditorState.h +++ b/editor/src/EditorState.h @@ -54,6 +54,7 @@ #include "EmacsIntegration.h" #include "EmacsPackageBrowser.h" #include "EmacsFunctionDiscovery.h" +#include "EmacsKeybinding.h" #include "ast/Serialization.h" #include "ast/Generator.h" #include "ast/Annotation.h" @@ -185,6 +186,7 @@ struct EditorState { EmacsPackageBrowserState emacsPackages; EmacsFunctionIndex emacsFunctionIndex; bool emacsFunctionIndexDirty = true; + EmacsKeybindingState emacsKeys; struct LibraryIndexRequest { std::string name; std::string version; @@ -1369,6 +1371,16 @@ struct EditorState { } } + bool handleEmacsKeyChord(const std::string& chord) { + if (layoutPreset != LayoutPreset::Emacs) return false; + return emacsHandleKeySequence(emacsKeys, emacs, chord, outputLog); + } + + void refreshEmacsModeLine(double nowSeconds) { + if (layoutPreset != LayoutPreset::Emacs) return; + updateEmacsModeLine(emacsKeys, emacs, nowSeconds, outputLog); + } + std::string buildRunCommand(const std::string& path, const std::string& language, bool buildOnly) const { diff --git a/editor/src/EmacsIntegration.h b/editor/src/EmacsIntegration.h index 64675bd..a4669fc 100644 --- a/editor/src/EmacsIntegration.h +++ b/editor/src/EmacsIntegration.h @@ -82,6 +82,30 @@ public: "(doc (or (documentation sym t) \"\"))) " "(concat (if args (prin1-to-string args) \"\") \"\\n\" doc))"; } + + // Build an Elisp expression to resolve a key binding to a command name + static std::string keyBinding(const std::string& keySequence) { + return "(let ((cmd (key-binding (kbd \"" + escapeString(keySequence) + "\")))) " + "(if cmd (symbol-name cmd) \"\"))"; + } + + // Build an Elisp expression to call a command interactively + static std::string callInteractive(const std::string& command) { + return "(call-interactively '" + escapeString(command) + ")"; + } + + // Build an Elisp expression to run an extended command by name + static std::string executeExtendedCommand(const std::string& command) { + std::string sym = escapeString(command); + return "(if (commandp '" + sym + ") " + "(call-interactively '" + sym + ") " + "(concat \"Unknown command: \" \"" + sym + "\"))"; + } + + // Build an Elisp expression for the current mode line + static std::string modeLine() { + return "(format-mode-line mode-line-format)"; + } }; // --------------------------------------------------------------------------- @@ -317,6 +341,21 @@ public: } return "()\nEmacs function."; } + if (elispCommand.find("key-binding") != std::string::npos) { + if (elispCommand.find("C-x C-s") != std::string::npos) { + return "save-buffer"; + } + if (elispCommand.find("C-x C-f") != std::string::npos) { + return "find-file"; + } + return ""; + } + if (elispCommand.find("call-interactively") != std::string::npos) { + return "t"; + } + if (elispCommand.find("format-mode-line") != std::string::npos) { + return "Emacs: Fundamental (Whetstone)"; + } // Unknown command — simulate error lastError_ = "void-function"; diff --git a/editor/src/EmacsKeybinding.h b/editor/src/EmacsKeybinding.h new file mode 100644 index 0000000..ab9c74a --- /dev/null +++ b/editor/src/EmacsKeybinding.h @@ -0,0 +1,96 @@ +#pragma once +#include "EmacsIntegration.h" +#include +#include +#include + +struct EmacsKeybindingState { + std::string prefix; + std::string lastCommand; + std::string lastMessage; + bool minibufferActive = false; + char minibufferBuf[256] = {}; + std::string minibufferPrompt = "M-x"; + std::string modeLine; + double lastModeQuery = 0.0; +}; + +static inline std::string emacsTrim(std::string value) { + auto notSpace = [](unsigned char c) { return !std::isspace(c); }; + value.erase(value.begin(), std::find_if(value.begin(), value.end(), notSpace)); + value.erase(std::find_if(value.rbegin(), value.rend(), notSpace).base(), value.end()); + return value; +} + +static inline bool emacsIsPrefixKey(const std::string& chord) { + return chord == "C-x" || chord == "C-c"; +} + +static inline bool emacsHandleKeySequence(EmacsKeybindingState& state, + EmacsConnection& emacs, + const std::string& chord, + std::string& logOut) { + if (chord.empty()) return false; + if (chord == "M-x") { + state.minibufferActive = true; + state.minibufferPrompt = "M-x"; + state.minibufferBuf[0] = '\0'; + state.prefix.clear(); + return true; + } + if (emacsIsPrefixKey(chord)) { + state.prefix = chord; + state.lastMessage = chord; + return true; + } + + std::string sequence = state.prefix.empty() ? chord : (state.prefix + " " + chord); + state.prefix.clear(); + + std::string cmd = emacs.sendCommand(ElispCommandBuilder::keyBinding(sequence)); + if (cmd.empty()) { + logOut += "[emacs] Unbound: " + sequence + "\n"; + return true; + } + state.lastCommand = cmd; + std::string result = emacs.sendCommand(ElispCommandBuilder::callInteractive(cmd)); + if (!emacs.getLastError().empty() && result == "error") { + logOut += "[emacs] " + emacs.getLastError() + "\n"; + } else { + logOut += "[emacs] " + cmd + "\n"; + } + return true; +} + +static inline bool emacsExecuteMinibuffer(EmacsKeybindingState& state, + EmacsConnection& emacs, + std::string& logOut) { + std::string cmd = emacsTrim(state.minibufferBuf); + if (cmd.empty()) { + state.minibufferActive = false; + return false; + } + std::string result = emacs.sendCommand(ElispCommandBuilder::executeExtendedCommand(cmd)); + if (!emacs.getLastError().empty() && result == "error") { + logOut += "[emacs] " + emacs.getLastError() + "\n"; + } else { + logOut += "[emacs] M-x " + cmd + "\n"; + } + state.minibufferActive = false; + state.minibufferBuf[0] = '\0'; + return true; +} + +static inline void updateEmacsModeLine(EmacsKeybindingState& state, + EmacsConnection& emacs, + double nowSeconds, + std::string& logOut) { + if (nowSeconds - state.lastModeQuery < 1.0) return; + std::string mode = emacs.sendCommand(ElispCommandBuilder::modeLine()); + if (!emacs.getLastError().empty() && mode == "error") { + logOut += "[emacs] " + emacs.getLastError() + "\n"; + return; + } + if (!mode.empty()) state.modeLine = mode; + state.lastModeQuery = nowSeconds; +} diff --git a/editor/src/main.cpp b/editor/src/main.cpp index a7a41fd..5500acd 100644 --- a/editor/src/main.cpp +++ b/editor/src/main.cpp @@ -11,11 +11,52 @@ #include "imgui_impl_opengl3.h" #include #include +#include #include "EditorState.h" #include "EditorUtils.h" #include "CompletionUtils.h" +static std::string emacsChordForEvent(SDL_Keycode sym, SDL_Keymod mods) { + std::string base; + if (sym >= SDLK_a && sym <= SDLK_z) { + char c = (char)('a' + (sym - SDLK_a)); + if (mods & KMOD_SHIFT) c = (char)std::toupper(c); + base.push_back(c); + } else if (sym >= SDLK_0 && sym <= SDLK_9) { + base.push_back((char)('0' + (sym - SDLK_0))); + } else if (sym == SDLK_SPACE) { + base = "SPC"; + } else if (sym == SDLK_RETURN || sym == SDLK_KP_ENTER) { + base = "RET"; + } else if (sym == SDLK_TAB) { + base = "TAB"; + } else if (sym == SDLK_BACKSPACE) { + base = "DEL"; + } else if (sym == SDLK_ESCAPE) { + base = "ESC"; + } else if (sym == SDLK_MINUS) { + base = "-"; + } else if (sym == SDLK_EQUALS) { + base = "="; + } else if (sym == SDLK_SLASH) { + base = "/"; + } else if (sym == SDLK_SEMICOLON) { + base = ";"; + } else if (sym == SDLK_PERIOD) { + base = "."; + } + + if (base.empty()) return ""; + + std::string prefix; + if (mods & KMOD_CTRL) prefix += "C-"; + if (mods & KMOD_ALT) prefix += "M-"; + if ((mods & KMOD_SHIFT) && base.size() > 1) prefix += "S-"; + + return prefix + base; +} + // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- @@ -122,6 +163,13 @@ int main(int, char**) { if (event.type == SDL_KEYDOWN && !io.WantTextInput) { int key = 0; auto sym = event.key.keysym.sym; + auto sdlMod = event.key.keysym.mod; + if (state.layoutPreset == LayoutPreset::Emacs) { + std::string chord = emacsChordForEvent(sym, sdlMod); + if (!chord.empty() && state.handleEmacsKeyChord(chord)) { + continue; + } + } if (sym >= SDLK_a && sym <= SDLK_z) key = 'A' + (sym - SDLK_a); else if (sym >= SDLK_0 && sym <= SDLK_9) key = '0' + (sym - SDLK_0); else if (sym == SDLK_EQUALS) key = '='; @@ -132,7 +180,6 @@ int main(int, char**) { else if (sym == SDLK_BACKQUOTE) key = '`'; int mods = WMOD_NONE; - auto sdlMod = event.key.keysym.mod; if (sdlMod & KMOD_CTRL) mods |= WMOD_CTRL; if (sdlMod & KMOD_SHIFT) mods |= WMOD_SHIFT; if (sdlMod & KMOD_ALT) mods |= WMOD_ALT; @@ -196,6 +243,7 @@ int main(int, char**) { if (state.emacsFunctionIndexDirty) { state.updateEmacsFunctionIndex(); } + state.refreshEmacsModeLine(ImGui::GetTime()); // Start frame ImGui_ImplOpenGL3_NewFrame(); @@ -624,6 +672,31 @@ int main(int, char**) { ImGui::End(); } + if (state.layoutPreset == LayoutPreset::Emacs && state.emacsKeys.minibufferActive) { + ImGuiWindowFlags mbFlags = ImGuiWindowFlags_NoDecoration | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoNavFocus; + float mbHeight = ImGui::GetFrameHeight() + 10; + ImVec2 mbPos(viewport->WorkPos.x, + viewport->WorkPos.y + viewport->WorkSize.y - mbHeight - 24.0f); + ImVec2 mbSize(viewport->WorkSize.x, mbHeight); + ImGui::SetNextWindowPos(mbPos); + ImGui::SetNextWindowSize(mbSize); + ImGui::Begin("##Minibuffer", nullptr, mbFlags); + ImGui::PushFont(uiFont); + ImGui::TextUnformatted(state.emacsKeys.minibufferPrompt.c_str()); + ImGui::SameLine(); + ImGui::SetNextItemWidth(-1.0f); + if (ImGui::InputText("##minibuffer", + state.emacsKeys.minibufferBuf, + sizeof(state.emacsKeys.minibufferBuf), + ImGuiInputTextFlags_EnterReturnsTrue)) { + emacsExecuteMinibuffer(state.emacsKeys, state.emacs, state.outputLog); + } + ImGui::PopFont(); + ImGui::End(); + } + // --------------------------------------------------------------- // Find / Replace bar (floating at top of editor) // --------------------------------------------------------------- @@ -2462,6 +2535,10 @@ int main(int, char**) { // Keybinding profile ImGui::Text("Keys: %s", KeybindingManager::profileName(state.keys.getProfile())); ImGui::SameLine(0, 30); + if (state.layoutPreset == LayoutPreset::Emacs && !state.emacsKeys.modeLine.empty()) { + ImGui::Text("Mode: %s", state.emacsKeys.modeLine.c_str()); + ImGui::SameLine(0, 30); + } // Zoom ImGui::Text("Zoom: %d%%", zoomPercent(state.settings.getFontSize(), baseFontSize)); diff --git a/editor/tests/step144_test.cpp b/editor/tests/step144_test.cpp new file mode 100644 index 0000000..fe7d829 --- /dev/null +++ b/editor/tests/step144_test.cpp @@ -0,0 +1,49 @@ +// Step 144 TDD Test: Emacs keybinding deep integration +#include "EmacsKeybinding.h" +#include + +static void expect(bool cond, const std::string& name, int& passed, int& failed) { + if (cond) { + std::cout << "Test " << (passed + failed + 1) << " PASS: " << name << "\n"; + ++passed; + } else { + std::cout << "Test " << (passed + failed + 1) << " FAIL: " << name << "\n"; + ++failed; + } +} + +int main() { + int passed = 0; + int failed = 0; + + MockEmacsConnection mock; + EmacsKeybindingState state; + std::string log; + + bool prefixOk = emacsHandleKeySequence(state, mock, "C-x", log); + expect(prefixOk, "prefix handled", passed, failed); + expect(state.prefix == "C-x", "prefix stored", passed, failed); + + bool seqOk = emacsHandleKeySequence(state, mock, "C-s", log); + expect(seqOk, "sequence handled", passed, failed); + expect(state.prefix.empty(), "prefix cleared", passed, failed); + expect(state.lastCommand == "save-buffer", "command resolved", passed, failed); + expect(mock.getLastSentCommand().find("call-interactively") != std::string::npos, + "call-interactively sent", passed, failed); + + bool mxOk = emacsHandleKeySequence(state, mock, "M-x", log); + expect(mxOk, "M-x handled", passed, failed); + expect(state.minibufferActive, "minibuffer active", passed, failed); + + std::snprintf(state.minibufferBuf, sizeof(state.minibufferBuf), "find-file"); + bool mbOk = emacsExecuteMinibuffer(state, mock, log); + expect(mbOk, "minibuffer command executed", passed, failed); + expect(!state.minibufferActive, "minibuffer cleared", passed, failed); + + updateEmacsModeLine(state, mock, 2.0, log); + expect(!state.modeLine.empty(), "mode line updated", passed, failed); + + std::cout << "\n=== Step 144 Results: " << passed << " passed, " + << failed << " failed ===\n"; + return failed == 0 ? 0 : 1; +} diff --git a/sprint5_plan.md b/sprint5_plan.md index 92b87e2..2856414 100644 --- a/sprint5_plan.md +++ b/sprint5_plan.md @@ -194,7 +194,7 @@ functionality through the Whetstone UI. Feed into `PrimitivesRegistry` for constrained coding. *Modifies:* `ElispCommandBuilder`, `PrimitivesRegistry.h` -- [ ] **Step 144: Emacs keybinding deep integration** +- [x] **Step 144: Emacs keybinding deep integration** Go beyond the simple keybinding profile. In Emacs layout mode, support: - M-x command execution (sends to Emacs daemon) - Emacs-style prefix keys (C-x C-f, C-x C-s, C-c prefix maps)