Skip to content

Commit 5b2f5c7

Browse files
committed
feat(input): support for undo/redo
1 parent 52fd519 commit 5b2f5c7

2 files changed

Lines changed: 182 additions & 3 deletions

File tree

src/ui/controls/input.cpp

Lines changed: 154 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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_
409415
void Input::setPasswordMaskStyle(PasswordMaskStyle style) noexcept { g_passwordMaskStyle = style; }
410416

411417
void Input::selectAll() {
418+
resetUndoCoalescing();
412419
m_selectionAnchor = 0;
413420
m_cursorPos = m_value.size();
414421
updateInteractiveGeometry();
415422
markPaintDirty();
416423
}
417424

418425
void 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

433441
void 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

448457
void 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

11171176
std::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+
11191186
void 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+
11271278
std::size_t Input::xToByteOffset(float localX) const {
11281279
if (m_stopX.empty() || localX <= 0.0f) {
11291280
return 0;

src/ui/controls/input.h

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,20 @@ class Input : public Node {
6666
[[nodiscard]] bool invalid() const noexcept { return m_invalid; }
6767

6868
private:
69+
enum class EditCoalesceKind : std::uint8_t {
70+
None = 0,
71+
Typing = 1,
72+
Discrete = 2,
73+
};
74+
75+
struct EditSnapshot {
76+
std::string value;
77+
std::size_t cursorPos = 0;
78+
std::size_t selectionAnchor = 0;
79+
80+
bool operator==(const EditSnapshot&) const = default;
81+
};
82+
6983
void doLayout(Renderer& renderer) override;
7084
LayoutSize doMeasure(Renderer& renderer, const LayoutConstraints& constraints) override;
7185
void handleKey(std::uint32_t sym, std::uint32_t utf32, std::uint32_t modifiers, bool preedit = false);
@@ -93,7 +107,16 @@ class Input : public Node {
93107
[[nodiscard]] std::size_t selectionStart() const noexcept;
94108
[[nodiscard]] std::size_t selectionEnd() const noexcept;
95109
[[nodiscard]] bool isReadOnlyVisual() const noexcept;
110+
[[nodiscard]] EditSnapshot currentEditSnapshot() const;
96111
void deleteSelection();
112+
void clearEditHistory();
113+
void resetUndoCoalescing();
114+
void pushUndoSnapshot(EditCoalesceKind kind);
115+
void noteTypingEditEnd();
116+
bool undoEdit();
117+
bool redoEdit();
118+
bool restoreFromHistory(std::vector<EditSnapshot>& source, std::vector<EditSnapshot>& target);
119+
void restoreEditSnapshot(const EditSnapshot& snapshot);
97120
[[nodiscard]] std::size_t xToByteOffset(float localX) const;
98121
[[nodiscard]] float stopXForByte(std::size_t bytePos) const;
99122
void syncPasswordGlyphNodes(std::size_t count);
@@ -117,6 +140,11 @@ class Input : public Node {
117140
std::size_t m_selectionAnchor = 0;
118141
std::size_t m_preeditStart = 0;
119142
std::size_t m_preeditLen = 0;
143+
std::vector<EditSnapshot> m_undoStack;
144+
std::vector<EditSnapshot> m_redoStack;
145+
EditCoalesceKind m_lastEditCoalesceKind = EditCoalesceKind::None;
146+
std::chrono::steady_clock::time_point m_lastUndoRecordTime{};
147+
std::size_t m_typingCoalesceCursorPos = 0;
120148

121149
std::vector<float> m_stopX;
122150
std::vector<std::size_t> m_stopByte;

0 commit comments

Comments
 (0)