diff --git a/PROGRESS.md b/PROGRESS.md index a512185..8c573a7 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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. | diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 4219b0d..5144edf 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -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) diff --git a/editor/src/CodeEditorRendering.h b/editor/src/CodeEditorRendering.h index 2b76ac2..58211a8 100644 --- a/editor/src/CodeEditorRendering.h +++ b/editor/src/CodeEditorRendering.h @@ -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 cursors_; + bool columnSelectActive_ = false; + int columnAnchorLine_ = 0; + int columnAnchorCol_ = 0; std::vector folds_; std::string lastFoldText_; std::string lastFoldLang_; diff --git a/editor/src/CodeEditorSelection.h b/editor/src/CodeEditorSelection.h index 78c534f..13c2c83 100644 --- a/editor/src/CodeEditorSelection.h +++ b/editor/src/CodeEditorSelection.h @@ -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& 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& 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& edits, + const std::vector& inserts, + bool& changed) { + if (edits.empty()) return; + struct Plan { int index; int start; int end; std::string insert; }; + std::vector 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 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 edits = cursors_; + std::vector 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 edits = cursors_; + std::vector 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& lineStarts, int line) { @@ -67,6 +212,7 @@ const std::vector& 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 edits = cursors_; + std::vector 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 edits = cursors_; + std::vector 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 { diff --git a/editor/tests/step178_integration_test.cpp b/editor/tests/step178_integration_test.cpp new file mode 100644 index 0000000..292234a --- /dev/null +++ b/editor/tests/step178_integration_test.cpp @@ -0,0 +1,26 @@ +// Step 178: Multi-cursor data structure integration checks. + +#include +#include +#include + +static std::string readFile(const std::string& path) { + std::ifstream f(path); + if (!f.is_open()) return {}; + return std::string((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); +} + +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; +} diff --git a/editor/tests/step178_test.cpp b/editor/tests/step178_test.cpp new file mode 100644 index 0000000..67418dd --- /dev/null +++ b/editor/tests/step178_test.cpp @@ -0,0 +1,31 @@ +// Step 178: Multi-cursor editing checks. + +#include +#include +#include + +static std::string readFile(const std::string& path) { + std::ifstream f(path); + if (!f.is_open()) return {}; + return std::string((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); +} + +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; +} diff --git a/sprint6_plan.md b/sprint6_plan.md index c82a7d9..719bba9 100644 --- a/sprint6_plan.md +++ b/sprint6_plan.md @@ -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