Step 78: line numbers and gutter
This commit is contained in:
@@ -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. |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<int>& 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_);
|
||||
|
||||
88
editor/tests/step78_test.cpp
Normal file
88
editor/tests/step78_test.cpp
Normal file
@@ -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 <cassert>
|
||||
#include <iostream>
|
||||
#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<HighlightSpan> 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<HighlightSpan> 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;
|
||||
}
|
||||
Reference in New Issue
Block a user