Step 144: add Emacs keybinding integration

This commit is contained in:
Bill
2026-02-09 18:06:50 -07:00
parent 568fa38f46
commit abd41217d3
8 changed files with 280 additions and 2 deletions

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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";

View File

@@ -0,0 +1,96 @@
#pragma once
#include "EmacsIntegration.h"
#include <string>
#include <algorithm>
#include <cctype>
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;
}

View File

@@ -11,11 +11,52 @@
#include "imgui_impl_opengl3.h"
#include <SDL2/SDL.h>
#include <SDL2/SDL_opengl.h>
#include <cctype>
#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));

View File

@@ -0,0 +1,49 @@
// Step 144 TDD Test: Emacs keybinding deep integration
#include "EmacsKeybinding.h"
#include <iostream>
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;
}