Step 178: multi-cursor editing

This commit is contained in:
Bill
2026-02-09 22:31:47 -07:00
parent 00910a0798
commit 6f3bfb7094
7 changed files with 389 additions and 65 deletions

View File

@@ -525,4 +525,5 @@ Sprint 5 in progress. Step 141 (Emacs daemon with user config) done. Next: Step
| 2026-02-10 | Codex | Step 175: Typography controls (fonts, line height, letter spacing) + theme layout spacing. 2/2 tests pass (step175_test, step175_integration_test). file_limits_test 4/4 passes. |
| 2026-02-10 | Codex | Step 176: Smooth UI transitions (panel slides, tab fade, tooltip fade, toast animations, search pulse, cursor blink) + reduce-motion/cursor blink settings. 2/2 tests pass (step176_test, step176_integration_test). |
| 2026-02-10 | Codex | Step 177: Enhanced find/replace (match counts, regex preview, replace preview, selection scope, history, toggles, find next/prev) with SearchUtils. 2/2 tests pass (step177_test, step177_integration_test). |
| 2026-02-10 | Codex | Step 178: Multi-cursor editing (Alt+Click, Ctrl+D, Ctrl+Alt+Up/Down, column selection) with multi-caret rendering and shared edits. 2/2 tests pass (step178_test, step178_integration_test). |
| 2026-02-10 | Codex | Step 166: Extract main.cpp into panel headers marked complete (already implemented). step166_test passes; file_limits_test 4/4 passes. |

View File

@@ -1030,6 +1030,13 @@ add_executable(step177_integration_test tests/step177_integration_test.cpp)
target_include_directories(step177_integration_test PRIVATE src)
target_link_libraries(step177_integration_test PRIVATE nlohmann_json::nlohmann_json)
add_executable(step178_test tests/step178_test.cpp)
target_include_directories(step178_test PRIVATE src)
add_executable(step178_integration_test tests/step178_integration_test.cpp)
target_include_directories(step178_integration_test PRIVATE src)
target_link_libraries(step178_integration_test PRIVATE nlohmann_json::nlohmann_json)
find_package(SDL2 CONFIG REQUIRED)
find_package(OpenGL REQUIRED)
find_package(glad CONFIG REQUIRED)

View File

