@@ -70,6 +70,8 @@ namespace {
7070 constexpr float kPasswordGlyphScale = 0 .82f ;
7171 constexpr auto kDoubleClickThreshold = std::chrono::milliseconds(400 );
7272 constexpr float kDoubleClickDistance = 6 .0f ;
73+ constexpr std::size_t kUndoStackLimit = 100 ;
74+ constexpr auto kTypingUndoCoalesceWindow = std::chrono::milliseconds(1000 );
7375
7476 bool isWordCodepoint (const std::string& text, std::size_t bytePos) {
7577 if (bytePos >= text.size ()) {
@@ -162,6 +164,7 @@ Input::Input() {
162164 });
163165 area->setOnPress ([this ](const InputArea::PointerData& data) {
164166 if (data.pressed ) {
167+ resetUndoCoalescing ();
165168 const float textStartX = m_horizontalPadding + kTextInnerInset ;
166169 const std::size_t offset = xToByteOffset (data.localX - textStartX + m_scrollOffset - m_contentLeadSlack);
167170 const auto now = std::chrono::steady_clock::now ();
@@ -193,6 +196,7 @@ Input::Input() {
193196 });
194197 area->setOnMotion ([this ](const InputArea::PointerData& data) {
195198 if (m_inputArea != nullptr && m_inputArea->pressed ()) {
199+ resetUndoCoalescing ();
196200 const float widthPx = width () > 0 .0f ? width () : kMinWidth ;
197201 const float edgePx = std::max (12 .0f , m_horizontalPadding);
198202 const float scrollNudge = std::max (4 .0f , textViewportWidth () * 0 .02f );
@@ -225,6 +229,7 @@ Input::Input() {
225229 if (std::abs (delta) < 0 .001f ) {
226230 return false ;
227231 }
232+ resetUndoCoalescing ();
228233 // Wheel should move caret through text, not pan viewport directly.
229234 constexpr int kWheelCaretStep = 1 ;
230235 if (delta > 0 .0f ) {
@@ -274,6 +279,7 @@ void Input::setValue(std::string_view value) {
274279 m_value = std::string (value);
275280 m_cursorPos = m_value.size ();
276281 m_selectionAnchor = m_cursorPos;
282+ clearEditHistory ();
277283 updateDisplayText ();
278284 markLayoutDirty ();
279285}
@@ -409,13 +415,15 @@ void Input::setValidateKeyMatcher(std::function<bool(std::uint32_t, std::uint32_
409415void Input::setPasswordMaskStyle (PasswordMaskStyle style) noexcept { g_passwordMaskStyle = style; }
410416
411417void Input::selectAll () {
418+ resetUndoCoalescing ();
412419 m_selectionAnchor = 0 ;
413420 m_cursorPos = m_value.size ();
414421 updateInteractiveGeometry ();
415422 markPaintDirty ();
416423}
417424
418425void Input::moveCaretLeft (bool shift) {
426+ resetUndoCoalescing ();
419427 if (!shift && hasSelection ()) {
420428 m_cursorPos = selectionStart ();
421429 m_selectionAnchor = m_cursorPos;
@@ -431,6 +439,7 @@ void Input::moveCaretLeft(bool shift) {
431439}
432440
433441void Input::moveCaretRight (bool shift) {
442+ resetUndoCoalescing ();
434443 if (!shift && hasSelection ()) {
435444 m_cursorPos = selectionEnd ();
436445 m_selectionAnchor = m_cursorPos;
@@ -446,6 +455,7 @@ void Input::moveCaretRight(bool shift) {
446455}
447456
448457void Input::clearSelection () {
458+ resetUndoCoalescing ();
449459 m_selectionAnchor = m_cursorPos;
450460 updateInteractiveGeometry ();
451461 markPaintDirty ();
@@ -581,20 +591,22 @@ void Input::handleKey(std::uint32_t sym, std::uint32_t utf32, std::uint32_t modi
581591 }
582592
583593 const bool validateMatch = g_validateKeyMatcher && g_validateKeyMatcher (sym, modifiers);
594+ const bool shift = (modifiers & kModShift ) != 0 ;
595+ const bool ctrl = (modifiers & kModCtrl ) != 0 ;
596+ const bool undoShortcut = ctrl && !shift && (sym == ' z' || sym == ' Z' );
597+ const bool redoShortcut = (ctrl && (sym == ' y' || sym == ' Y' )) || (ctrl && shift && (sym == ' z' || sym == ' Z' ));
584598
585599 // Ignore keys that produce no text and aren't action keys we handle below
586600 if (utf32 == 0 && !preedit) {
587601 const bool navigationOrEdit = sym == XKB_KEY_BackSpace || sym == XKB_KEY_Delete || sym == XKB_KEY_Left ||
588602 sym == XKB_KEY_Right || sym == XKB_KEY_Home || sym == XKB_KEY_End ||
589- sym == XKB_KEY_Insert;
603+ sym == XKB_KEY_Insert || undoShortcut || redoShortcut ;
590604 if (!navigationOrEdit && !validateMatch) {
591605 return ;
592606 }
593607 }
594608
595609 bool changed = false ;
596- const bool shift = (modifiers & kModShift ) != 0 ;
597- const bool ctrl = (modifiers & kModCtrl ) != 0 ;
598610
599611 // Remove previous preedit text before processing
600612 if (m_preeditLen > 0 ) {
@@ -610,8 +622,38 @@ void Input::handleKey(std::uint32_t sym, std::uint32_t utf32, std::uint32_t modi
610622 (ctrl && (sym == ' x' || sym == ' X' )) || (!ctrl && shift && sym == XKB_KEY_Delete && hasSelection ());
611623 const bool pasteShortcut = (ctrl && (sym == ' v' || sym == ' V' )) || (!ctrl && shift && sym == XKB_KEY_Insert);
612624
625+ if (undoShortcut) {
626+ if (undoEdit ()) {
627+ return ;
628+ }
629+ if (changed) {
630+ updateDisplayText ();
631+ markLayoutDirty ();
632+ revealCursor ();
633+ if (!preedit && m_onChange) {
634+ m_onChange (m_value);
635+ }
636+ }
637+ return ;
638+ }
639+ if (redoShortcut) {
640+ if (redoEdit ()) {
641+ return ;
642+ }
643+ if (changed) {
644+ updateDisplayText ();
645+ markLayoutDirty ();
646+ revealCursor ();
647+ if (!preedit && m_onChange) {
648+ m_onChange (m_value);
649+ }
650+ }
651+ return ;
652+ }
653+
613654 if (ctrl && (sym == ' a' || sym == ' A' )) {
614655 // Select all
656+ resetUndoCoalescing ();
615657 m_selectionAnchor = 0 ;
616658 m_cursorPos = m_value.size ();
617659 } else if (copyShortcut) {
@@ -620,13 +662,15 @@ void Input::handleKey(std::uint32_t sym, std::uint32_t utf32, std::uint32_t modi
620662 }
621663 } else if (cutShortcut) {
622664 if (g_clipboard != nullptr && hasSelection ()) {
665+ pushUndoSnapshot (EditCoalesceKind::Discrete);
623666 g_clipboard->copyText (m_value.substr (selectionStart (), selectionEnd () - selectionStart ()));
624667 deleteSelection ();
625668 changed = true ;
626669 }
627670 } else if (pasteShortcut) {
628671 if (g_clipboard != nullptr ) {
629672 if (auto text = readClipboardText (); text.has_value ()) {
673+ pushUndoSnapshot (EditCoalesceKind::Discrete);
630674 if (hasSelection ()) {
631675 deleteSelection ();
632676 }
@@ -638,9 +682,11 @@ void Input::handleKey(std::uint32_t sym, std::uint32_t utf32, std::uint32_t modi
638682 }
639683 } else if (sym == XKB_KEY_BackSpace) {
640684 if (hasSelection ()) {
685+ pushUndoSnapshot (EditCoalesceKind::Discrete);
641686 deleteSelection ();
642687 changed = true ;
643688 } else if (m_cursorPos > 0 ) {
689+ pushUndoSnapshot (EditCoalesceKind::Discrete);
644690 const std::size_t prev = ctrl ? previousWordStartForByteOffset (m_cursorPos) : prevCharPos (m_value, m_cursorPos);
645691 m_value.erase (prev, m_cursorPos - prev);
646692 m_cursorPos = prev;
@@ -649,14 +695,17 @@ void Input::handleKey(std::uint32_t sym, std::uint32_t utf32, std::uint32_t modi
649695 }
650696 } else if (sym == XKB_KEY_Delete) {
651697 if (hasSelection ()) {
698+ pushUndoSnapshot (EditCoalesceKind::Discrete);
652699 deleteSelection ();
653700 changed = true ;
654701 } else if (m_cursorPos < m_value.size ()) {
702+ pushUndoSnapshot (EditCoalesceKind::Discrete);
655703 const std::size_t next = ctrl ? nextWordEndForByteOffset (m_cursorPos) : nextCharPos (m_value, m_cursorPos);
656704 m_value.erase (m_cursorPos, next - m_cursorPos);
657705 changed = true ;
658706 }
659707 } else if (sym == XKB_KEY_Left) {
708+ resetUndoCoalescing ();
660709 if (!shift && hasSelection ()) {
661710 // Collapse to start of selection
662711 m_cursorPos = selectionStart ();
@@ -668,6 +717,7 @@ void Input::handleKey(std::uint32_t sym, std::uint32_t utf32, std::uint32_t modi
668717 }
669718 }
670719 } else if (sym == XKB_KEY_Right) {
720+ resetUndoCoalescing ();
671721 if (!shift && hasSelection ()) {
672722 // Collapse to end of selection
673723 m_cursorPos = selectionEnd ();
@@ -679,11 +729,13 @@ void Input::handleKey(std::uint32_t sym, std::uint32_t utf32, std::uint32_t modi
679729 }
680730 }
681731 } else if (sym == XKB_KEY_Home) {
732+ resetUndoCoalescing ();
682733 m_cursorPos = 0 ;
683734 if (!shift) {
684735 m_selectionAnchor = 0 ;
685736 }
686737 } else if (sym == XKB_KEY_End) {
738+ resetUndoCoalescing ();
687739 m_cursorPos = m_value.size ();
688740 if (!shift) {
689741 m_selectionAnchor = m_cursorPos;
@@ -694,6 +746,9 @@ void Input::handleKey(std::uint32_t sym, std::uint32_t utf32, std::uint32_t modi
694746 }
695747 } else if (utf32 >= 0x20U && utf32 != 0x7FU ) {
696748 // Printable character (skip DEL = 0x7F)
749+ if (!preedit) {
750+ pushUndoSnapshot (hasSelection () ? EditCoalesceKind::Discrete : EditCoalesceKind::Typing);
751+ }
697752 if (hasSelection ()) {
698753 deleteSelection ();
699754 changed = true ;
@@ -707,6 +762,9 @@ void Input::handleKey(std::uint32_t sym, std::uint32_t utf32, std::uint32_t modi
707762 m_cursorPos += bytes.size ();
708763 m_selectionAnchor = m_cursorPos;
709764 changed = true ;
765+ if (!preedit) {
766+ noteTypingEditEnd ();
767+ }
710768 }
711769
712770 updateDisplayText ();
@@ -825,6 +883,7 @@ void Input::clearFromButton() {
825883 if (m_value.empty ()) {
826884 return ;
827885 }
886+ pushUndoSnapshot (EditCoalesceKind::Discrete);
828887 m_value.clear ();
829888 m_cursorPos = 0 ;
830889 m_selectionAnchor = 0 ;
@@ -1116,6 +1175,14 @@ std::size_t Input::selectionStart() const noexcept { return std::min(m_selection
11161175
11171176std::size_t Input::selectionEnd () const noexcept { return std::max (m_selectionAnchor, m_cursorPos); }
11181177
1178+ Input::EditSnapshot Input::currentEditSnapshot () const {
1179+ return EditSnapshot{
1180+ .value = m_value,
1181+ .cursorPos = m_cursorPos,
1182+ .selectionAnchor = m_selectionAnchor,
1183+ };
1184+ }
1185+
11191186void Input::deleteSelection () {
11201187 const std::size_t start = selectionStart ();
11211188 const std::size_t end = selectionEnd ();
@@ -1124,6 +1191,90 @@ void Input::deleteSelection() {
11241191 m_selectionAnchor = start;
11251192}
11261193
1194+ void Input::clearEditHistory () {
1195+ m_undoStack.clear ();
1196+ m_redoStack.clear ();
1197+ resetUndoCoalescing ();
1198+ }
1199+
1200+ void Input::resetUndoCoalescing () {
1201+ m_lastEditCoalesceKind = EditCoalesceKind::None;
1202+ m_lastUndoRecordTime = {};
1203+ m_typingCoalesceCursorPos = m_cursorPos;
1204+ }
1205+
1206+ void Input::pushUndoSnapshot (EditCoalesceKind kind) {
1207+ if (kind == EditCoalesceKind::None) {
1208+ resetUndoCoalescing ();
1209+ return ;
1210+ }
1211+
1212+ const auto now = std::chrono::steady_clock::now ();
1213+ if (kind == EditCoalesceKind::Typing && m_lastEditCoalesceKind == EditCoalesceKind::Typing &&
1214+ m_cursorPos == m_typingCoalesceCursorPos && !hasSelection () && !m_undoStack.empty () &&
1215+ now - m_lastUndoRecordTime <= kTypingUndoCoalesceWindow ) {
1216+ m_redoStack.clear ();
1217+ m_lastUndoRecordTime = now;
1218+ return ;
1219+ }
1220+
1221+ const EditSnapshot snapshot = currentEditSnapshot ();
1222+ if (m_undoStack.empty () || !(m_undoStack.back () == snapshot)) {
1223+ m_undoStack.push_back (snapshot);
1224+ if (m_undoStack.size () > kUndoStackLimit ) {
1225+ m_undoStack.erase (m_undoStack.begin ());
1226+ }
1227+ }
1228+ m_redoStack.clear ();
1229+ m_lastEditCoalesceKind = kind;
1230+ m_lastUndoRecordTime = now;
1231+ m_typingCoalesceCursorPos = m_cursorPos;
1232+ }
1233+
1234+ void Input::noteTypingEditEnd () { m_typingCoalesceCursorPos = m_cursorPos; }
1235+
1236+ bool Input::undoEdit () { return restoreFromHistory (m_undoStack, m_redoStack); }
1237+
1238+ bool Input::redoEdit () { return restoreFromHistory (m_redoStack, m_undoStack); }
1239+
1240+ bool Input::restoreFromHistory (std::vector<EditSnapshot>& source, std::vector<EditSnapshot>& target) {
1241+ if (source.empty ()) {
1242+ resetUndoCoalescing ();
1243+ return false ;
1244+ }
1245+
1246+ const EditSnapshot current = currentEditSnapshot ();
1247+ const EditSnapshot snapshot = source.back ();
1248+ source.pop_back ();
1249+ if (target.empty () || !(target.back () == current)) {
1250+ target.push_back (current);
1251+ if (target.size () > kUndoStackLimit ) {
1252+ target.erase (target.begin ());
1253+ }
1254+ }
1255+ restoreEditSnapshot (snapshot);
1256+ resetUndoCoalescing ();
1257+ return true ;
1258+ }
1259+
1260+ void Input::restoreEditSnapshot (const EditSnapshot& snapshot) {
1261+ const std::string previousValue = m_value;
1262+ m_value = snapshot.value ;
1263+ m_cursorPos = std::min (snapshot.cursorPos , m_value.size ());
1264+ m_selectionAnchor = std::min (snapshot.selectionAnchor , m_value.size ());
1265+ m_preeditStart = 0 ;
1266+ m_preeditLen = 0 ;
1267+ updateDisplayText ();
1268+ updateInteractiveGeometry ();
1269+ revealCursor ();
1270+ applyVisualState ();
1271+ markLayoutDirty ();
1272+ markPaintDirty ();
1273+ if (m_value != previousValue && m_onChange) {
1274+ m_onChange (m_value);
1275+ }
1276+ }
1277+
11271278std::size_t Input::xToByteOffset (float localX) const {
11281279 if (m_stopX.empty () || localX <= 0 .0f ) {
11291280 return 0 ;
0 commit comments