From 8bad31e94c11d3ccac59268eafdf37c0699d7389 Mon Sep 17 00:00:00 2001 From: Bill Date: Mon, 9 Feb 2026 09:12:34 -0700 Subject: [PATCH] Step 81: code folding --- PROGRESS.md | 3 + editor/CMakeLists.txt | 4 + editor/src/CodeEditorWidget.h | 171 ++++++++++++++++++++++++++++++++++ editor/src/main.cpp | 1 + editor/tests/step81_test.cpp | 88 +++++++++++++++++ 5 files changed, 267 insertions(+) create mode 100644 editor/tests/step81_test.cpp diff --git a/PROGRESS.md b/PROGRESS.md index 3d136b0..65b3f43 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -176,6 +176,7 @@ All 38 steps implemented and passing. Each step has a corresponding test (`step1 - [x] Step 78: **IMPLEMENTED** — Line numbers and gutter: fixed-width gutter with right-aligned line numbers, current line highlight, gutter click selects line (2/2 tests pass) - [x] Step 79: **IMPLEMENTED** — Auto-indent and smart editing: EditorMode integration, enter auto-indent, tab inserts spaces, auto-close brackets/quotes (3/3 tests pass) - [x] Step 80: **IMPLEMENTED** — Scroll/selection/clipboard: shift-extend selection, double/triple click selection, Ctrl+C/X/V clipboard, drag select (3/3 tests pass) +- [x] Step 81: **IMPLEMENTED** — Code folding: tree-sitter fold regions, gutter fold toggles, hidden ranges with placeholders (2/2 tests pass) --- @@ -235,6 +236,7 @@ vcpkg's imgui 1.91.9 removed the `sdl2-binding` feature (only `sdl3-binding` exi **Step 78:** Compile and pass (2/2) **Step 79:** Compile and pass (3/3) **Step 80:** Compile and pass (3/3) +**Step 81:** Compile and pass (2/2) --- @@ -323,3 +325,4 @@ Sprint 4 in progress. Step 76 (LayoutManager) done. Next: Step 77 (custom code e | 2026-02-09 | Codex | Step 78: Line numbers and gutter. Gutter width auto-adjusts, current line highlight, gutter click selects line. 2/2 tests pass. | | 2026-02-09 | Codex | Step 79: Auto-indent and smart editing. EditorMode wired for newline indent, tab spacing, and bracket auto-close. 3/3 tests pass. | | 2026-02-09 | Codex | Step 80: Selection and clipboard operations (Ctrl+C/X/V, shift-extend, double/triple click). 3/3 tests pass. | +| 2026-02-09 | Codex | Step 81: Code folding with tree-sitter fold regions and gutter toggles. 2/2 tests pass. | diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 0ce4e99..3ed5ed6 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -339,6 +339,10 @@ add_executable(step80_test tests/step80_test.cpp) target_include_directories(step80_test PRIVATE src) target_link_libraries(step80_test PRIVATE imgui::imgui) +add_executable(step81_test tests/step81_test.cpp) +target_include_directories(step81_test PRIVATE src) +target_link_libraries(step81_test PRIVATE imgui::imgui unofficial::tree-sitter::tree-sitter tree_sitter_python tree_sitter_cpp tree_sitter_elisp) + find_package(SDL2 CONFIG REQUIRED) find_package(OpenGL REQUIRED) find_package(glad CONFIG REQUIRED) diff --git a/editor/src/CodeEditorWidget.h b/editor/src/CodeEditorWidget.h index ad331fd..f06f711 100644 --- a/editor/src/CodeEditorWidget.h +++ b/editor/src/CodeEditorWidget.h @@ -13,11 +13,13 @@ #include #include #include +#include struct CodeEditorOptions { bool showWhitespace = false; bool readOnly = false; EditorMode* mode = nullptr; + bool enableFolding = false; }; struct CodeEditorResult { @@ -26,6 +28,14 @@ struct CodeEditorResult { int lineCount = 0; float gutterWidth = 0.0f; int currentLine = 0; + int foldCount = 0; + bool anyFolded = false; +}; + +struct FoldRegion { + int startLine = 0; + int endLine = 0; + bool folded = false; }; class CodeEditorWidget { @@ -61,6 +71,12 @@ public: } } + if (options.enableFolding && options.mode) { + updateFolds(text, options.mode->getLanguage()); + } else { + folds_.clear(); + } + // Measure const float lineHeight = ImGui::GetTextLineHeightWithSpacing(); const float charAdvance = font->CalcTextSizeA(font->FontSize, FLT_MAX, -1.0f, "M").x; @@ -100,6 +116,10 @@ public: int clickCount = ImGui::GetIO().MouseClickedCount[0]; if (mouse.x < origin.x + gutterWidth || clickCount >= 3) { int line = lineFromMouseY(mouse.y, gutterBase.y, lineHeight, lineCount); + const FoldRegion* fold = findFoldAtLine(line); + if (fold && mouse.x < origin.x + 12.0f) { + toggleFoldAtLine(line); + } int lineStart = lineStarts[line]; int lineEnd = (line + 1 < lineCount) ? lineStarts[line + 1] : (int)text.size(); cursor_ = lineStart; @@ -146,7 +166,16 @@ public: (ImGui::GetWindowContentRegionMax().x - ImGui::GetWindowContentRegionMin().x) - gutterWidth); const int currentLine = lineFromPos(cursor_, lineStarts); + std::vector hiddenLines(lineCount, false); + for (const auto& f : folds_) { + if (!f.folded) continue; + for (int l = f.startLine + 1; l <= f.endLine && l < lineCount; ++l) { + hiddenLines[l] = true; + } + } + for (int ln = firstLine; ln <= lastLine; ++ln) { + if (ln >= 0 && ln < lineCount && hiddenLines[ln]) continue; int start = lineStarts[ln]; int end = (ln + 1 < lineCount) ? lineStarts[ln + 1] - 1 : (int)text.size(); int len = std::max(0, end - start); @@ -165,6 +194,17 @@ public: ImVec2 numPos(origin.x + gutterWidth - 4.0f - numSize.x, y); drawList->AddText(font, font->FontSize, numPos, IM_COL32(120, 120, 120, 255), numBuf); + // Fold indicator + const FoldRegion* fold = findFoldAtLine(ln); + if (fold) { + ImVec2 triCenter(origin.x + 6.0f, y + lineHeight * 0.5f); + if (fold->folded) { + drawTriangle(drawList, triCenter, IM_COL32(160, 160, 160, 255), true); + } else { + drawTriangle(drawList, triCenter, IM_COL32(160, 160, 160, 255), false); + } + } + // Current line highlight (text area only) if (ln == currentLine) { ImVec2 hlA(textBase.x, y); @@ -216,6 +256,12 @@ public: pos = spanEnd; } + + // Folded placeholder + if (fold && fold->folded) { + ImVec2 p(textBase.x + len * charAdvance + 6.0f, y); + drawList->AddText(font, font->FontSize, p, IM_COL32(140, 140, 140, 255), "{...}"); + } } // Cursor @@ -238,17 +284,33 @@ public: result.lineCount = lineCount; result.gutterWidth = gutterWidth; result.currentLine = currentLine; + result.foldCount = (int)folds_.size(); + result.anyFolded = std::any_of(folds_.begin(), folds_.end(), + [](const FoldRegion& f) { return f.folded; }); return result; } void setCursor(int pos) { cursor_ = pos; } int getCursor() const { return cursor_; } + const std::vector& getFoldRegions() const { return folds_; } + + void toggleFoldAtLine(int line) { + for (auto& f : folds_) { + if (f.startLine == line) { + f.folded = !f.folded; + return; + } + } + } private: int cursor_ = 0; int selStart_ = -1; int selEnd_ = -1; bool selecting_ = false; + std::vector folds_; + std::string lastFoldText_; + std::string lastFoldLang_; bool hasSelection() const { return selStart_ >= 0 && selEnd_ >= 0 && selStart_ != selEnd_; @@ -316,6 +378,115 @@ private: return std::max(width, charAdvance * 2.0f + pad * 2.0f); } + const FoldRegion* findFoldAtLine(int line) const { + for (const auto& f : folds_) { + if (f.startLine == line) return &f; + } + return nullptr; + } + + static void drawTriangle(ImDrawList* drawList, const ImVec2& center, ImU32 color, bool down) { + float s = 4.0f; + if (down) { + drawList->AddTriangleFilled( + ImVec2(center.x - s, center.y - s * 0.6f), + ImVec2(center.x + s, center.y - s * 0.6f), + ImVec2(center.x, center.y + s), + color); + } else { + drawList->AddTriangleFilled( + ImVec2(center.x - s, center.y - s), + ImVec2(center.x - s, center.y + s), + ImVec2(center.x + s, center.y), + color); + } + } + + void updateFolds(const std::string& text, const std::string& language) { + if (text == lastFoldText_ && language == lastFoldLang_) return; + lastFoldText_ = text; + lastFoldLang_ = language; + + std::vector newFolds; + if (text.empty()) { folds_.clear(); return; } + + const TSLanguage* lang = nullptr; + if (language == "python") lang = tree_sitter_python(); + else if (language == "cpp") lang = tree_sitter_cpp(); + else if (language == "elisp") lang = tree_sitter_elisp(); + if (!lang) { folds_.clear(); return; } + + TSParser* parser = ts_parser_new(); + ts_parser_set_language(parser, lang); + TSTree* tree = ts_parser_parse_string(parser, nullptr, text.c_str(), (uint32_t)text.size()); + TSNode root = ts_tree_root_node(tree); + + collectFoldNodes(root, language, newFolds); + + // Preserve folded state by startLine + for (auto& f : newFolds) { + for (const auto& old : folds_) { + if (old.startLine == f.startLine && old.endLine == f.endLine) { + f.folded = old.folded; + break; + } + } + } + + folds_ = std::move(newFolds); + + ts_tree_delete(tree); + ts_parser_delete(parser); + } + + static bool isFoldNodeType(const std::string& language, const char* type) { + if (language == "python") { + return strcmp(type, "function_definition") == 0 || + strcmp(type, "class_definition") == 0 || + strcmp(type, "if_statement") == 0 || + strcmp(type, "for_statement") == 0 || + strcmp(type, "while_statement") == 0 || + strcmp(type, "with_statement") == 0 || + strcmp(type, "try_statement") == 0; + } else if (language == "cpp") { + return strcmp(type, "function_definition") == 0 || + strcmp(type, "class_specifier") == 0 || + strcmp(type, "struct_specifier") == 0 || + strcmp(type, "namespace_definition") == 0 || + strcmp(type, "if_statement") == 0 || + strcmp(type, "for_statement") == 0 || + strcmp(type, "while_statement") == 0 || + strcmp(type, "compound_statement") == 0; + } else if (language == "elisp") { + return strcmp(type, "function_definition") == 0 || + strcmp(type, "lambda_expression") == 0 || + strcmp(type, "if_expression") == 0 || + strcmp(type, "while_expression") == 0 || + strcmp(type, "cond_expression") == 0; + } + return false; + } + + static void collectFoldNodes(TSNode node, const std::string& language, std::vector& out) { + if (ts_node_is_null(node)) return; + const char* type = ts_node_type(node); + if (isFoldNodeType(language, type)) { + TSPoint start = ts_node_start_point(node); + TSPoint end = ts_node_end_point(node); + if (end.row > start.row) { + FoldRegion f; + f.startLine = (int)start.row; + f.endLine = (int)end.row; + f.folded = false; + out.push_back(f); + } + } + uint32_t count = ts_node_child_count(node); + for (uint32_t i = 0; i < count; ++i) { + collectFoldNodes(ts_node_child(node, i), language, out); + } + } + void deleteSelection(std::string& text, bool& changed) { if (!hasSelection()) return; int a = std::min(selStart_, selEnd_); diff --git a/editor/src/main.cpp b/editor/src/main.cpp index faf8b8c..182c5a4 100644 --- a/editor/src/main.cpp +++ b/editor/src/main.cpp @@ -666,6 +666,7 @@ int main(int, char**) { CodeEditorOptions opts; opts.showWhitespace = state.showWhitespace; opts.mode = &state.mode; + opts.enableFolding = true; CodeEditorResult res = state.codeWidget.render("##editor", state.editBuf, state.highlights, opts, avail, monoFont); diff --git a/editor/tests/step81_test.cpp b/editor/tests/step81_test.cpp new file mode 100644 index 0000000..e610239 --- /dev/null +++ b/editor/tests/step81_test.cpp @@ -0,0 +1,88 @@ +// Step 81 TDD Test: Code folding +// +// Tests: +// 1. Fold regions detected for Python function +// 2. Toggle fold updates fold state + +#include +#include +#include "imgui.h" +#include "CodeEditorWidget.h" +#include "EditorMode.h" + +static void beginFrame() { + ImGuiIO& io = ImGui::GetIO(); + io.DisplaySize = ImVec2(800, 600); + io.DeltaTime = 1.0f / 60.0f; + ImGui::NewFrame(); +} + +static void endFrame() { + ImGui::Render(); +} + +int main() { + int passed = 0; + int failed = 0; + + IMGUI_CHECKVERSION(); + ImGui::CreateContext(); + ImGui::StyleColorsDark(); + ImGuiIO& io = ImGui::GetIO(); + io.Fonts->AddFontDefault(); + io.Fonts->Build(); + + // --- Test 1: Fold region detection --- + { + beginFrame(); + ImGui::SetNextWindowFocus(); + ImGui::Begin("TestWindow"); + CodeEditorWidget widget; + EditorMode mode("python"); + std::string text = "def f():\n x = 1\n y = 2\n"; + std::vector spans; + CodeEditorOptions opts; + opts.mode = &mode; + opts.enableFolding = true; + + CodeEditorResult res = widget.render("##editor", text, spans, opts, ImVec2(300, 200), ImGui::GetFont()); + (void)res; + ImGui::End(); + endFrame(); + + const auto& folds = widget.getFoldRegions(); + assert(!folds.empty() && "Should detect fold region"); + std::cout << "Test 1 PASS: Fold region detected" << std::endl; + ++passed; + } + + // --- Test 2: Toggle fold --- + { + beginFrame(); + ImGui::SetNextWindowFocus(); + ImGui::Begin("TestWindow2"); + CodeEditorWidget widget; + EditorMode mode("python"); + std::string text = "def f():\n x = 1\n y = 2\n"; + std::vector spans; + CodeEditorOptions opts; + opts.mode = &mode; + opts.enableFolding = true; + + widget.render("##editor2", text, spans, opts, ImVec2(300, 200), ImGui::GetFont()); + widget.toggleFoldAtLine(0); + ImGui::End(); + endFrame(); + + const auto& folds = widget.getFoldRegions(); + assert(!folds.empty()); + assert(folds[0].folded && "Fold should be toggled on"); + std::cout << "Test 2 PASS: Fold toggled" << std::endl; + ++passed; + } + + ImGui::DestroyContext(); + + std::cout << "\n=== Step 81 Results: " << passed << " passed, " << failed << " failed ===" << std::endl; + return failed > 0 ? 1 : 0; +}