Skip to content

Commit f29e451

Browse files
committed
Multi Caretsupport
1 parent 6c8efef commit f29e451

3 files changed

Lines changed: 266 additions & 23 deletions

File tree

Examples/ExampleGitReview/ReviewGui.cpp

Lines changed: 262 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,14 @@
99
#include "imgui_internal.h"
1010

1111
#include <algorithm>
12+
#include <cmath>
1213
#include <filesystem>
1314
#include <cstdio>
1415
#include <cstring>
16+
#include <limits>
1517
#include <string>
1618
#include <unordered_set>
19+
#include <set>
1720
#include <vector>
1821

1922
namespace gitReview
@@ -123,6 +126,204 @@ namespace gitReview
123126
state->ReloadSelectionEnd = mappedCursor;
124127
}
125128

129+
static void reloadUserBufferAtCursor(ImGuiInputTextState *state, int cursor)
130+
{
131+
if (!state)
132+
return;
133+
state->WantReloadUserBuf = true;
134+
state->ReloadSelectionStart = cursor;
135+
state->ReloadSelectionEnd = cursor;
136+
}
137+
138+
static std::vector<size_t> lineStartOffsets(const std::string &text)
139+
{
140+
std::vector<size_t> ret;
141+
ret.push_back(0);
142+
for (size_t i = 0; i < text.size(); ++i)
143+
{
144+
if (text[i] == '\n')
145+
ret.push_back(i + 1);
146+
}
147+
return ret;
148+
}
149+
150+
static void mergeDuplicateCarets(ReviewAppState &app, int primaryCursor, int textLength)
151+
{
152+
std::set<int> unique;
153+
for (int caret : app.rightEditExtraCarets)
154+
{
155+
caret = std::clamp(caret, 0, textLength);
156+
if (caret != primaryCursor)
157+
unique.insert(caret);
158+
}
159+
app.rightEditExtraCarets.assign(unique.begin(), unique.end());
160+
}
161+
162+
static int mouseToBufferOffset(const std::string &text, const ImVec2 &contentTopLeft, float gutterW, float padX, float padY, float lineH)
163+
{
164+
const ImGuiIO &io = ImGui::GetIO();
165+
const std::vector<size_t> starts = lineStartOffsets(text);
166+
if (starts.empty())
167+
return 0;
168+
169+
int row = static_cast<int>((io.MousePos.y - (contentTopLeft.y + padY)) / lineH);
170+
row = std::clamp(row, 0, static_cast<int>(starts.size()) - 1);
171+
const size_t lineStart = starts[static_cast<size_t>(row)];
172+
const size_t lineEnd = (row + 1 < static_cast<int>(starts.size())) ? starts[static_cast<size_t>(row + 1)] - 1 : text.size();
173+
const float relX = std::max(0.f, io.MousePos.x - (contentTopLeft.x + gutterW + padX));
174+
175+
size_t best = lineStart;
176+
float bestDist = std::numeric_limits<float>::max();
177+
for (size_t p = lineStart; p <= lineEnd; ++p)
178+
{
179+
const float w = ImGui::CalcTextSize(text.data() + lineStart, text.data() + p, false).x;
180+
const float dist = std::abs(w - relX);
181+
if (dist < bestDist)
182+
{
183+
bestDist = dist;
184+
best = p;
185+
}
186+
}
187+
return static_cast<int>(best);
188+
}
189+
190+
static void addExtraCaretFromMouse(ReviewAppState &app, const std::string &text, const ImVec2 &contentTopLeft, float gutterW, float padX, float padY,
191+
float lineH, int primaryCursor, int previousPrimaryCursor)
192+
{
193+
const ImGuiIO &io = ImGui::GetIO();
194+
const ImRect paneRect(ImGui::GetWindowPos(), ImVec2(ImGui::GetWindowPos().x + ImGui::GetWindowSize().x, ImGui::GetWindowPos().y + ImGui::GetWindowSize().y));
195+
if (!io.MouseClicked[0] || !paneRect.Contains(io.MousePos))
196+
return;
197+
if (!io.KeyAlt && !io.KeyCtrl)
198+
{
199+
app.rightEditExtraCarets.clear();
200+
return;
201+
}
202+
const int caret = mouseToBufferOffset(text, contentTopLeft, gutterW, padX, padY, lineH);
203+
if (previousPrimaryCursor >= 0)
204+
app.rightEditExtraCarets.push_back(previousPrimaryCursor);
205+
app.rightEditExtraCarets.push_back(caret);
206+
mergeDuplicateCarets(app, primaryCursor, static_cast<int>(text.size()));
207+
}
208+
209+
static void drawExtraCarets(const ReviewAppState &app, const std::string &text, const ImVec2 &contentTopLeft, float gutterW, float padX, float padY,
210+
float lineH)
211+
{
212+
const std::vector<size_t> starts = lineStartOffsets(text);
213+
if (starts.empty())
214+
return;
215+
const ImRect clipR(ImGui::GetCurrentWindow()->InnerClipRect);
216+
const float blink = 0.55f + 0.45f * ((std::sin(static_cast<float>(ImGui::GetTime()) * 6.0f) + 1.0f) * 0.5f);
217+
const ImU32 visibleColor = IM_COL32(255, 255, 255, static_cast<int>(255.0f * blink));
218+
ImDrawList *dl = ImGui::GetForegroundDrawList();
219+
dl->PushClipRect(clipR.Min, clipR.Max, true);
220+
for (int caret : app.rightEditExtraCarets)
221+
{
222+
caret = std::clamp(caret, 0, static_cast<int>(text.size()));
223+
auto it = std::upper_bound(starts.begin(), starts.end(), static_cast<size_t>(caret));
224+
const int row = static_cast<int>(std::distance(starts.begin(), it)) - 1;
225+
if (row < 0)
226+
continue;
227+
const size_t lineStart = starts[static_cast<size_t>(row)];
228+
const float x = contentTopLeft.x + gutterW + padX + ImGui::CalcTextSize(text.data() + lineStart, text.data() + caret, false).x;
229+
const float y0 = contentTopLeft.y + padY + static_cast<float>(row) * lineH;
230+
if (x < clipR.Min.x || x > clipR.Max.x || y0 + lineH < clipR.Min.y || y0 > clipR.Max.y)
231+
continue;
232+
dl->AddLine(ImVec2(x, y0), ImVec2(x, y0 + lineH), visibleColor, 2.0f);
233+
}
234+
dl->PopClipRect();
235+
}
236+
237+
static bool applyEditToExtraCarets(ReviewAppState &app, const std::string &before, std::string &after, int cursorBefore, int cursorAfterEdit,
238+
int selectionStart, int selectionEnd, int &cursorAfterMultiEdit)
239+
{
240+
if (app.rightEditExtraCarets.empty() || before == after)
241+
return false;
242+
243+
size_t prefix = 0;
244+
while (prefix < before.size() && prefix < after.size() && before[prefix] == after[prefix])
245+
++prefix;
246+
size_t suffix = 0;
247+
while (suffix < before.size() - prefix && suffix < after.size() - prefix &&
248+
before[before.size() - 1 - suffix] == after[after.size() - 1 - suffix])
249+
++suffix;
250+
251+
const int oldStart = static_cast<int>(prefix);
252+
const int oldEnd = static_cast<int>(before.size() - suffix);
253+
const int oldLen = oldEnd - oldStart;
254+
const std::string inserted = after.substr(prefix, after.size() - prefix - suffix);
255+
const bool hasSelection = selectionStart != selectionEnd;
256+
const bool backspaceLike = !hasSelection && oldLen > 0 && cursorBefore >= oldEnd;
257+
258+
struct Replacement
259+
{
260+
int start = 0;
261+
int end = 0;
262+
std::string text;
263+
};
264+
std::vector<Replacement> replacements;
265+
for (int caret : app.rightEditExtraCarets)
266+
{
267+
caret = std::clamp(caret, 0, static_cast<int>(before.size()));
268+
if (caret >= oldStart && caret <= oldEnd)
269+
continue;
270+
271+
Replacement r;
272+
r.text = inserted;
273+
if (oldLen == 0)
274+
{
275+
r.start = r.end = caret;
276+
}
277+
else if (backspaceLike)
278+
{
279+
r.end = caret;
280+
r.start = std::max(0, caret - oldLen);
281+
}
282+
else
283+
{
284+
r.start = caret;
285+
r.end = std::min(static_cast<int>(before.size()), caret + oldLen);
286+
}
287+
replacements.push_back(r);
288+
}
289+
if (replacements.empty())
290+
return false;
291+
292+
const int primaryDelta = static_cast<int>(inserted.size()) - oldLen;
293+
for (Replacement &r : replacements)
294+
{
295+
if (r.start > oldEnd)
296+
r.start += primaryDelta;
297+
if (r.end > oldEnd)
298+
r.end += primaryDelta;
299+
r.start = std::clamp(r.start, 0, static_cast<int>(after.size()));
300+
r.end = std::clamp(r.end, r.start, static_cast<int>(after.size()));
301+
}
302+
303+
std::sort(replacements.begin(), replacements.end(), [](const Replacement &a, const Replacement &b) { return a.start > b.start; });
304+
std::string out = after;
305+
for (const Replacement &r : replacements)
306+
{
307+
out.replace(static_cast<size_t>(r.start), static_cast<size_t>(std::max(0, r.end - r.start)), r.text);
308+
}
309+
310+
std::vector<Replacement> ascending = replacements;
311+
std::sort(ascending.begin(), ascending.end(), [](const Replacement &a, const Replacement &b) { return a.start < b.start; });
312+
std::vector<int> newCarets;
313+
int shift = 0;
314+
cursorAfterMultiEdit = std::clamp(cursorAfterEdit, 0, static_cast<int>(after.size()));
315+
for (const Replacement &r : ascending)
316+
{
317+
if (r.start <= cursorAfterMultiEdit)
318+
cursorAfterMultiEdit += static_cast<int>(r.text.size()) - (r.end - r.start);
319+
newCarets.push_back(r.start + shift + static_cast<int>(r.text.size()));
320+
shift += static_cast<int>(r.text.size()) - (r.end - r.start);
321+
}
322+
after = std::move(out);
323+
app.rightEditExtraCarets = std::move(newCarets);
324+
return true;
325+
}
326+
126327
static int diffLineCount(const std::string &text)
127328
{
128329
return static_cast<int>(splitLinesForDiff(text).size());
@@ -1786,6 +1987,12 @@ namespace gitReview
17861987
buf.reserve(buf.size() + 2048u);
17871988

17881989
const std::string undoBefore = readOnly ? std::string{} : rightBufferText(app);
1990+
const std::string rawBefore = readOnly ? std::string{} : bufferToString(buf);
1991+
const ImGuiID inputId = ImGui::GetID(inputLabel);
1992+
ImGuiInputTextState *preEditState = ImGui::GetInputTextState(inputId);
1993+
const int cursorBefore = preEditState ? preEditState->GetCursorPos() : 0;
1994+
const int selectionStart = preEditState ? preEditState->GetSelectionStart() : cursorBefore;
1995+
const int selectionEnd = preEditState ? preEditState->GetSelectionEnd() : cursorBefore;
17891996
ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.f, 0.f, 0.f, 0.f));
17901997
ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, ImVec4(0.f, 0.f, 0.f, 0.f));
17911998
ImGui::PushStyleColor(ImGuiCol_FrameBgActive, ImVec4(0.f, 0.f, 0.f, 0.f));
@@ -1797,23 +2004,26 @@ namespace gitReview
17972004
ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarSize, 0.0f);
17982005
const bool edited = ImGui::InputTextMultiline(inputLabel, buf.data(), static_cast<int>(buf.size()), ImVec2(innerWL, paneH), fl,
17992006
vectorResizeCallback, &buf);
1800-
const ImGuiID inputId = ImGui::GetItemID();
18012007
if (edited && pane == M3PaneCol::Work)
18022008
{
1803-
const std::string rawAfterEdit = bufferToString(buf);
2009+
std::string rawAfterEdit = bufferToString(buf);
2010+
ImGuiInputTextState *editedState = ImGui::GetInputTextState(inputId);
2011+
int cursorAfterMultiEdit = editedState ? editedState->GetCursorPos() : cursorBefore;
2012+
const bool multiCaretApplied = applyEditToExtraCarets(app, rawBefore, rawAfterEdit, cursorBefore, cursorAfterMultiEdit, selectionStart,
2013+
selectionEnd, cursorAfterMultiEdit);
2014+
if (multiCaretApplied)
2015+
setBufferText(buf, rawAfterEdit);
18042016
cachedMergeThreePaneRows(app);
18052017
noteRightEditForUndo(app, undoBefore, rightBufferText(app));
1806-
1807-
1808-
1809-
1810-
1811-
1812-
1813-
if (bufferToString(buf) != rawAfterEdit)
2018+
if (multiCaretApplied || bufferToString(buf) != rawAfterEdit)
18142019
{
18152020
if (ImGuiInputTextState *st = ImGui::GetInputTextState(inputId))
1816-
reloadUserBufferPreservingCursor(st, rawAfterEdit, bufferToString(buf));
2021+
{
2022+
if (multiCaretApplied)
2023+
reloadUserBufferAtCursor(st, cursorAfterMultiEdit);
2024+
else
2025+
reloadUserBufferPreservingCursor(st, rawAfterEdit, bufferToString(buf));
2026+
}
18172027
}
18182028
}
18192029
if (!readOnly && ImGui::IsItemActive())
@@ -1862,6 +2072,16 @@ namespace gitReview
18622072
}
18632073
}
18642074
}
2075+
if (!readOnly)
2076+
{
2077+
ImGuiInputTextState *st = ImGui::GetInputTextState(inputId);
2078+
const int primaryCursor = st ? st->GetCursorPos() : -1;
2079+
const std::string curText = bufferToString(buf);
2080+
addExtraCaretFromMouse(app, curText, innerL, gutterW, padXL, padYL, lineH, primaryCursor, app.rightEditLastPrimaryCursor);
2081+
mergeDuplicateCarets(app, primaryCursor, static_cast<int>(curText.size()));
2082+
drawExtraCarets(app, curText, innerL, gutterW, padXL, padYL, lineH);
2083+
app.rightEditLastPrimaryCursor = primaryCursor;
2084+
}
18652085
ImGui::SetCursorPos(ImVec2(0.f, 0.f));
18662086
ImGuiListClipper clipGut;
18672087
clipGut.Begin(static_cast<int>(rows.size()), lineH);
@@ -2312,6 +2532,12 @@ namespace gitReview
23122532
buf.reserve(buf.size() + 2048u);
23132533

