Step 80: selection and clipboard
This commit is contained in:
@@ -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 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)
|
||||
|
||||
---
|
||||
|
||||
@@ -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 78:** Compile and pass (2/2)
|
||||
**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 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. |
|
||||
|
||||
@@ -335,6 +335,10 @@ add_executable(step79_test tests/step79_test.cpp)
|
||||
target_include_directories(step79_test PRIVATE src)
|
||||
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(OpenGL REQUIRED)
|
||||
find_package(glad CONFIG REQUIRED)
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
struct CodeEditorOptions {
|
||||
bool showWhitespace = false;
|
||||
@@ -96,7 +97,8 @@ public:
|
||||
if (hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
||||
ImGui::SetKeyboardFocusHere(-1);
|
||||
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 lineStart = lineStarts[line];
|
||||
int lineEnd = (line + 1 < lineCount) ? lineStarts[line + 1] : (int)text.size();
|
||||
@@ -104,10 +106,19 @@ public:
|
||||
selStart_ = lineStart;
|
||||
selEnd_ = lineEnd;
|
||||
} else {
|
||||
cursor_ = positionFromMouse(mouse, textBase, lineStarts, text, charAdvance, lineHeight);
|
||||
int pos = positionFromMouse(mouse, textBase, lineStarts, text, charAdvance, lineHeight);
|
||||
if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) {
|
||||
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;
|
||||
}
|
||||
if (hovered && selecting_ && ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
|
||||
@@ -399,20 +410,30 @@ private:
|
||||
io.InputQueueCharacters.resize(0);
|
||||
|
||||
// Navigation
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_LeftArrow)) {
|
||||
if (hasSelection()) {
|
||||
cursor_ = std::min(selStart_, selEnd_);
|
||||
auto moveCursor = [&](int newPos) {
|
||||
newPos = std::max(0, std::min(newPos, (int)text.size()));
|
||||
if (io.KeyShift) {
|
||||
if (!hasSelection()) selStart_ = cursor_;
|
||||
cursor_ = newPos;
|
||||
selEnd_ = cursor_;
|
||||
} else {
|
||||
cursor_ = newPos;
|
||||
selStart_ = selEnd_ = -1;
|
||||
}
|
||||
};
|
||||
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_LeftArrow)) {
|
||||
if (!io.KeyShift && hasSelection()) {
|
||||
moveCursor(std::min(selStart_, selEnd_));
|
||||
} else if (cursor_ > 0) {
|
||||
cursor_--;
|
||||
moveCursor(cursor_ - 1);
|
||||
}
|
||||
}
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_RightArrow)) {
|
||||
if (hasSelection()) {
|
||||
cursor_ = std::max(selStart_, selEnd_);
|
||||
selStart_ = selEnd_ = -1;
|
||||
if (!io.KeyShift && hasSelection()) {
|
||||
moveCursor(std::max(selStart_, selEnd_));
|
||||
} else if (cursor_ < (int)text.size()) {
|
||||
cursor_++;
|
||||
moveCursor(cursor_ + 1);
|
||||
}
|
||||
}
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_UpArrow)) {
|
||||
@@ -422,7 +443,7 @@ private:
|
||||
int prevStart = lineStarts[curLine - 1];
|
||||
int prevEnd = (curLine < (int)lineStarts.size()) ? lineStarts[curLine] - 1 : (int)text.size();
|
||||
int prevLen = std::max(0, prevEnd - prevStart);
|
||||
cursor_ = prevStart + std::min(curCol, prevLen);
|
||||
moveCursor(prevStart + std::min(curCol, prevLen));
|
||||
}
|
||||
}
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_DownArrow)) {
|
||||
@@ -432,7 +453,7 @@ private:
|
||||
int nextStart = lineStarts[curLine + 1];
|
||||
int nextEnd = (curLine + 2 < (int)lineStarts.size()) ? lineStarts[curLine + 2] - 1 : (int)text.size();
|
||||
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();
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
132
editor/tests/step80_test.cpp
Normal file
132
editor/tests/step80_test.cpp
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user