diff --git a/PROGRESS.md b/PROGRESS.md index cc0ea33..93dd025 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -173,6 +173,7 @@ All 38 steps implemented and passing. Each step has a corresponding test (`step1 ### Phase 4a: Layout & Code Editor Core (Steps 76–83) — In Progress - [x] Step 76: **IMPLEMENTED** — LayoutManager: 3 preset docking layouts (VSCode/Emacs/JetBrains), panel visibility/ratio queries, dirty flag for rebuild, save/load persistence, preset name round-trip (10/10 tests pass) - [x] Step 77: **IMPLEMENTED** — Custom CodeEditorWidget renderer: per-token coloring, cursor/selection/input handling, monospace grid, blinking cursor, visible whitespace toggle (3/3 tests pass) +- [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) --- @@ -229,6 +230,7 @@ vcpkg's imgui 1.91.9 removed the `sdl2-binding` feature (only `sdl3-binding` exi **Steps 72–75:** All compile and pass (step72: 6/6, step73: 8/8, step74: 6/6, step75: 6/6) **Step 76:** Compile and pass (10/10) **Step 77:** Compile and pass (3/3) +**Step 78:** Compile and pass (2/2) --- @@ -314,3 +316,4 @@ Sprint 4 in progress. Step 76 (LayoutManager) done. Next: Step 77 (custom code e | 2026-02-09 | Claude Opus 4.6 | Phase 3h complete: Integration & Validation (Steps 72–75). Pipeline (end-to-end parse→infer→validate→optimize→generate across Python/C++). Error handling (8 edge cases: null roots, empty ASTs, nonexistent IDs). Performance benchmarks (1000-fn AST in 1ms, JSON round-trip 4ms). APIDocGenerator (23 components, 6 categories, markdown output). 26/26 tests pass. **Sprint 3 complete: all 75 steps done.** | | 2026-02-09 | Claude Opus 4.6 | Step 76: LayoutManager with 3 preset docking layouts (VSCode/Emacs/JetBrains). Panel visibility/ratio queries, dirty flag, save/load persistence. 10/10 tests pass. Sprint 4 started. | | 2026-02-09 | Codex | Step 77: Custom code editor renderer (CodeEditorWidget). Per-token coloring, cursor/selection/input, blinking cursor, whitespace toggle. 3/3 tests pass. | +| 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. | diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index b5ed328..ec3abe7 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -327,6 +327,10 @@ add_executable(step77_test tests/step77_test.cpp) target_include_directories(step77_test PRIVATE src) target_link_libraries(step77_test PRIVATE imgui::imgui) +add_executable(step78_test tests/step78_test.cpp) +target_include_directories(step78_test PRIVATE src) +target_link_libraries(step78_test PRIVATE imgui::imgui) + 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 f7dafeb..1d6f42f 100644 --- a/editor/src/CodeEditorWidget.h +++ b/editor/src/CodeEditorWidget.h @@ -20,6 +20,9 @@ struct CodeEditorOptions { struct CodeEditorResult { bool changed = false; int cursorByte = 0; + int lineCount = 0; + float gutterWidth = 0.0f; + int currentLine = 0; }; class CodeEditorWidget { @@ -58,6 +61,7 @@ public: // Measure const float lineHeight = ImGui::GetTextLineHeightWithSpacing(); const float charAdvance = font->CalcTextSizeA(font->FontSize, FLT_MAX, -1.0f, "M").x; + const float gutterWidth = calcGutterWidth(lineCount, font, charAdvance); // Estimate content size for scrollbars int maxLineLen = 0; @@ -67,7 +71,8 @@ public: int len = std::max(0, end - start); maxLineLen = std::max(maxLineLen, len); } - ImVec2 contentSize(maxLineLen * charAdvance + 4.0f, lineCount * lineHeight + 4.0f); + ImVec2 contentSize(gutterWidth + maxLineLen * charAdvance + 4.0f, + lineCount * lineHeight + 4.0f); ImGui::BeginChild(id, size, false, ImGuiWindowFlags_HorizontalScrollbar); ImVec2 origin = ImGui::GetCursorScreenPos(); @@ -78,7 +83,8 @@ public: ImDrawList* drawList = ImGui::GetWindowDrawList(); const float scrollX = ImGui::GetScrollX(); const float scrollY = ImGui::GetScrollY(); - ImVec2 base(origin.x - scrollX, origin.y - scrollY); + ImVec2 gutterBase(origin.x, origin.y - scrollY); + ImVec2 textBase(origin.x + gutterWidth - scrollX, origin.y - scrollY); // Focus and input const bool hovered = ImGui::IsWindowHovered(); @@ -87,14 +93,27 @@ public: // Mouse handling if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { ImGui::SetKeyboardFocusHere(-1); - cursor_ = positionFromMouse(ImGui::GetMousePos(), base, lineStarts, text, charAdvance, lineHeight); - selStart_ = cursor_; - selEnd_ = cursor_; + const ImVec2 mouse = ImGui::GetMousePos(); + if (mouse.x < origin.x + gutterWidth) { + int line = lineFromMouseY(mouse.y, gutterBase.y, lineHeight, lineCount); + int lineStart = lineStarts[line]; + int lineEnd = (line + 1 < lineCount) ? lineStarts[line + 1] : (int)text.size(); + cursor_ = lineStart; + selStart_ = lineStart; + selEnd_ = lineEnd; + } else { + cursor_ = positionFromMouse(mouse, textBase, lineStarts, text, charAdvance, lineHeight); + selStart_ = cursor_; + selEnd_ = cursor_; + } selecting_ = true; } if (hovered && selecting_ && ImGui::IsMouseDown(ImGuiMouseButton_Left)) { - int pos = positionFromMouse(ImGui::GetMousePos(), base, lineStarts, text, charAdvance, lineHeight); - selEnd_ = pos; + const ImVec2 mouse = ImGui::GetMousePos(); + if (mouse.x >= origin.x + gutterWidth) { + int pos = positionFromMouse(mouse, textBase, lineStarts, text, charAdvance, lineHeight); + selEnd_ = pos; + } } if (selecting_ && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { selecting_ = false; @@ -110,12 +129,35 @@ public: const int visibleLines = (int)(ImGui::GetContentRegionAvail().y / lineHeight) + 2; const int lastLine = std::min(lineCount - 1, firstLine + visibleLines); + const float textAreaWidth = std::max(0.0f, + (ImGui::GetWindowContentRegionMax().x - ImGui::GetWindowContentRegionMin().x) - gutterWidth); + const int currentLine = lineFromPos(cursor_, lineStarts); + for (int ln = firstLine; ln <= lastLine; ++ln) { int start = lineStarts[ln]; int end = (ln + 1 < lineCount) ? lineStarts[ln + 1] - 1 : (int)text.size(); int len = std::max(0, end - start); - float y = base.y + ln * lineHeight; + float y = textBase.y + ln * lineHeight; + + // Gutter background + ImVec2 gutterA(origin.x, y); + ImVec2 gutterB(origin.x + gutterWidth, y + lineHeight); + drawList->AddRectFilled(gutterA, gutterB, IM_COL32(20, 20, 20, 255)); + + // Line number (right-aligned) + char numBuf[16]; + snprintf(numBuf, sizeof(numBuf), "%d", ln + 1); + ImVec2 numSize = font->CalcTextSizeA(font->FontSize, FLT_MAX, -1.0f, numBuf); + ImVec2 numPos(origin.x + gutterWidth - 4.0f - numSize.x, y); + drawList->AddText(font, font->FontSize, numPos, IM_COL32(120, 120, 120, 255), numBuf); + + // Current line highlight (text area only) + if (ln == currentLine) { + ImVec2 hlA(textBase.x, y); + ImVec2 hlB(textBase.x + textAreaWidth, y + lineHeight); + drawList->AddRectFilled(hlA, hlB, IM_COL32(40, 40, 40, 120)); + } // Selection background if (hasSelection()) { @@ -126,8 +168,8 @@ public: if (lineSelStart < lineSelEnd) { int colA = lineSelStart - start; int colB = lineSelEnd - start; - ImVec2 a(base.x + colA * charAdvance, y); - ImVec2 b(base.x + colB * charAdvance, y + lineHeight); + ImVec2 a(textBase.x + colA * charAdvance, y); + ImVec2 b(textBase.x + colB * charAdvance, y + lineHeight); drawList->AddRectFilled(a, b, IM_COL32(60, 100, 160, 120)); } } @@ -156,7 +198,7 @@ public: chunk.pop_back(); } - ImVec2 p(base.x + (pos - start) * charAdvance, y); + ImVec2 p(textBase.x + (pos - start) * charAdvance, y); drawList->AddText(font, font->FontSize, p, colorFor(cat), chunk.c_str()); pos = spanEnd; @@ -168,11 +210,11 @@ public: const double t = ImGui::GetTime(); const bool blinkOn = ((int)(t * 2.0)) % 2 == 0; if (blinkOn) { - int curLine = lineFromPos(cursor_, lineStarts); + int curLine = currentLine; int lineStart = lineStarts[curLine]; int col = cursor_ - lineStart; - float x = base.x + col * charAdvance; - float y = base.y + curLine * lineHeight; + float x = textBase.x + col * charAdvance; + float y = textBase.y + curLine * lineHeight; drawList->AddLine(ImVec2(x, y), ImVec2(x, y + lineHeight), IM_COL32(240, 240, 240, 255), 1.0f); } } @@ -180,6 +222,9 @@ public: ImGui::EndChild(); result.cursorByte = cursor_; + result.lineCount = lineCount; + result.gutterWidth = gutterWidth; + result.currentLine = currentLine; return result; } @@ -223,6 +268,12 @@ private: return line; } + static int lineFromMouseY(float mouseY, float baseY, float lineHeight, int lineCount) { + int line = (int)((mouseY - baseY) / lineHeight); + line = std::max(0, std::min(line, lineCount - 1)); + return line; + } + static int positionFromMouse(const ImVec2& mouse, const ImVec2& base, const std::vector& lineStarts, @@ -241,6 +292,17 @@ private: return start + col; } + static float calcGutterWidth(int lineCount, ImFont* font, float charAdvance) { + int digits = 1; + int n = std::max(1, lineCount); + while (n >= 10) { n /= 10; ++digits; } + const float pad = 6.0f; + float digitsWidth = font->CalcTextSizeA(font->FontSize, FLT_MAX, -1.0f, + std::string(digits, '0').c_str()).x; + float width = digitsWidth + pad * 2.0f; + return std::max(width, charAdvance * 2.0f + pad * 2.0f); + } + void deleteSelection(std::string& text, bool& changed) { if (!hasSelection()) return; int a = std::min(selStart_, selEnd_); diff --git a/editor/tests/step78_test.cpp b/editor/tests/step78_test.cpp new file mode 100644 index 0000000..f4b0133 --- /dev/null +++ b/editor/tests/step78_test.cpp @@ -0,0 +1,88 @@ +// Step 78 TDD Test: Line numbers and gutter +// +// Tests: +// 1. Line count matches content +// 2. Gutter width grows with line count + +#include +#include +#include "imgui.h" +#include "CodeEditorWidget.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(); +} + +static std::string makeLines(int count) { + std::string s; + for (int i = 0; i < count; ++i) { + s += "line"; + s += std::to_string(i); + s += "\n"; + } + return s; +} + +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: Line count --- + { + beginFrame(); + ImGui::SetNextWindowFocus(); + ImGui::Begin("TestWindow"); + CodeEditorWidget widget; + std::string text = makeLines(5); + std::vector spans; + CodeEditorOptions opts; + CodeEditorResult res = widget.render("##editor", text, spans, opts, ImVec2(300, 200), ImGui::GetFont()); + ImGui::End(); + endFrame(); + + assert(res.lineCount == 5 && "Line count should match content"); + std::cout << "Test 1 PASS: Line count correct" << std::endl; + ++passed; + } + + // --- Test 2: Gutter width grows with line count --- + { + beginFrame(); + ImGui::SetNextWindowFocus(); + ImGui::Begin("TestWindow2"); + CodeEditorWidget widget; + std::string shortText = makeLines(9); + std::string longText = makeLines(120); + std::vector spans; + CodeEditorOptions opts; + + CodeEditorResult res1 = widget.render("##editor2", shortText, spans, opts, ImVec2(300, 200), ImGui::GetFont()); + CodeEditorResult res2 = widget.render("##editor3", longText, spans, opts, ImVec2(300, 200), ImGui::GetFont()); + ImGui::End(); + endFrame(); + + assert(res1.gutterWidth > 0.0f && res2.gutterWidth > 0.0f); + assert(res2.gutterWidth > res1.gutterWidth && "Gutter should grow for more digits"); + std::cout << "Test 2 PASS: Gutter width grows" << std::endl; + ++passed; + } + + ImGui::DestroyContext(); + + std::cout << "\n=== Step 78 Results: " << passed << " passed, " << failed << " failed ===" << std::endl; + return failed > 0 ? 1 : 0; +}