23142534
const std::string undoBefore = app.rightSideIsWorktreeFile ? rightBufferText(app) : std::string{};
2535+
const std::string rawBefore = app.rightSideIsWorktreeFile ? bufferToString(buf) : std::string{};
2536+
const ImGuiID rightInputId = ImGui::GetID("##rightEditMain");
2537+
ImGuiInputTextState *preEditState = ImGui::GetInputTextState(rightInputId);
2538+
const int cursorBefore = preEditState ? preEditState->GetCursorPos() : 0;
2539+
const int selectionStart = preEditState ? preEditState->GetSelectionStart() : cursorBefore;
2540+
const int selectionEnd = preEditState ? preEditState->GetSelectionEnd() : cursorBefore;
23152541
ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.f, 0.f, 0.f, 0.f));
23162542
ImGui::PushStyleColor(ImGuiCol_FrameBgHovered, ImVec4(0.f, 0.f, 0.f, 0.f));
23172543
ImGui::PushStyleColor(ImGuiCol_FrameBgActive, ImVec4(0.f, 0.f, 0.f, 0.f));
@@ -2320,25 +2546,38 @@ namespace gitReview
23202546
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.f, 0.f, 0.f, 0.f));
23212547
const bool rightEdited = ImGui::InputTextMultiline("##rightEditMain", buf.data(), static_cast<int>(buf.size()), ImVec2(textW, paneH), editFlags,
23222548
vectorResizeCallback, &buf);
2323-
const ImGuiID rightInputId = ImGui::GetItemID();
23242549
if (rightEdited && app.rightSideIsWorktreeFile)
23252550
{
2326-
const std::string rawAfterEdit = bufferToString(buf);
2551+
std::string rawAfterEdit = bufferToString(buf);
2552+
ImGuiInputTextState *editedState = ImGui::GetInputTextState(rightInputId);
2553+
int cursorAfterMultiEdit = editedState ? editedState->GetCursorPos() : cursorBefore;
2554+
const bool multiCaretApplied = applyEditToExtraCarets(app, rawBefore, rawAfterEdit, cursorBefore, cursorAfterMultiEdit, selectionStart,
2555+
selectionEnd, cursorAfterMultiEdit);
2556+
if (multiCaretApplied)
2557+
setBufferText(buf, rawAfterEdit);
23272558
cachedDiffRows(app);
23282559
noteRightEditForUndo(app, undoBefore, rightBufferText(app));
2329-
2330-
2331-
2332-
2333-
2334-
2335-
2336-
if (bufferToString(buf) != rawAfterEdit)
2560+
if (multiCaretApplied || bufferToString(buf) != rawAfterEdit)
23372561
{
23382562
if (ImGuiInputTextState *rightState = ImGui::GetInputTextState(rightInputId))
2339-
reloadUserBufferPreservingCursor(rightState, rawAfterEdit, bufferToString(buf));
2563+
{
2564+
if (multiCaretApplied)
2565+
reloadUserBufferAtCursor(rightState, cursorAfterMultiEdit);
2566+
else
2567+
reloadUserBufferPreservingCursor(rightState, rawAfterEdit, bufferToString(buf));
2568+
}
23402569
}
23412570
}
2571+
if (app.rightSideIsWorktreeFile)
2572+
{
2573+
ImGuiInputTextState *rightState = ImGui::GetInputTextState(rightInputId);
2574+
const int primaryCursor = rightState ? rightState->GetCursorPos() : -1;
2575+
const std::string curText = bufferToString(buf);
2576+
addExtraCaretFromMouse(app, curText, inner0, gutterW, padX, padY, lineH, primaryCursor, app.rightEditLastPrimaryCursor);
2577+
mergeDuplicateCarets(app, primaryCursor, static_cast<int>(curText.size()));
2578+
drawExtraCarets(app, curText, inner0, gutterW, padX, padY, lineH);
2579+
app.rightEditLastPrimaryCursor = primaryCursor;
2580+
}
23422581
if (app.rightSideIsWorktreeFile && ImGui::IsItemActive())
23432582
{
23442583
if (ImGui::IsKeyChordPressed(ImGuiMod_Ctrl | ImGuiKey_Z))
@@ -2820,4 +3059,4 @@ namespace gitReview
28203059

28213060
drawHistoryBrowser(app);
28223061
}
2823-
}
3062+
}

Examples/ExampleGitReview/ReviewSession.cpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,8 @@ namespace gitReview
361361
app.rightEditUndoChunkActive = false;
362362
app.rightEditUndoLastSeconds = -1000.0;
363363
app.rightEditUndoLastLineCount = 0;
364+
app.rightEditExtraCarets.clear();
365+
app.rightEditLastPrimaryCursor = -1;
364366
app.rightSideIsWorktreeFile = false;
365367
app.binaryFile = false;
366368
app.cachedDiffLeft.clear();

Examples/ExampleGitReview/ReviewSession.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ namespace gitReview
5454
bool rightEditUndoChunkActive = false;
5555
double rightEditUndoLastSeconds = -1000.0;
5656
int rightEditUndoLastLineCount = 0;
57+
std::vector<int> rightEditExtraCarets;
58+
int rightEditLastPrimaryCursor = -1;
5759
/// Canonical working-tree text last loaded from disk or written by Save; used for the unsaved (*) indicator.
5860
std::string rightWorktreeSavedCanon;
5961
bool rightSideIsWorktreeFile = false;

0 commit comments

Comments
 (0)