Step 81: code folding
This commit is contained in:
@@ -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. |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -13,11 +13,13 @@
|
||||
#include <vector>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
|
||||
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<bool> 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<FoldRegion>& 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<FoldRegion> 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<FoldRegion> 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<FoldRegion>& 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_);
|
||||
|
||||
@@ -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);
|
||||
|
||||
88
editor/tests/step81_test.cpp
Normal file
88
editor/tests/step81_test.cpp
Normal file
@@ -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 <cassert>
|
||||
#include <iostream>
|
||||
#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<HighlightSpan> 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<HighlightSpan> 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;
|
||||
}
|
||||
Reference in New Issue
Block a user