@@ -11,6 +11,7 @@ public:
if (!font) font = ImGui::GetFont();
ImGuiIO& io = ImGui::GetIO();
syncPrimaryToMulti();
const double nowSeconds = options.nowSeconds > 0.0 ? options.nowSeconds : ImGui::GetTime();
auto beginFadingTooltip = [&](const std::string& id, bool hovered) -> bool {
float alpha = AnimationUtils::tooltipAlpha(id, hovered, nowSeconds, options.reduceMotion);
@@ -116,6 +117,8 @@ public:
goto after_mouse;
}
bool ctrlClick = io.KeyCtrl || io.KeySuper;
bool altClick = io.KeyAlt;
bool columnSelect = io.KeyAlt && io.KeyShift;
int clickCount = ImGui::GetIO().MouseClickedCount[0];
if (mouse.x < origin.x + gutterWidth || clickCount >= 3) {
int line = lineFromMouseY(mouse.y, gutterBase.y, lineHeight, lineCount);
@@ -130,7 +133,23 @@ public:
cursor_ = lineStart;
selStart_ = lineStart;
selEnd_ = lineEnd;
} else if (columnSelect) {
int pos = positionFromMouse(mouse, textBase, lineStarts, text, charAdvance, lineHeight);
int line = lineFromPos(pos, lineStarts);
int col = pos - lineStarts[line];
columnSelectActive_ = true;
columnAnchorLine_ = line;
columnAnchorCol_ = col;
updateColumnSelection(text, lineStarts, columnAnchorLine_, columnAnchorCol_, line, col);
selecting_ = true;
goto after_mouse;
} else if (altClick) {
int pos = positionFromMouse(mouse, textBase, lineStarts, text, charAdvance, lineHeight);
addCursorAt(pos);
selecting_ = false;
goto after_mouse;
} else {
clearExtraCursors();
int pos = positionFromMouse(mouse, textBase, lineStarts, text, charAdvance, lineHeight);
result.lineClicked = true;
result.clickedLine = lineFromPos(pos, lineStarts);
@@ -159,11 +178,18 @@ public:
const ImVec2 mouse = ImGui::GetMousePos();
if (mouse.x >= origin.x + gutterWidth) {
int pos = positionFromMouse(mouse, textBase, lineStarts, text, charAdvance, lineHeight);
selEnd_ = pos;
if (columnSelectActive_ && (io.KeyAlt && io.KeyShift)) {
int line = lineFromPos(pos, lineStarts);
int col = pos - lineStarts[line];
updateColumnSelection(text, lineStarts, columnAnchorLine_, columnAnchorCol_, line, col);
} else {
selEnd_ = pos;
}
}
}
if (selecting_ && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
selecting_ = false;
columnSelectActive_ = false;
}
if (hovered) {
@@ -388,9 +414,10 @@ public:
}
// Selection background
if (hasSelection()) {
int selA = std::min(selStart_, selEnd_);
int selB = std::max(selStart_, selEnd_);
for (const auto& c : cursors_) {
if (c.selStart < 0 || c.selEnd < 0 || c.selStart == c.selEnd) continue;
int selA = std::min(c.selStart, c.selEnd);
int selB = std::max(c.selStart, c.selEnd);
int lineSelStart = std::max(selA, start);
int lineSelEnd = std::min(selB, end);
if (lineSelStart < lineSelEnd) {
@@ -502,17 +529,20 @@ public:
options.cursorBlinkRate,
options.reduceMotion);
if (blinkAlpha > 0.02f) {
int curLine = currentLine;
int lineStart = lineStarts[curLine];
int col = cursor_ - lineStart;
float x = textBase.x + col * charAdvance;
float y = textBase.y + curLine * lineHeight;
ImU32 base = ThemeEngine::instance().editorColor("caret",
IM_COL32(240, 240, 240, 255));
ImVec4 c = ImGui::ColorConvertU32ToFloat4(base);
c.w *= blinkAlpha;
ImU32 col32 = ImGui::ColorConvertFloat4ToU32(c);
drawList->AddLine(ImVec2(x, y), ImVec2(x, y + lineHeight), col32, 1.0f);
for (size_t i = 0; i < cursors_.size(); ++i) {
const auto& c = cursors_[i];
int curLine = lineFromPos(c.cursor, lineStarts);
int lineStart = lineStarts[curLine];
int col = c.cursor - lineStart;
float x = textBase.x + col * charAdvance;
float y = textBase.y + curLine * lineHeight;
ImU32 base = ThemeEngine::instance().editorColor("caret",
IM_COL32(240, 240, 240, 255));
ImVec4 color = ImGui::ColorConvertU32ToFloat4(base);
color.w *= (i == 0) ? blinkAlpha : (blinkAlpha * 0.6f);
ImU32 col32 = ImGui::ColorConvertFloat4ToU32(color);
drawList->AddLine(ImVec2(x, y), ImVec2(x, y + lineHeight), col32, 1.0f);
}
}
}
@@ -549,6 +579,7 @@ public:
ImGui::EndChild();
syncMultiToPrimary();
result.cursorByte = cursor_;
result.lineCount = lineCount;
result.gutterWidth = gutterWidth;
@@ -567,7 +598,10 @@ public:
return result;
}
void setCursor(int pos) { cursor_ = pos; }
void setCursor(int pos) {
cursor_ = pos;
syncPrimaryToMulti();
}
int getCursor() const { return cursor_; }
bool hasSelectionRange() const { return hasSelection(); }
void getSelectionRange(int& start, int& end) const {
@@ -610,6 +644,10 @@ private:
int selStart_ = -1;
int selEnd_ = -1;
bool selecting_ = false;
std::vector<MultiCursor> cursors_;
bool columnSelectActive_ = false;
int columnAnchorLine_ = 0;
int columnAnchorCol_ = 0;
std::vector<FoldRegion> folds_;
std::string lastFoldText_;
std::string lastFoldLang_;

View File

@@ -1,9 +1,100 @@
#pragma once
// Included inside CodeEditorWidget (selection + editing).
struct MultiCursor {
int cursor = 0;
int selStart = -1;
int selEnd = -1;
};
void syncPrimaryToMulti() {
if (cursors_.empty()) {
cursors_.push_back({cursor_, selStart_, selEnd_});
return;
}
cursors_[0].cursor = cursor_;
cursors_[0].selStart = selStart_;
cursors_[0].selEnd = selEnd_;
}
void syncMultiToPrimary() {
if (cursors_.empty()) return;
cursor_ = cursors_[0].cursor;
selStart_ = cursors_[0].selStart;
selEnd_ = cursors_[0].selEnd;
}
void clearExtraCursors() {
if (cursors_.size() > 1) {
cursors_.erase(cursors_.begin() + 1, cursors_.end());
}
}
void addCursorAt(int pos, int selStart = -1, int selEnd = -1) {
for (const auto& c : cursors_) {
if (c.cursor == pos) return;
}
MultiCursor c;
c.cursor = pos;
c.selStart = selStart >= 0 ? selStart : pos;
c.selEnd = selEnd >= 0 ? selEnd : pos;
cursors_.push_back(c);
}
void dedupeCursors() {
if (cursors_.size() <= 1) return;
std::sort(cursors_.begin(), cursors_.end(),
[](const MultiCursor& a, const MultiCursor& b) { return a.cursor < b.cursor; });
auto it = std::unique(cursors_.begin(), cursors_.end(),
[](const MultiCursor& a, const MultiCursor& b) {
return a.cursor == b.cursor &&
a.selStart == b.selStart &&
a.selEnd == b.selEnd;
});
cursors_.erase(it, cursors_.end());
}
static int positionFromLineCol(const std::vector<int>& lineStarts, int line, int col, const std::string& text) {
if (lineStarts.empty()) return 0;
line = std::max(0, std::min(line, (int)lineStarts.size() - 1));
int start = lineStarts[line];
int end = (line + 1 < (int)lineStarts.size()) ? lineStarts[line + 1] - 1 : (int)text.size();
int len = std::max(0, end - start);
int clampedCol = std::max(0, std::min(col, len));
return start + clampedCol;
}
void updateColumnSelection(const std::string& text,
const std::vector<int>& lineStarts,
int anchorLine,
int anchorCol,
int currentLine,
int currentCol) {
cursors_.clear();
int startLine = std::min(anchorLine, currentLine);
int endLine = std::max(anchorLine, currentLine);
int col = currentCol;
for (int line = startLine; line <= endLine; ++line) {
int pos = positionFromLineCol(lineStarts, line, col, text);
MultiCursor c;
c.cursor = pos;
c.selStart = pos;
c.selEnd = pos;
cursors_.push_back(c);
}
if (!cursors_.empty()) {
cursor_ = cursors_[0].cursor;
selStart_ = cursors_[0].selStart;
selEnd_ = cursors_[0].selEnd;
}
}
bool hasSelection() const {
return selStart_ >= 0 && selEnd_ >= 0 && selStart_ != selEnd_;
}
static bool cursorHasSelection(const MultiCursor& c) {
return c.selStart >= 0 && c.selEnd >= 0 && c.selStart != c.selEnd;
}
void deleteSelection(std::string& text, bool& changed) {
if (!hasSelection()) return;
int a = std::min(selStart_, selEnd_);
@@ -14,22 +105,76 @@
changed = true;
}
selStart_ = selEnd_ = -1;
syncPrimaryToMulti();
}
void applyEdits(std::string& text,
const std::vector<MultiCursor>& edits,
const std::vector<std::string>& inserts,
bool& changed) {
if (edits.empty()) return;
struct Plan { int index; int start; int end; std::string insert; };
std::vector<Plan> plans;
plans.reserve(edits.size());
for (size_t i = 0; i < edits.size(); ++i) {
const auto& c = edits[i];
int start = c.cursor;
int end = c.cursor;
if (cursorHasSelection(c)) {
start = std::min(c.selStart, c.selEnd);
end = std::max(c.selStart, c.selEnd);
}
plans.push_back({(int)i, start, end, inserts[i]});
}
std::sort(plans.begin(), plans.end(),
[](const Plan& a, const Plan& b) { return a.start > b.start; });
std::vector<MultiCursor> next = edits;
bool didChange = false;
for (const auto& plan : plans) {
if (plan.start < 0 || plan.start > (int)text.size()) continue;
int safeEnd = std::max(plan.start, std::min(plan.end, (int)text.size()));
if (safeEnd > plan.start) {
text.erase(plan.start, safeEnd - plan.start);
didChange = true;
}
if (!plan.insert.empty()) {
text.insert(plan.start, plan.insert);
didChange = true;
}
next[plan.index].cursor = plan.start + (int)plan.insert.size();
next[plan.index].selStart = next[plan.index].cursor;
next[plan.index].selEnd = next[plan.index].cursor;
}
if (didChange) {
changed = true;
}
cursors_ = std::move(next);
syncMultiToPrimary();
}
void insertText(std::string& text, const std::string& insert, bool& changed) {
if (insert.empty()) return;
deleteSelection(text, changed);
text.insert(cursor_, insert);
cursor_ += (int)insert.size();
changed = true;
syncPrimaryToMulti();
std::vector<MultiCursor> edits = cursors_;
std::vector<std::string> inserts(edits.size(), insert);
applyEdits(text, edits, inserts, changed);
}
void insertPair(std::string& text, char open, char close, bool& changed) {
deleteSelection(text, changed);
text.insert(cursor_, 1, open);
text.insert(cursor_ + 1, 1, close);
cursor_ += 1;
changed = true;
syncPrimaryToMulti();
std::string pair;
pair.push_back(open);
pair.push_back(close);
std::vector<MultiCursor> edits = cursors_;
std::vector<std::string> inserts(edits.size(), pair);
applyEdits(text, edits, inserts, changed);
for (auto& c : cursors_) {
c.cursor -= 1;
c.selStart = c.cursor;
c.selEnd = c.cursor;
}
syncMultiToPrimary();
}
static std::string lineTextAt(const std::string& text, const std::vector<int>& lineStarts, int line) {
@@ -67,6 +212,7 @@
const std::vector<int>& lineStarts,
const EditorMode* mode) {
ImGuiIO& io = ImGui::GetIO();
syncPrimaryToMulti();
// Text input
for (int n = 0; n < io.InputQueueCharacters.Size; n++) {
@@ -98,76 +244,91 @@
io.InputQueueCharacters.resize(0);
// Navigation
auto moveCursor = [&](int newPos) {
auto moveCursor = [&](MultiCursor& c, int newPos) {
newPos = std::max(0, std::min(newPos, (int)text.size()));
if (io.KeyShift) {
if (!hasSelection()) selStart_ = cursor_;
cursor_ = newPos;
selEnd_ = cursor_;
if (!cursorHasSelection(c)) c.selStart = c.cursor;
c.cursor = newPos;
c.selEnd = c.cursor;
} else {
cursor_ = newPos;
selStart_ = selEnd_ = -1;
c.cursor = newPos;
c.selStart = c.selEnd = -1;
}
};
if (ImGui::IsKeyPressed(ImGuiKey_LeftArrow)) {
if (!io.KeyShift && hasSelection()) {
moveCursor(std::min(selStart_, selEnd_));
} else if (cursor_ > 0) {
moveCursor(cursor_ - 1);
for (auto& c : cursors_) {
if (!io.KeyShift && cursorHasSelection(c)) {
moveCursor(c, std::min(c.selStart, c.selEnd));
} else if (c.cursor > 0) {
moveCursor(c, c.cursor - 1);
}
}
}
if (ImGui::IsKeyPressed(ImGuiKey_RightArrow)) {
if (!io.KeyShift && hasSelection()) {
moveCursor(std::max(selStart_, selEnd_));
} else if (cursor_ < (int)text.size()) {
moveCursor(cursor_ + 1);
for (auto& c : cursors_) {
if (!io.KeyShift && cursorHasSelection(c)) {
moveCursor(c, std::max(c.selStart, c.selEnd));
} else if (c.cursor < (int)text.size()) {
moveCursor(c, c.cursor + 1);
}
}
}
if (ImGui::IsKeyPressed(ImGuiKey_UpArrow)) {
int curLine = lineFromPos(cursor_, lineStarts);
if (curLine > 0) {
int curCol = cursor_ - lineStarts[curLine];
int prevStart = lineStarts[curLine - 1];
int prevEnd = (curLine < (int)lineStarts.size()) ? lineStarts[curLine] - 1 : (int)text.size();
int prevLen = std::max(0, prevEnd - prevStart);
moveCursor(prevStart + std::min(curCol, prevLen));
for (auto& c : cursors_) {
int curLine = lineFromPos(c.cursor, lineStarts);
if (curLine > 0) {
int curCol = c.cursor - lineStarts[curLine];
int prevStart = lineStarts[curLine - 1];
int prevEnd = (curLine < (int)lineStarts.size()) ? lineStarts[curLine] - 1 : (int)text.size();
int prevLen = std::max(0, prevEnd - prevStart);
moveCursor(c, prevStart + std::min(curCol, prevLen));
}
}
}
if (ImGui::IsKeyPressed(ImGuiKey_DownArrow)) {
int curLine = lineFromPos(cursor_, lineStarts);
if (curLine + 1 < (int)lineStarts.size()) {
int curCol = cursor_ - lineStarts[curLine];
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);
moveCursor(nextStart + std::min(curCol, nextLen));
for (auto& c : cursors_) {
int curLine = lineFromPos(c.cursor, lineStarts);
if (curLine + 1 < (int)lineStarts.size()) {
int curCol = c.cursor - lineStarts[curLine];
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);
moveCursor(c, nextStart + std::min(curCol, nextLen));
}
}
}
// Editing keys
if (ImGui::IsKeyPressed(ImGuiKey_Backspace) && !text.empty()) {
if (hasSelection()) {
deleteSelection(text, changed);
} else if (cursor_ > 0) {
text.erase(cursor_ - 1, 1);
cursor_--;
changed = true;
std::vector<MultiCursor> edits = cursors_;
std::vector<std::string> inserts(edits.size(), "");
for (auto& c : edits) {
if (!cursorHasSelection(c) && c.cursor > 0) {
c.selStart = c.cursor - 1;
c.selEnd = c.cursor;
}
}
applyEdits(text, edits, inserts, changed);
}
if (ImGui::IsKeyPressed(ImGuiKey_Delete) && !text.empty()) {
if (hasSelection()) {
deleteSelection(text, changed);
} else if (cursor_ < (int)text.size()) {
text.erase(cursor_, 1);
changed = true;
std::vector<MultiCursor> edits = cursors_;
std::vector<std::string> inserts(edits.size(), "");
for (auto& c : edits) {
if (!cursorHasSelection(c) && c.cursor < (int)text.size()) {
c.selStart = c.cursor;
c.selEnd = c.cursor + 1;
}
}
applyEdits(text, edits, inserts, changed);
}
if ((io.KeyCtrl || io.KeySuper) && ImGui::IsKeyPressed(ImGuiKey_A)) {
clearExtraCursors();
selStart_ = 0;
selEnd_ = (int)text.size();
cursor_ = selEnd_;
syncPrimaryToMulti();
}
if ((io.KeyCtrl || io.KeySuper) && ImGui::IsKeyPressed(ImGuiKey_C)) {
@@ -187,6 +348,66 @@
insertText(text, std::string(clip), changed);
}
}
if (ImGui::IsKeyPressed(ImGuiKey_Escape)) {
clearExtraCursors();
selStart_ = selEnd_ = -1;
syncPrimaryToMulti();
}
if ((io.KeyCtrl || io.KeySuper) && ImGui::IsKeyPressed(ImGuiKey_D)) {
int start = -1;
int end = -1;
std::string needle;
if (hasSelection()) {
start = std::min(selStart_, selEnd_);
end = std::max(selStart_, selEnd_);
needle = text.substr(start, end - start);
} else {
int pos = cursor_;
if (pos >= (int)text.size()) pos = (int)text.size() - 1;
auto isWord = [](char c) { return std::isalnum((unsigned char)c) || c == '_'; };
int s = pos;
while (s > 0 && isWord(text[s - 1])) --s;
int e = pos;
while (e < (int)text.size() && isWord(text[e])) ++e;
if (e > s) {
start = s;
end = e;
needle = text.substr(start, end - start);
}
}
if (!needle.empty()) {
size_t next = text.find(needle, (size_t)end);
if (next != std::string::npos) {
addCursorAt((int)next, (int)next, (int)next + (int)needle.size());
dedupeCursors();
}
}
}
if ((io.KeyCtrl || io.KeySuper) && io.KeyAlt &&
ImGui::IsKeyPressed(ImGuiKey_UpArrow)) {
for (const auto& c : cursors_) {
int curLine = lineFromPos(c.cursor, lineStarts);
if (curLine <= 0) continue;
int curCol = c.cursor - lineStarts[curLine];
int pos = positionFromLineCol(lineStarts, curLine - 1, curCol, text);
addCursorAt(pos);
}
dedupeCursors();
}
if ((io.KeyCtrl || io.KeySuper) && io.KeyAlt &&
ImGui::IsKeyPressed(ImGuiKey_DownArrow)) {
for (const auto& c : cursors_) {
int curLine = lineFromPos(c.cursor, lineStarts);
if (curLine + 1 >= (int)lineStarts.size()) continue;
int curCol = c.cursor - lineStarts[curLine];
int pos = positionFromLineCol(lineStarts, curLine + 1, curCol, text);
addCursorAt(pos);
}
dedupeCursors();
}
}
std::string getSelectionText(const std::string& text) const {

View File

@@ -0,0 +1,26 @@
// Step 178: Multi-cursor data structure integration checks.
#include <cassert>
#include <fstream>
#include <string>
static std::string readFile(const std::string& path) {
std::ifstream f(path);
if (!f.is_open()) return {};
return std::string((std::istreambuf_iterator<char>(f)),
std::istreambuf_iterator<char>());
}
static void assertContains(const std::string& text, const std::string& needle) {
assert(text.find(needle) != std::string::npos);
}
int main() {
const std::string selection = readFile("src/CodeEditorSelection.h");
assertContains(selection, "syncPrimaryToMulti");
assertContains(selection, "applyEdits");
assertContains(selection, "updateColumnSelection");
printf("step178_integration_test: all assertions passed\n");
return 0;
}

View File

@@ -0,0 +1,31 @@
// Step 178: Multi-cursor editing checks.
#include <cassert>
#include <fstream>
#include <string>
static std::string readFile(const std::string& path) {
std::ifstream f(path);
if (!f.is_open()) return {};
return std::string((std::istreambuf_iterator<char>(f)),
std::istreambuf_iterator<char>());
}
static void assertContains(const std::string& text, const std::string& needle) {
assert(text.find(needle) != std::string::npos);
}
int main() {
const std::string selection = readFile("src/CodeEditorSelection.h");
assertContains(selection, "MultiCursor");
assertContains(selection, "ImGuiKey_D");
assertContains(selection, "KeyAlt");
assertContains(selection, "addCursorAt");
const std::string rendering = readFile("src/CodeEditorRendering.h");
assertContains(rendering, "columnSelectActive_");
assertContains(rendering, "updateColumnSelection");
printf("step178_test: all assertions passed\n");
return 0;
}

View File

@@ -171,7 +171,7 @@ Core editing improvements that make daily use smooth.
- Preserve search history (last 10 queries, accessible via dropdown)
*Modifies:* `CodeEditorWidget.h`, find/replace rendering
- [ ] **Step 178: Multi-cursor editing**
- [x] **Step 178: Multi-cursor editing**
Support for multiple simultaneous cursors:
- Ctrl+D: select next occurrence of current selection (add cursor)
- Alt+Click: add cursor at click position