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
1922namespace 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+ }
0 commit comments