Step 80: selection and clipboard

This commit is contained in:
Bill
2026-02-09 09:10:01 -07:00
parent c66d80c0e8
commit 225e0f50a0
4 changed files with 212 additions and 14 deletions

View File

@@ -175,6 +175,7 @@ All 38 steps implemented and passing. Each step has a corresponding test (`step1
- [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 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) - [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 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)
--- ---
@@ -233,6 +234,7 @@ vcpkg's imgui 1.91.9 removed the `sdl2-binding` feature (only `sdl3-binding` exi
**Step 77:** Compile and pass (3/3) **Step 77:** Compile and pass (3/3)
**Step 78:** Compile and pass (2/2) **Step 78:** Compile and pass (2/2)
**Step 79:** Compile and pass (3/3) **Step 79:** Compile and pass (3/3)
**Step 80:** Compile and pass (3/3)
--- ---
@@ -320,3 +322,4 @@ Sprint 4 in progress. Step 76 (LayoutManager) done. Next: Step 77 (custom code e
| 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 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. | | 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 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. |

View File

@@ -335,6 +335,10 @@ add_executable(step79_test tests/step79_test.cpp)
target_include_directories(step79_test PRIVATE src) target_include_directories(step79_test PRIVATE src)
target_link_libraries(step79_test PRIVATE imgui::imgui) target_link_libraries(step79_test PRIVATE imgui::imgui)
add_executable(step80_test tests/step80_test.cpp)
target_include_directories(step80_test PRIVATE src)
target_link_libraries(step80_test PRIVATE imgui::imgui)
find_package(SDL2 CONFIG REQUIRED) find_package(SDL2 CONFIG REQUIRED)
find_package(OpenGL REQUIRED) find_package(OpenGL REQUIRED)
find_package(glad CONFIG REQUIRED) find_package(glad CONFIG REQUIRED)

View File

@@ -12,6 +12,7 @@
#include <string> #include <string>
#include <vector> #include <vector>
#include <algorithm> #include <algorithm>
#include <cctype>
struct CodeEditorOptions { struct CodeEditorOptions {
bool showWhitespace = false; bool showWhitespace = false;
@@ -96,7 +97,8 @@ public:
if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
ImGui::SetKeyboardFocusHere(-1); ImGui::SetKeyboardFocusHere(-1);
const ImVec2 mouse = ImGui::GetMousePos(); const ImVec2 mouse = ImGui::GetMousePos();
if (mouse.x < origin.x + gutterWidth) { int clickCount = ImGui::GetIO().MouseClickedCount[0];
if (mouse.x < origin.x + gutterWidth || clickCount >= 3) {
int line = lineFromMouseY(mouse.y, gutterBase.y, lineHeight, lineCount); int line = lineFromMouseY(mouse.y, gutterBase.y, lineHeight, lineCount);
int lineStart = lineStarts[line]; int lineStart = lineStarts[line];
int lineEnd = (line + 1 < lineCount) ? lineStarts[line + 1] : (int)text.size(); int lineEnd = (line + 1 < lineCount) ? lineStarts[line + 1] : (int)text.size();
@@ -104,9 +106,18 @@ public:
selStart_ = lineStart; selStart_ = lineStart;
selEnd_ = lineEnd; selEnd_ = lineEnd;
} else { } else {
cursor_ = positionFromMouse(mouse, textBase, lineStarts, text, charAdvance, lineHeight); int pos = positionFromMouse(mouse, textBase, lineStarts, text, charAdvance, lineHeight);
selStart_ = cursor_; if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) {
selEnd_ = cursor_; selectWordAt(text, pos);
} else if (ImGui::GetIO().KeyShift) {
if (!hasSelection()) selStart_ = cursor_;
cursor_ = pos;
selEnd_ = cursor_;
} else {
cursor_ = pos;
selStart_ = cursor_;
selEnd_ = cursor_;
}
} }
selecting_ = true; selecting_ = true;
} }
@@ -399,20 +410,30 @@ private:
io.InputQueueCharacters.resize(0); io.InputQueueCharacters.resize(0);
// Navigation // Navigation
if (ImGui::IsKeyPressed(ImGuiKey_LeftArrow)) { auto moveCursor = [&](int newPos) {
if (hasSelection()) { newPos = std::max(0, std::min(newPos, (int)text.size()));
cursor_ = std::min(selStart_, selEnd_); if (io.KeyShift) {
if (!hasSelection()) selStart_ = cursor_;
cursor_ = newPos;
selEnd_ = cursor_;
} else {
cursor_ = newPos;
selStart_ = selEnd_ = -1; selStart_ = selEnd_ = -1;
}
};
if (ImGui::IsKeyPressed(ImGuiKey_LeftArrow)) {
if (!io.KeyShift && hasSelection()) {
moveCursor(std::min(selStart_, selEnd_));
} else if (cursor_ > 0) { } else if (cursor_ > 0) {
cursor_--; moveCursor(cursor_ - 1);
} }
} }
if (ImGui::IsKeyPressed(ImGuiKey_RightArrow)) { if (ImGui::IsKeyPressed(ImGuiKey_RightArrow)) {
if (hasSelection()) { if (!io.KeyShift && hasSelection()) {
cursor_ = std::max(selStart_, selEnd_); moveCursor(std::max(selStart_, selEnd_));
selStart_ = selEnd_ = -1;
} else if (cursor_ < (int)text.size()) { } else if (cursor_ < (int)text.size()) {
cursor_++; moveCursor(cursor_ + 1);
} }
} }
if (ImGui::IsKeyPressed(ImGuiKey_UpArrow)) { if (ImGui::IsKeyPressed(ImGuiKey_UpArrow)) {
@@ -422,7 +443,7 @@ private:
int prevStart = lineStarts[curLine - 1]; int prevStart = lineStarts[curLine - 1];
int prevEnd = (curLine < (int)lineStarts.size()) ? lineStarts[curLine] - 1 : (int)text.size(); int prevEnd = (curLine < (int)lineStarts.size()) ? lineStarts[curLine] - 1 : (int)text.size();
int prevLen = std::max(0, prevEnd - prevStart); int prevLen = std::max(0, prevEnd - prevStart);
cursor_ = prevStart + std::min(curCol, prevLen); moveCursor(prevStart + std::min(curCol, prevLen));
} }
} }
if (ImGui::IsKeyPressed(ImGuiKey_DownArrow)) { if (ImGui::IsKeyPressed(ImGuiKey_DownArrow)) {
@@ -432,7 +453,7 @@ private:
int nextStart = lineStarts[curLine + 1]; int nextStart = lineStarts[curLine + 1];
int nextEnd = (curLine + 2 < (int)lineStarts.size()) ? lineStarts[curLine + 2] - 1 : (int)text.size(); int nextEnd = (curLine + 2 < (int)lineStarts.size()) ? lineStarts[curLine + 2] - 1 : (int)text.size();
int nextLen = std::max(0, nextEnd - nextStart); int nextLen = std::max(0, nextEnd - nextStart);
cursor_ = nextStart + std::min(curCol, nextLen); moveCursor(nextStart + std::min(curCol, nextLen));
} }
} }
@@ -460,5 +481,43 @@ private:
selEnd_ = (int)text.size(); selEnd_ = (int)text.size();
cursor_ = selEnd_; cursor_ = selEnd_;
} }
if ((io.KeyCtrl || io.KeySuper) && ImGui::IsKeyPressed(ImGuiKey_C)) {
if (hasSelection()) {
ImGui::SetClipboardText(getSelectionText(text).c_str());
}
}
if ((io.KeyCtrl || io.KeySuper) && ImGui::IsKeyPressed(ImGuiKey_X)) {
if (hasSelection()) {
ImGui::SetClipboardText(getSelectionText(text).c_str());
deleteSelection(text, changed);
}
}
if ((io.KeyCtrl || io.KeySuper) && ImGui::IsKeyPressed(ImGuiKey_V)) {
const char* clip = ImGui::GetClipboardText();
if (clip && *clip) {
insertText(text, std::string(clip), changed);
}
}
}
std::string getSelectionText(const std::string& text) const {
if (!hasSelection()) return "";
int a = std::min(selStart_, selEnd_);
int b = std::max(selStart_, selEnd_);
if (a < 0 || b > (int)text.size() || a >= b) return "";
return text.substr(a, b - a);
}
void selectWordAt(const std::string& text, int pos) {
if (pos < 0 || pos > (int)text.size()) return;
auto isWord = [](char c) { return std::isalnum((unsigned char)c) || c == '_'; };
int start = pos;
int end = pos;
while (start > 0 && isWord(text[start - 1])) --start;
while (end < (int)text.size() && isWord(text[end])) ++end;
selStart_ = start;
selEnd_ = end;
cursor_ = end;
} }
}; };

View File

@@ -0,0 +1,132 @@
// Step 80 TDD Test: Scroll, selection, clipboard
//
// Tests:
// 1. Ctrl+A then Ctrl+C copies full buffer to clipboard
// 2. Ctrl+X cuts selection to clipboard
// 3. Ctrl+V pastes clipboard at cursor
#include <cassert>
#include <iostream>
#include <string>
#include "imgui.h"
#include "CodeEditorWidget.h"
#include "EditorMode.h"
static std::string g_clipboard;
static void SetClipboardText(void*, const char* text) {
g_clipboard = text ? text : "";
}
static const char* GetClipboardText(void*) {
return g_clipboard.c_str();
}
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 void queueCtrlKey(ImGuiKey key) {
ImGuiIO& io = ImGui::GetIO();
io.AddKeyEvent(ImGuiKey_LeftCtrl, true);
io.AddKeyEvent(key, true);
}
int main() {
int passed = 0;
int failed = 0;
IMGUI_CHECKVERSION();
ImGui::CreateContext();
ImGui::StyleColorsDark();
ImGuiIO& io = ImGui::GetIO();
io.Fonts->AddFontDefault();
io.Fonts->Build();
io.SetClipboardTextFn = SetClipboardText;
io.GetClipboardTextFn = GetClipboardText;
// --- Test 1: Ctrl+A then Ctrl+C copies full text ---
{
g_clipboard.clear();
beginFrame();
ImGui::SetNextWindowFocus();
ImGui::Begin("TestWindow");
CodeEditorWidget widget;
std::string text = "abc";
std::vector<HighlightSpan> spans;
CodeEditorOptions opts;
queueCtrlKey(ImGuiKey_A);
queueCtrlKey(ImGuiKey_C);
CodeEditorResult res = widget.render("##editor", text, spans, opts, ImVec2(300, 200), ImGui::GetFont());
(void)res;
ImGui::End();
endFrame();
assert(g_clipboard == "abc" && "Clipboard should contain full text");
std::cout << "Test 1 PASS: Ctrl+A/C copies text" << std::endl;
++passed;
}
// --- Test 2: Ctrl+X cuts selection ---
{
g_clipboard.clear();
beginFrame();
ImGui::SetNextWindowFocus();
ImGui::Begin("TestWindow2");
CodeEditorWidget widget;
std::string text = "hello";
std::vector<HighlightSpan> spans;
CodeEditorOptions opts;
queueCtrlKey(ImGuiKey_A);
queueCtrlKey(ImGuiKey_X);
CodeEditorResult res = widget.render("##editor2", text, spans, opts, ImVec2(300, 200), ImGui::GetFont());
(void)res;
ImGui::End();
endFrame();
assert(g_clipboard == "hello" && "Clipboard should contain cut text");
assert(text.empty() && "Text should be empty after cut");
std::cout << "Test 2 PASS: Ctrl+X cuts selection" << std::endl;
++passed;
}
// --- Test 3: Ctrl+V pastes clipboard ---
{
g_clipboard = "zz";
beginFrame();
ImGui::SetNextWindowFocus();
ImGui::Begin("TestWindow3");
CodeEditorWidget widget;
std::string text = "";
std::vector<HighlightSpan> spans;
CodeEditorOptions opts;
widget.setCursor(0);
queueCtrlKey(ImGuiKey_V);
CodeEditorResult res = widget.render("##editor3", text, spans, opts, ImVec2(300, 200), ImGui::GetFont());
(void)res;
ImGui::End();
endFrame();
assert(text == "zz" && "Paste should insert clipboard contents");
std::cout << "Test 3 PASS: Ctrl+V pastes" << std::endl;
++passed;
}
ImGui::DestroyContext();
std::cout << "\n=== Step 80 Results: " << passed << " passed, " << failed << " failed ===" << std::endl;
return failed > 0 ? 1 : 0;
}