diff --git a/PROGRESS.md b/PROGRESS.md index 94ce4b3..fd89c2e 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -492,3 +492,4 @@ Sprint 5 in progress. Step 141 (Emacs daemon with user config) done. Next: Step | 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. | +| 2026-02-10 | Codex | Step 145: Org-mode rendering with headings/blocks, editable source blocks, inline results, org temp runner, and tree-sitter-org integration. 11/11 tests pass. | diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 9761ffd..6c1cc90 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -76,6 +76,13 @@ FetchContent_Declare( GIT_SHALLOW TRUE ) +FetchContent_Declare( + tree-sitter-org + GIT_REPOSITORY https://github.com/milisims/tree-sitter-org.git + GIT_TAG v1.3.1 + GIT_SHALLOW TRUE +) + FetchContent_Declare( tinyfiledialogs GIT_REPOSITORY https://github.com/native-toolkit/tinyfiledialogs.git @@ -124,6 +131,11 @@ if(NOT tree-sitter-go_POPULATED) FetchContent_Populate(tree-sitter-go) endif() +FetchContent_GetProperties(tree-sitter-org) +if(NOT tree-sitter-org_POPULATED) + FetchContent_Populate(tree-sitter-org) +endif() + FetchContent_GetProperties(tinyfiledialogs) if(NOT tinyfiledialogs_POPULATED) FetchContent_Populate(tinyfiledialogs) @@ -204,6 +216,15 @@ add_library(tree_sitter_go STATIC ${_ts_go_src}) target_include_directories(tree_sitter_go PUBLIC ${tree-sitter-go_SOURCE_DIR}/src) target_link_libraries(tree_sitter_go PUBLIC unofficial::tree-sitter::tree-sitter) +# Build tree-sitter-org grammar as a static C library +set(_ts_org_src ${tree-sitter-org_SOURCE_DIR}/src/parser.c) +if(EXISTS "${tree-sitter-org_SOURCE_DIR}/src/scanner.c") + list(APPEND _ts_org_src "${tree-sitter-org_SOURCE_DIR}/src/scanner.c") +endif() +add_library(tree_sitter_org STATIC ${_ts_org_src}) +target_include_directories(tree_sitter_org PUBLIC ${tree-sitter-org_SOURCE_DIR}/src) +target_link_libraries(tree_sitter_org PUBLIC unofficial::tree-sitter::tree-sitter) + # tinyfiledialogs (single C file) add_library(tinyfiledialogs STATIC ${tinyfiledialogs_SOURCE_DIR}/tinyfiledialogs.c) target_include_directories(tinyfiledialogs PUBLIC ${tinyfiledialogs_SOURCE_DIR}) @@ -794,6 +815,10 @@ 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) +add_executable(step145_test tests/step145_test.cpp) +target_include_directories(step145_test PRIVATE src) +target_link_libraries(step145_test PRIVATE nlohmann_json::nlohmann_json imgui::imgui tree_sitter_org) + find_package(SDL2 CONFIG REQUIRED) find_package(OpenGL REQUIRED) find_package(glad CONFIG REQUIRED) @@ -834,6 +859,7 @@ target_include_directories(whetstone_editor PRIVATE ${VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}/include/imgui ${VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}/include ) +target_compile_definitions(whetstone_editor PRIVATE WHETSTONE_ENABLE_ORG=1) if(IMGUI_SDL2_BACKEND) get_filename_component(_sdl2_backend_dir "${IMGUI_SDL2_BACKEND}" DIRECTORY) target_include_directories(whetstone_editor PRIVATE "${_sdl2_backend_dir}") @@ -853,7 +879,8 @@ target_link_libraries(whetstone_editor PRIVATE tree_sitter_typescript tree_sitter_java tree_sitter_rust - tree_sitter_go) + tree_sitter_go + tree_sitter_org) target_link_libraries(whetstone_editor PRIVATE tinyfiledialogs) add_executable(orchestrator src/orchestrator_main.cpp) diff --git a/editor/src/EditorMode.h b/editor/src/EditorMode.h index 093f1cc..dcb1cec 100644 --- a/editor/src/EditorMode.h +++ b/editor/src/EditorMode.h @@ -47,6 +47,7 @@ enum class EditorModeType { Java, Rust, Go, + Org, PlainText }; @@ -66,6 +67,7 @@ public: else if (language == "java") loadJava(); else if (language == "rust") loadRust(); else if (language == "go") loadGo(); + else if (language == "org") loadOrg(); else loadPlainText(); } @@ -162,6 +164,7 @@ public: case EditorModeType::Java: return "Java"; case EditorModeType::Rust: return "Rust"; case EditorModeType::Go: return "Go"; + case EditorModeType::Org: return "Org"; case EditorModeType::PlainText: return "Plain Text"; } return "Unknown"; @@ -176,6 +179,7 @@ public: if (lang == "java") return EditorModeType::Java; if (lang == "rust") return EditorModeType::Rust; if (lang == "go") return EditorModeType::Go; + if (lang == "org") return EditorModeType::Org; return EditorModeType::PlainText; } @@ -308,6 +312,17 @@ private: }; } + void loadOrg() { + type_ = EditorModeType::Org; + indent_ = {"", "", 2, false}; + comment_ = {"#", "", ""}; + brackets_ = {{'(', ')'}, {'[', ']'}, {'{', '}'}, {'\"', '\"'}}; + snippets_ = { + {"src", "#+begin_src $1\\n$0\\n#+end_src", "Source block"}, + {"hdr", "* $1\\n$0", "Heading"}, + }; + } + std::string language_; EditorModeType type_; IndentRule indent_; diff --git a/editor/src/EditorState.h b/editor/src/EditorState.h index 561234e..051ad03 100644 --- a/editor/src/EditorState.h +++ b/editor/src/EditorState.h @@ -55,6 +55,7 @@ #include "EmacsPackageBrowser.h" #include "EmacsFunctionDiscovery.h" #include "EmacsKeybinding.h" +#include "OrgMode.h" #include "ast/Serialization.h" #include "ast/Generator.h" #include "ast/Annotation.h" @@ -187,6 +188,9 @@ struct EditorState { EmacsFunctionIndex emacsFunctionIndex; bool emacsFunctionIndexDirty = true; EmacsKeybindingState emacsKeys; + bool showOrgPanel = true; + OrgDocumentState orgDoc; + int orgTempCounter = 0; struct LibraryIndexRequest { std::string name; std::string version; @@ -430,6 +434,8 @@ struct EditorState { void createBuffer(const std::string& path, const std::string& content, const std::string& language, BufferManager::BufferMode mode = BufferManager::BufferMode::Structured) { + BufferManager::BufferMode effectiveMode = mode; + if (language == "org") effectiveMode = BufferManager::BufferMode::Text; if (buffers.hasBuffer(path)) { buffers.switchToBuffer(path); activeBuffer = bufferStates[path].get(); @@ -450,8 +456,8 @@ struct EditorState { state->generatedHighlightsDirty = true; state->modified = false; state->lspVersion = 1; - buffers.openBuffer(path, content, language, mode); - state->bufferMode = mode; + buffers.openBuffer(path, content, language, effectiveMode); + state->bufferMode = effectiveMode; activeBuffer = state.get(); bufferStates[path] = std::move(state); active()->orchestratorDirty = true; @@ -802,6 +808,49 @@ struct EditorState { refreshBuildSystem(); } + std::string orgTempExtension(const std::string& language) const { + if (language == "python") return ".py"; + if (language == "cpp") return ".cpp"; + if (language == "elisp") return ".el"; + if (language == "javascript") return ".js"; + if (language == "typescript") return ".ts"; + if (language == "java") return ".java"; + if (language == "rust") return ".rs"; + if (language == "go") return ".go"; + return ".txt"; + } + + std::string writeOrgTempFile(const std::string& language, const std::string& code) { + std::filesystem::path root = workspaceRoot.empty() + ? std::filesystem::current_path() + : std::filesystem::path(workspaceRoot); + std::filesystem::path dir = root / ".whetstone_org"; + std::filesystem::create_directories(dir); + std::string name = "org_block_" + std::to_string(++orgTempCounter) + orgTempExtension(language); + std::filesystem::path filePath = dir / name; + std::ofstream out(filePath.string(), std::ios::binary); + out << code; + return filePath.string(); + } + + std::string runOrgBlock(const std::string& language, const std::string& code) { + if (language == "elisp") { + std::string result = emacs.sendCommand(ElispCommandBuilder::eval(code)); + if (!emacs.getLastError().empty() && result == "error") { + return emacs.getLastError(); + } + return result.empty() ? "OK" : result; + } + std::string path = writeOrgTempFile(language, code); + std::string cmd = buildRunCommand(path, language, false); + if (cmd.empty()) return "No runner for language: " + language; + showTerminalPanel = true; + std::string output; + int codeExit = terminal.runAndCapture(workspaceRoot, cmd, output); + output += "[exit code: " + std::to_string(codeExit) + "]"; + return output; + } + void startEmacsDaemonFromSettings() { emacsDiagnostics.clear(); std::string initPath = resolveEmacsInitPath(); @@ -1369,6 +1418,10 @@ struct EditorState { if (lang == "elisp") { emacsFunctionIndexDirty = true; } + if (lang == "org") { + active()->bufferMode = BufferManager::BufferMode::Text; + buffers.setBufferMode(active()->path, active()->bufferMode); + } } bool handleEmacsKeyChord(const std::string& chord) { @@ -1569,6 +1622,8 @@ struct EditorState { language = "rust"; else if (path.size() > 3 && path.substr(path.size() - 3) == ".go") language = "go"; + else if (path.size() > 4 && path.substr(path.size() - 4) == ".org") + language = "org"; createBuffer(path, content, language, modeOverride); welcome.addRecentFile(path, language, bufferModeToString(modeOverride)); saveRecentFiles(); diff --git a/editor/src/OrgMode.h b/editor/src/OrgMode.h new file mode 100644 index 0000000..1c46115 --- /dev/null +++ b/editor/src/OrgMode.h @@ -0,0 +1,239 @@ +#pragma once +#include "imgui.h" +#include "CodeEditorWidget.h" +#include "SyntaxHighlighter.h" +#include "EditorMode.h" +#include +#include +#include +#include +#include + +enum class OrgBlockType { + Heading, + Paragraph, + SourceBlock +}; + +struct OrgBlock { + OrgBlockType type = OrgBlockType::Paragraph; + int level = 0; + std::string title; + std::string text; + std::string language; + std::string code; + std::string result; +}; + +struct OrgDocumentState { + std::string source; + std::vector blocks; + std::vector editors; + std::vector modes; + std::vector sourceBlockMap; + bool dirty = true; +}; + +static inline std::string orgTrim(const std::string& s) { + size_t start = s.find_first_not_of(" \t"); + if (start == std::string::npos) return ""; + size_t end = s.find_last_not_of(" \t"); + return s.substr(start, end - start + 1); +} + +static inline bool orgStartsWith(const std::string& line, const std::string& prefix) { + return line.rfind(prefix, 0) == 0; +} + +static std::vector parseOrgBlocks(const std::string& text) { + std::vector blocks; + std::vector paragraphLines; + std::vector codeLines; + bool inSrc = false; + std::string srcLang; + + auto flushParagraph = [&]() { + if (paragraphLines.empty()) return; + OrgBlock block; + block.type = OrgBlockType::Paragraph; + std::ostringstream oss; + for (size_t i = 0; i < paragraphLines.size(); ++i) { + if (i) oss << "\n"; + oss << paragraphLines[i]; + } + block.text = oss.str(); + blocks.push_back(std::move(block)); + paragraphLines.clear(); + }; + + auto flushSource = [&]() { + OrgBlock block; + block.type = OrgBlockType::SourceBlock; + block.language = srcLang.empty() ? "text" : srcLang; + std::ostringstream oss; + for (size_t i = 0; i < codeLines.size(); ++i) { + if (i) oss << "\n"; + oss << codeLines[i]; + } + block.code = oss.str(); + blocks.push_back(std::move(block)); + codeLines.clear(); + srcLang.clear(); + }; + + std::istringstream ss(text); + std::string line; + while (std::getline(ss, line)) { + if (inSrc) { + if (orgStartsWith(orgTrim(line), "#+end_src")) { + inSrc = false; + flushSource(); + } else { + codeLines.push_back(line); + } + continue; + } + + std::string trimmed = orgTrim(line); + if (trimmed.empty()) { + flushParagraph(); + continue; + } + + if (orgStartsWith(trimmed, "#+begin_src")) { + flushParagraph(); + std::string rest = trimmed.substr(std::string("#+begin_src").size()); + srcLang = orgTrim(rest); + inSrc = true; + continue; + } + + if (!trimmed.empty() && trimmed[0] == '*') { + flushParagraph(); + size_t level = 0; + while (level < trimmed.size() && trimmed[level] == '*') ++level; + std::string title = orgTrim(trimmed.substr(level)); + OrgBlock block; + block.type = OrgBlockType::Heading; + block.level = (int)level; + block.title = title; + blocks.push_back(std::move(block)); + continue; + } + + paragraphLines.push_back(line); + } + + if (inSrc) { + flushSource(); + } + flushParagraph(); + + return blocks; +} + +static std::string buildOrgText(const std::vector& blocks) { + std::ostringstream out; + for (size_t i = 0; i < blocks.size(); ++i) { + const auto& block = blocks[i]; + if (block.type == OrgBlockType::Heading) { + out << std::string(std::max(1, block.level), '*') << " " << block.title << "\n"; + } else if (block.type == OrgBlockType::Paragraph) { + out << block.text << "\n"; + } else if (block.type == OrgBlockType::SourceBlock) { + out << "#+begin_src " << (block.language.empty() ? "text" : block.language) << "\n"; + out << block.code << "\n"; + out << "#+end_src\n"; + if (!block.result.empty()) { + out << "#+RESULTS:\n" << block.result << "\n"; + } + } + if (i + 1 < blocks.size()) out << "\n"; + } + return out.str(); +} + +static void updateOrgDocument(OrgDocumentState& state, const std::string& sourceText) { + if (!state.dirty && sourceText == state.source) return; + state.source = sourceText; + state.blocks = parseOrgBlocks(sourceText); + state.sourceBlockMap.clear(); + state.editors.clear(); + state.modes.clear(); + for (size_t i = 0; i < state.blocks.size(); ++i) { + if (state.blocks[i].type == OrgBlockType::SourceBlock) { + state.sourceBlockMap.push_back((int)i); + state.editors.emplace_back(); + state.modes.emplace_back(state.blocks[i].language); + } + } + state.dirty = false; +} + +static bool renderOrgDocument(OrgDocumentState& state, + const std::string& sourceText, + bool showWhitespace, + ImFont* monoFont, + ImFont* uiFont, + const std::function& onOrgTextChanged, + const std::function& onBlockChanged, + const std::function& onRunBlock) { + updateOrgDocument(state, sourceText); + bool changed = false; + + int srcIndex = 0; + for (size_t i = 0; i < state.blocks.size(); ++i) { + auto& block = state.blocks[i]; + ImGui::PushID((int)i); + if (block.type == OrgBlockType::Heading) { + ImGui::PushFont(uiFont ? uiFont : ImGui::GetFont()); + ImGui::Text("%s%s", std::string(block.level, '#').c_str(), block.title.c_str()); + ImGui::PopFont(); + } else if (block.type == OrgBlockType::Paragraph) { + ImGui::TextWrapped("%s", block.text.c_str()); + } else if (block.type == OrgBlockType::SourceBlock) { + ImGui::PushFont(uiFont ? uiFont : ImGui::GetFont()); + ImGui::Text("Source (%s)", block.language.c_str()); + ImGui::PopFont(); + + CodeEditorOptions opts; + opts.readOnly = false; + opts.showWhitespace = showWhitespace; + opts.showLineNumbers = true; + EditorMode& mode = state.modes[srcIndex]; + mode.setLanguage(block.language); + opts.mode = &mode; + + auto spans = SyntaxHighlighter::highlight(block.code, block.language); + CodeEditorResult result = state.editors[srcIndex].render( + "##orgBlockEditor", block.code, spans, opts, + ImVec2(0, 180), monoFont ? monoFont : ImGui::GetFont()); + + if (result.changed) { + changed = true; + onBlockChanged((int)i, block.language, block.code); + } + + if (ImGui::Button("Run Block")) { + block.result = onRunBlock(block.language, block.code); + changed = true; + } + if (!block.result.empty()) { + ImGui::SameLine(); + ImGui::TextDisabled("Result:"); + ImGui::BeginChild("##orgResult", ImVec2(0, 80), true); + ImGui::TextWrapped("%s", block.result.c_str()); + ImGui::EndChild(); + } + srcIndex++; + } + ImGui::Spacing(); + ImGui::PopID(); + } + + if (changed) { + std::string rebuilt = buildOrgText(state.blocks); + onOrgTextChanged(rebuilt); + } + return changed; +} diff --git a/editor/src/SyntaxHighlighter.h b/editor/src/SyntaxHighlighter.h index d12d233..0fa8dec 100644 --- a/editor/src/SyntaxHighlighter.h +++ b/editor/src/SyntaxHighlighter.h @@ -24,6 +24,12 @@ extern "C" { const TSLanguage* tree_sitter_java(); const TSLanguage* tree_sitter_rust(); const TSLanguage* tree_sitter_go(); +#if !defined(WHETSTONE_ENABLE_ORG) +#define WHETSTONE_ENABLE_ORG 0 +#endif +#if WHETSTONE_ENABLE_ORG + const TSLanguage* tree_sitter_org(); +#endif } enum class TokenCategory { @@ -65,6 +71,9 @@ public: else if (language == "java") lang = tree_sitter_java(); else if (language == "rust") lang = tree_sitter_rust(); else if (language == "go") lang = tree_sitter_go(); +#if WHETSTONE_ENABLE_ORG + else if (language == "org") lang = tree_sitter_org(); +#endif if (!lang) { ts_parser_delete(parser); @@ -85,6 +94,9 @@ public: else if (language == "java") walkJava(root, source, spans); else if (language == "rust") walkRust(root, source, spans); else if (language == "go") walkGo(root, source, spans); +#if WHETSTONE_ENABLE_ORG + else if (language == "org") walkOrgSimple(source, spans); +#endif ts_tree_delete(tree); ts_parser_delete(parser); @@ -732,4 +744,28 @@ private: } } } + + // --- Org ----------------------------------------------------------- + + static void walkOrgSimple(const std::string& source, + std::vector& spans) { + size_t start = 0; + while (start < source.size()) { + size_t end = source.find('\n', start); + if (end == std::string::npos) end = source.size(); + std::string line = source.substr(start, end - start); + std::string trimmed = line; + while (!trimmed.empty() && (trimmed.back() == '\r' || trimmed.back() == '\n')) { + trimmed.pop_back(); + } + if (!trimmed.empty() && trimmed[0] == '*') { + spans.push_back({(uint32_t)start, (uint32_t)end, TokenCategory::Keyword}); + } else if (trimmed.rfind("#+begin_src", 0) == 0 || + trimmed.rfind("#+end_src", 0) == 0 || + trimmed.rfind("#+", 0) == 0) { + spans.push_back({(uint32_t)start, (uint32_t)end, TokenCategory::Comment}); + } + start = end + 1; + } + } }; diff --git a/editor/src/main.cpp b/editor/src/main.cpp index 5500acd..c0dacc0 100644 --- a/editor/src/main.cpp +++ b/editor/src/main.cpp @@ -280,7 +280,7 @@ int main(int, char**) { } if (ImGui::MenuItem("Open...", state.keys.getBinding("file.open").toString().c_str())) { - auto path = FileDialog::openFile({"Open File", {"*.py","*.cpp","*.h","*.el","*.js","*.ts","*.java","*.rs","*.go"}, lastDialogPath}); + auto path = FileDialog::openFile({"Open File", {"*.py","*.cpp","*.h","*.el","*.js","*.ts","*.java","*.rs","*.go","*.org"}, lastDialogPath}); if (!path.empty()) { lastDialogPath = path; state.doOpen(path); @@ -429,6 +429,8 @@ int main(int, char**) { state.setLanguage("rust"); if (ImGui::MenuItem("Go", nullptr, state.active() && state.active()->language == "go")) state.setLanguage("go"); + if (ImGui::MenuItem("Org", nullptr, state.active() && state.active()->language == "org")) + state.setLanguage("org"); ImGui::EndMenu(); } if (ImGui::BeginMenu("Refactor")) { @@ -1256,117 +1258,138 @@ int main(int, char**) { } } - CodeEditorOptions opts; - opts.showWhitespace = state.showWhitespace; - opts.readOnly = buf->readOnly; - opts.mode = &buf->mode; - opts.enableFolding = true; - opts.showMinimap = state.showMinimap; - opts.showAnnotations = state.showAnnotations; - opts.showLineNumbers = state.showLineNumbers; - if (state.layoutPreset == LayoutPreset::Emacs) opts.annotationLayout = 1; - else if (state.layoutPreset == LayoutPreset::JetBrains) opts.annotationLayout = 2; - else opts.annotationLayout = 0; - opts.errorLines = &errorLines; - opts.warningLines = &warningLines; - opts.diagnostics = &diagRanges; - opts.annotations = &annoMarkers; - opts.suggestions = &suggestionMarkers; - opts.conflicts = &conflictMarkers; - CodeEditorResult res; - if (buf->bufferMode == BufferManager::BufferMode::Structured) { - opts.syncScrollX = &buf->splitScrollX; - opts.syncScrollY = &buf->splitScrollY; - opts.scrollMaster = true; - - ImGui::BeginTable("##editorSplit", 2, - ImGuiTableFlags_Resizable | ImGuiTableFlags_SizingStretchProp); - ImGui::TableSetupColumn("Source", ImGuiTableColumnFlags_WidthStretch, 0.55f); - ImGui::TableSetupColumn("Generated", ImGuiTableColumnFlags_WidthStretch, 0.45f); - ImGui::TableNextRow(); - ImGui::TableSetColumnIndex(0); - res = buf->widget.render("##editor", - buf->editBuf, buf->highlights, opts, ImVec2(0, avail.y), monoFont); - - ImGui::TableSetColumnIndex(1); - ImGui::BeginGroup(); - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted("Generated"); - ImGui::SameLine(); - const char* targetLabels[] = {"Python", "C++", "Elisp"}; - const char* targetValues[] = {"python", "cpp", "elisp"}; - int targetIndex = 0; - for (int i = 0; i < IM_ARRAYSIZE(targetValues); ++i) { - if (buf->generatedLanguage == targetValues[i]) { - targetIndex = i; - break; - } - } - ImGui::SetNextItemWidth(120.0f); - if (ImGui::Combo("##targetLang", &targetIndex, - targetLabels, IM_ARRAYSIZE(targetLabels))) { - buf->generatedLanguage = targetValues[targetIndex]; - buf->generatedMode.setLanguage(buf->generatedLanguage); - buf->generatedHighlightsDirty = true; - state.updateGenerated(); - } - ImVec2 genAvail = ImGui::GetContentRegionAvail(); - CodeEditorOptions genOpts; - genOpts.readOnly = true; - genOpts.showWhitespace = state.showWhitespace; - genOpts.mode = &buf->generatedMode; - genOpts.showLineNumbers = state.showLineNumbers; - genOpts.showCurrentLine = false; - genOpts.highlightLine = buf->generatedHighlightLine; - genOpts.syncScrollX = &buf->splitScrollX; - genOpts.syncScrollY = &buf->splitScrollY; - genOpts.scrollMaster = false; - buf->generatedWidget.render("##generated", - buf->generatedBuf, buf->generatedHighlights, genOpts, - ImVec2(0, genAvail.y), monoFont); - ImGui::EndGroup(); - ImGui::EndTable(); + if (buf->language == "org") { + renderOrgDocument(state.orgDoc, + buf->editBuf, + state.showWhitespace, + monoFont, + uiFont, + [&](const std::string& newText) { + buf->editBuf = newText; + state.onTextChanged(); + }, + [&](int, const std::string&, const std::string&) { + buf->modified = true; + }, + [&](const std::string& lang, const std::string& code) { + return state.runOrgBlock(lang, code); + }); + res.cursorByte = buf->widget.getCursor(); } else { - res = buf->widget.render("##editor", - buf->editBuf, buf->highlights, opts, avail, monoFont); + CodeEditorOptions opts; + opts.showWhitespace = state.showWhitespace; + opts.readOnly = buf->readOnly; + opts.mode = &buf->mode; + opts.enableFolding = true; + opts.showMinimap = state.showMinimap; + opts.showAnnotations = state.showAnnotations; + opts.showLineNumbers = state.showLineNumbers; + if (state.layoutPreset == LayoutPreset::Emacs) opts.annotationLayout = 1; + else if (state.layoutPreset == LayoutPreset::JetBrains) opts.annotationLayout = 2; + else opts.annotationLayout = 0; + opts.errorLines = &errorLines; + opts.warningLines = &warningLines; + opts.diagnostics = &diagRanges; + opts.annotations = &annoMarkers; + opts.suggestions = &suggestionMarkers; + opts.conflicts = &conflictMarkers; + + if (buf->bufferMode == BufferManager::BufferMode::Structured) { + opts.syncScrollX = &buf->splitScrollX; + opts.syncScrollY = &buf->splitScrollY; + opts.scrollMaster = true; + + ImGui::BeginTable("##editorSplit", 2, + ImGuiTableFlags_Resizable | ImGuiTableFlags_SizingStretchProp); + ImGui::TableSetupColumn("Source", ImGuiTableColumnFlags_WidthStretch, 0.55f); + ImGui::TableSetupColumn("Generated", ImGuiTableColumnFlags_WidthStretch, 0.45f); + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + res = buf->widget.render("##editor", + buf->editBuf, buf->highlights, opts, ImVec2(0, avail.y), monoFont); + + ImGui::TableSetColumnIndex(1); + ImGui::BeginGroup(); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted("Generated"); + ImGui::SameLine(); + const char* targetLabels[] = {"Python", "C++", "Elisp"}; + const char* targetValues[] = {"python", "cpp", "elisp"}; + int targetIndex = 0; + for (int i = 0; i < IM_ARRAYSIZE(targetValues); ++i) { + if (buf->generatedLanguage == targetValues[i]) { + targetIndex = i; + break; + } + } + ImGui::SetNextItemWidth(120.0f); + if (ImGui::Combo("##targetLang", &targetIndex, + targetLabels, IM_ARRAYSIZE(targetLabels))) { + buf->generatedLanguage = targetValues[targetIndex]; + buf->generatedMode.setLanguage(buf->generatedLanguage); + buf->generatedHighlightsDirty = true; + state.updateGenerated(); + } + ImVec2 genAvail = ImGui::GetContentRegionAvail(); + CodeEditorOptions genOpts; + genOpts.readOnly = true; + genOpts.showWhitespace = state.showWhitespace; + genOpts.mode = &buf->generatedMode; + genOpts.showLineNumbers = state.showLineNumbers; + genOpts.showCurrentLine = false; + genOpts.highlightLine = buf->generatedHighlightLine; + genOpts.syncScrollX = &buf->splitScrollX; + genOpts.syncScrollY = &buf->splitScrollY; + genOpts.scrollMaster = false; + buf->generatedWidget.render("##generated", + buf->generatedBuf, buf->generatedHighlights, genOpts, + ImVec2(0, genAvail.y), monoFont); + ImGui::EndGroup(); + ImGui::EndTable(); + } else { + res = buf->widget.render("##editor", + buf->editBuf, buf->highlights, opts, avail, monoFont); + } } - state.updateCursorPos(res.cursorByte); - if (res.ctrlClick && state.active()) { - state.goToDefinitionAt(res.ctrlClickLine, res.ctrlClickCol); - } - if (res.lineClicked && buf->bufferMode == BufferManager::BufferMode::Structured) { - Module* ast = state.activeAST(); - if (ast) { - int genLines = countLines(buf->generatedBuf); - int targetLine = std::max(0, std::min(res.clickedLine, genLines - 1)); - buf->generatedHighlightLine = targetLine; - } else { - buf->generatedHighlightLine = -1; + if (buf->language != "org") { + state.updateCursorPos(res.cursorByte); + if (res.ctrlClick && state.active()) { + state.goToDefinitionAt(res.ctrlClickLine, res.ctrlClickCol); } - } - if (res.changed) { - state.onTextChanged(); - state.completionPending = true; - state.completionLastChange = ImGui::GetTime(); - state.completionVisible = false; - state.completionSelected = 0; - if (state.lsp) state.lsp->clearCompletionItems(); - if (state.lsp && state.active() && state.active()->path.rfind("(untitled", 0) != 0) { - int cursor = state.active()->widget.getCursor(); - if (cursor > 0 && state.active()->editBuf[cursor - 1] == '(') { - int lineZero = std::max(0, state.active()->cursorLine - 1); - int colZero = std::max(0, state.active()->cursorCol - 1); - state.lsp->requestSignatureHelp(EditorState::toFileUri(state.active()->path), - lineZero, colZero); - } else if (cursor > 0 && state.active()->editBuf[cursor - 1] == ')') { - state.lsp->clearSignatureHelp(); + if (res.lineClicked && buf->bufferMode == BufferManager::BufferMode::Structured) { + Module* ast = state.activeAST(); + if (ast) { + int genLines = countLines(buf->generatedBuf); + int targetLine = std::max(0, std::min(res.clickedLine, genLines - 1)); + buf->generatedHighlightLine = targetLine; + } else { + buf->generatedHighlightLine = -1; } } - if (buf->bufferMode == BufferManager::BufferMode::Structured) { - state.analysisPending = true; - state.analysisLastChange = ImGui::GetTime(); + if (res.changed) { + state.onTextChanged(); + state.completionPending = true; + state.completionLastChange = ImGui::GetTime(); + state.completionVisible = false; + state.completionSelected = 0; + if (state.lsp) state.lsp->clearCompletionItems(); + if (state.lsp && state.active() && state.active()->path.rfind("(untitled", 0) != 0) { + int cursor = state.active()->widget.getCursor(); + if (cursor > 0 && state.active()->editBuf[cursor - 1] == '(') { + int lineZero = std::max(0, state.active()->cursorLine - 1); + int colZero = std::max(0, state.active()->cursorCol - 1); + state.lsp->requestSignatureHelp(EditorState::toFileUri(state.active()->path), + lineZero, colZero); + } else if (cursor > 0 && state.active()->editBuf[cursor - 1] == ')') { + state.lsp->clearSignatureHelp(); + } + } + if (buf->bufferMode == BufferManager::BufferMode::Structured) { + state.analysisPending = true; + state.analysisLastChange = ImGui::GetTime(); + } } } diff --git a/editor/tests/step145_test.cpp b/editor/tests/step145_test.cpp new file mode 100644 index 0000000..cada540 --- /dev/null +++ b/editor/tests/step145_test.cpp @@ -0,0 +1,49 @@ +// Step 145 TDD Test: Org-mode parsing and source blocks +#include "OrgMode.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; + + std::string text = + "* Title\n" + "\n" + "Some paragraph text.\n" + "\n" + "#+begin_src python\n" + "print('hi')\n" + "#+end_src\n"; + + auto blocks = parseOrgBlocks(text); + expect(blocks.size() == 3, "parsed block count", passed, failed); + expect(blocks[0].type == OrgBlockType::Heading, "heading block", passed, failed); + expect(blocks[0].title == "Title", "heading title", passed, failed); + expect(blocks[1].type == OrgBlockType::Paragraph, "paragraph block", passed, failed); + expect(blocks[2].type == OrgBlockType::SourceBlock, "source block", passed, failed); + expect(blocks[2].language == "python", "source language", passed, failed); + expect(blocks[2].code.find("print") != std::string::npos, "source code", passed, failed); + + std::string rebuilt = buildOrgText(blocks); + expect(rebuilt.find("#+begin_src python") != std::string::npos, "build org text", passed, failed); + + OrgDocumentState state; + updateOrgDocument(state, text); + expect(!state.blocks.empty(), "doc blocks stored", passed, failed); + expect(state.editors.size() == 1, "source editors count", passed, failed); + expect(state.modes.size() == 1, "source modes count", passed, failed); + + std::cout << "\n=== Step 145 Results: " << passed << " passed, " + << failed << " failed ===\n"; + return failed == 0 ? 0 : 1; +} diff --git a/sprint5_plan.md b/sprint5_plan.md index 2856414..c868431 100644 --- a/sprint5_plan.md +++ b/sprint5_plan.md @@ -203,7 +203,7 @@ functionality through the Whetstone UI. Keybindings loaded from user's Emacs config (key-binding query to daemon). *Modifies:* `KeybindingManager.h`, `LayoutManager.h` -- [ ] **Step 145: Org-mode and Literate programming support** +- [x] **Step 145: Org-mode and Literate programming support** Parse `.org` files using a tree-sitter-org grammar. Render org-mode documents with headings, source blocks, and prose. Source blocks are editable with full syntax highlighting and LSP support.