Added Undo/Redo to TextBuffer
This commit is contained in:
parent
abc04ec132
commit
d5bf65465a
5 changed files with 402 additions and 66 deletions
2
build
2
build
|
|
@ -49,7 +49,7 @@ else
|
||||||
gdb -batch \
|
gdb -batch \
|
||||||
-ex 'set pagination off' \
|
-ex 'set pagination off' \
|
||||||
-ex 'set print pretty on' \
|
-ex 'set print pretty on' \
|
||||||
-ex 'set logging file ../gdb-last.log' \
|
-ex 'set logging file gdb-last.log' \
|
||||||
-ex 'set logging overwrite on' \
|
-ex 'set logging overwrite on' \
|
||||||
-ex 'set logging on' \
|
-ex 'set logging on' \
|
||||||
-ex 'handle SIGPIPE nostop noprint pass' \
|
-ex 'handle SIGPIPE nostop noprint pass' \
|
||||||
|
|
|
||||||
38
gdb-last.log
38
gdb-last.log
|
|
@ -1,38 +0,0 @@
|
||||||
|
|
||||||
This GDB supports auto-downloading debuginfo from the following URLs:
|
|
||||||
<https://debuginfod.archlinux.org>
|
|
||||||
Enable debuginfod for this session? (y or [n]) [answered N; input not from terminal]
|
|
||||||
Debuginfod has been disabled.
|
|
||||||
To make this setting permanent, add 'set debuginfod enabled off' to .gdbinit.
|
|
||||||
[Thread debugging using libthread_db enabled]
|
|
||||||
Using host libthread_db library "/usr/lib/libthread_db.so.1".
|
|
||||||
[New Thread 0x7ffff41726c0 (LWP 82519)]
|
|
||||||
[New Thread 0x7fffe1dff6c0 (LWP 82522)]
|
|
||||||
[New Thread 0x7fffe11ff6c0 (LWP 82523)]
|
|
||||||
[New Thread 0x7fffe09fe6c0 (LWP 82524)]
|
|
||||||
[New Thread 0x7fffd3fff6c0 (LWP 82525)]
|
|
||||||
[New Thread 0x7fffd33fe6c0 (LWP 82526)]
|
|
||||||
[New Thread 0x7fffd2bfd6c0 (LWP 82527)]
|
|
||||||
[New Thread 0x7fffd23fc6c0 (LWP 82528)]
|
|
||||||
[New Thread 0x7fffd1bfb6c0 (LWP 82529)]
|
|
||||||
[New Thread 0x7fffd13fa6c0 (LWP 82530)]
|
|
||||||
[New Thread 0x7fffd0bf96c0 (LWP 82531)]
|
|
||||||
[New Thread 0x7fff822bf6c0 (LWP 82538)]
|
|
||||||
[Thread 0x7fffd0bf96c0 (LWP 82531) exited]
|
|
||||||
[Thread 0x7fffd13fa6c0 (LWP 82530) exited]
|
|
||||||
[Thread 0x7fffd1bfb6c0 (LWP 82529) exited]
|
|
||||||
[Thread 0x7fffd33fe6c0 (LWP 82526) exited]
|
|
||||||
[Thread 0x7fffd2bfd6c0 (LWP 82527) exited]
|
|
||||||
[Thread 0x7fffd23fc6c0 (LWP 82528) exited]
|
|
||||||
[Thread 0x7fffe09fe6c0 (LWP 82524) exited]
|
|
||||||
[Thread 0x7fffd3fff6c0 (LWP 82525) exited]
|
|
||||||
[Thread 0x7fffe11ff6c0 (LWP 82523) exited]
|
|
||||||
[Thread 0x7fffe1dff6c0 (LWP 82522) exited]
|
|
||||||
[Thread 0x7ffff41726c0 (LWP 82519) exited]
|
|
||||||
[Thread 0x7ffff7b5bf80 (LWP 82512) exited]
|
|
||||||
[Thread 0x7fff822bf6c0 (LWP 82538) exited]
|
|
||||||
[New process 82512]
|
|
||||||
[Inferior 1 (process 82512) exited normally]
|
|
||||||
No stack.
|
|
||||||
No frame selected.
|
|
||||||
No frame selected.
|
|
||||||
|
|
@ -459,11 +459,13 @@ namespace ogfx
|
||||||
}
|
}
|
||||||
else if (data.isUndo())
|
else if (data.isUndo())
|
||||||
{
|
{
|
||||||
std::cout << "Undo is not implemented yet!\n";
|
if (m_buffer.canUndo())
|
||||||
|
m_buffer.undo();
|
||||||
}
|
}
|
||||||
else if (data.isRedo())
|
else if (data.isRedo())
|
||||||
{
|
{
|
||||||
std::cout << "Redo is not implemented yet!\n";
|
if (m_buffer.canRedo())
|
||||||
|
m_buffer.redo();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- Cursor movement -----
|
// ----- Cursor movement -----
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include "TextBuffer.hpp"
|
#include "TextBuffer.hpp"
|
||||||
|
#include <ostd/utils/Time.hpp>
|
||||||
|
|
||||||
namespace ostd
|
namespace ostd
|
||||||
{
|
{
|
||||||
|
|
@ -101,12 +102,16 @@ namespace ostd
|
||||||
m_multiline = ml;
|
m_multiline = ml;
|
||||||
if (!m_multiline)
|
if (!m_multiline)
|
||||||
{
|
{
|
||||||
strip_newlines_from_buffer();
|
// Strip newlines via replace_range so the change is undoable as
|
||||||
m_cursor = clamp_to_codepoint_boundary(m_cursor);
|
// a single step. We compute the stripped form on a copy and
|
||||||
m_anchor = clamp_to_codepoint_boundary(m_anchor);
|
// hand it to the chokepoint.
|
||||||
m_lineStartsDirty = true;
|
String stripped = m_buffer;
|
||||||
fire_changed();
|
stripped.replaceAll("\r\n", " ");
|
||||||
fire_cursor_moved();
|
stripped.replaceAll("\n", " ");
|
||||||
|
stripped.replaceAll("\r", " ");
|
||||||
|
if (stripped.cpp_str() != m_buffer.cpp_str())
|
||||||
|
replace_range(0, cast<u32>(m_buffer.len()), stripped, EditKind::Generic);
|
||||||
|
// If nothing changed (no newlines to strip), no edit is recorded.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -117,24 +122,29 @@ namespace ostd
|
||||||
|
|
||||||
void TextBuffer::setText(const String& text)
|
void TextBuffer::setText(const String& text)
|
||||||
{
|
{
|
||||||
m_buffer = text;
|
// Strip newlines first if we're in single-line mode, since
|
||||||
if (!m_multiline) strip_newlines_from_buffer();
|
// replace_range writes the input as-is. We do this on a copy so the
|
||||||
m_cursor = m_anchor = cast<u32>(m_buffer.len());
|
// caller's String isn't mutated.
|
||||||
|
String to_set = text;
|
||||||
|
if (!m_multiline)
|
||||||
|
{
|
||||||
|
to_set.replaceAll("\r\n", " ");
|
||||||
|
to_set.replaceAll("\n", " ");
|
||||||
|
to_set.replaceAll("\r", " ");
|
||||||
|
}
|
||||||
|
// Route through replace_range so this counts as an undoable operation.
|
||||||
|
// replace_range will leave the cursor at the end of the inserted text,
|
||||||
|
// matching the original setText() contract.
|
||||||
|
replace_range(0, cast<u32>(m_buffer.len()), to_set, EditKind::Generic);
|
||||||
m_desiredColumn = -1;
|
m_desiredColumn = -1;
|
||||||
m_lineStartsDirty = true;
|
|
||||||
fire_changed();
|
|
||||||
fire_cursor_moved();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void TextBuffer::clear(void)
|
void TextBuffer::clear(void)
|
||||||
{
|
{
|
||||||
if (m_buffer.empty() && m_cursor == 0 && m_anchor == 0) return;
|
// replace_range already short-circuits no-op edits, so we don't need
|
||||||
m_buffer.clr();
|
// the explicit empty-and-cursor-at-zero guard the original had.
|
||||||
m_cursor = m_anchor = 0;
|
replace_range(0, cast<u32>(m_buffer.len()), String(""), EditKind::Generic);
|
||||||
m_desiredColumn = -1;
|
m_desiredColumn = -1;
|
||||||
m_lineStartsDirty = true;
|
|
||||||
fire_changed();
|
|
||||||
fire_cursor_moved();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -180,6 +190,7 @@ namespace ostd
|
||||||
{
|
{
|
||||||
if (!hasSelection()) return;
|
if (!hasSelection()) return;
|
||||||
m_anchor = m_cursor;
|
m_anchor = m_cursor;
|
||||||
|
m_lastEditKind = EditKind::None;
|
||||||
fire_cursor_moved();
|
fire_cursor_moved();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -188,6 +199,7 @@ namespace ostd
|
||||||
m_anchor = 0;
|
m_anchor = 0;
|
||||||
m_cursor = cast<u32>(m_buffer.len());
|
m_cursor = cast<u32>(m_buffer.len());
|
||||||
m_desiredColumn = -1;
|
m_desiredColumn = -1;
|
||||||
|
m_lastEditKind = EditKind::None;
|
||||||
fire_cursor_moved();
|
fire_cursor_moved();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -198,6 +210,7 @@ namespace ostd
|
||||||
m_anchor = a;
|
m_anchor = a;
|
||||||
m_cursor = b;
|
m_cursor = b;
|
||||||
m_desiredColumn = -1;
|
m_desiredColumn = -1;
|
||||||
|
m_lastEditKind = EditKind::None;
|
||||||
fire_cursor_moved();
|
fire_cursor_moved();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -357,7 +370,30 @@ namespace ostd
|
||||||
to_insert.replaceAll("\r\n", "\n");
|
to_insert.replaceAll("\r\n", "\n");
|
||||||
to_insert.replaceAll("\r", "\n");
|
to_insert.replaceAll("\r", "\n");
|
||||||
}
|
}
|
||||||
replace_range(selectionStart(), selectionEnd(), to_insert);
|
// Decide the edit kind for undo coalescing. The caller might be
|
||||||
|
// pasting a whole document (definitely Generic — one undo step for
|
||||||
|
// the entire paste) or it might be the keyboard handler shovelling
|
||||||
|
// one typed character at a time through this method (TextEdit does
|
||||||
|
// exactly that — every keystroke arrives as a single-codepoint
|
||||||
|
// String). For the typing case we want the same coalescing as
|
||||||
|
// insertCodepoint, so consecutive typed characters collapse into
|
||||||
|
// one undo step.
|
||||||
|
//
|
||||||
|
// Note: we deliberately *don't* exclude the selection-replace case
|
||||||
|
// here. The selection-replace becomes one InsertCodepoint entry,
|
||||||
|
// and subsequent typing merges into it — so "select, type 'hello'"
|
||||||
|
// is undone in one step. The kind-mismatch check in
|
||||||
|
// try_coalesce_with_top stops this entry from merging backward
|
||||||
|
// into whatever Generic edit preceded the selection, which is the
|
||||||
|
// part that actually matters.
|
||||||
|
EditKind kind = EditKind::Generic;
|
||||||
|
if (!to_insert.empty()
|
||||||
|
&& String::utf8::count_codepoints(to_insert.cpp_str()) == 1
|
||||||
|
&& to_insert.cpp_str() != "\n")
|
||||||
|
{
|
||||||
|
kind = EditKind::InsertCodepoint;
|
||||||
|
}
|
||||||
|
replace_range(selectionStart(), selectionEnd(), to_insert, kind);
|
||||||
m_desiredColumn = -1;
|
m_desiredColumn = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -366,7 +402,16 @@ namespace ostd
|
||||||
if (!m_multiline && codepoint == '\n') return;
|
if (!m_multiline && codepoint == '\n') return;
|
||||||
const String encoded = String::utf8::encode(codepoint);
|
const String encoded = String::utf8::encode(codepoint);
|
||||||
if (encoded.empty()) return; // Invalid codepoint — silently drop.
|
if (encoded.empty()) return; // Invalid codepoint — silently drop.
|
||||||
replace_range(selectionStart(), selectionEnd(), encoded);
|
// Tag as InsertCodepoint unconditionally (except for newlines, where
|
||||||
|
// each line break is a natural undo boundary). When this replaces a
|
||||||
|
// selection, the kind-mismatch check in try_coalesce_with_top stops
|
||||||
|
// it from merging into the previous entry — but it can serve as the
|
||||||
|
// *start* of a fresh coalesce chain that subsequent typing extends,
|
||||||
|
// which means "select, type 'hello'" undoes in one step.
|
||||||
|
const EditKind kind = (codepoint == '\n')
|
||||||
|
? EditKind::Generic
|
||||||
|
: EditKind::InsertCodepoint;
|
||||||
|
replace_range(selectionStart(), selectionEnd(), encoded, kind);
|
||||||
m_desiredColumn = -1;
|
m_desiredColumn = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -380,7 +425,7 @@ namespace ostd
|
||||||
}
|
}
|
||||||
if (m_cursor == 0) return;
|
if (m_cursor == 0) return;
|
||||||
const u32 prev = String::utf8::prev_codepoint_start(m_buffer.cpp_str(), m_cursor);
|
const u32 prev = String::utf8::prev_codepoint_start(m_buffer.cpp_str(), m_cursor);
|
||||||
replace_range(prev, m_cursor, String(""));
|
replace_range(prev, m_cursor, String(""), EditKind::Backspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
void TextBuffer::deleteForward(void)
|
void TextBuffer::deleteForward(void)
|
||||||
|
|
@ -393,7 +438,7 @@ namespace ostd
|
||||||
}
|
}
|
||||||
if (m_cursor >= m_buffer.len()) return;
|
if (m_cursor >= m_buffer.len()) return;
|
||||||
const u32 next = String::utf8::next_codepoint_start(m_buffer.cpp_str(), m_cursor);
|
const u32 next = String::utf8::next_codepoint_start(m_buffer.cpp_str(), m_cursor);
|
||||||
replace_range(m_cursor, next, String(""));
|
replace_range(m_cursor, next, String(""), EditKind::DeleteForward);
|
||||||
}
|
}
|
||||||
|
|
||||||
void TextBuffer::backspaceWord(void)
|
void TextBuffer::backspaceWord(void)
|
||||||
|
|
@ -467,7 +512,8 @@ namespace ostd
|
||||||
// Private — chokepoints
|
// Private — chokepoints
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
void TextBuffer::replace_range(u32 start_byte, u32 end_byte, const String& with)
|
void TextBuffer::replace_range(u32 start_byte, u32 end_byte, const String& with,
|
||||||
|
EditKind kind)
|
||||||
{
|
{
|
||||||
// Defensive clamping. In normal usage the public methods already
|
// Defensive clamping. In normal usage the public methods already
|
||||||
// produce well-formed boundaries, but we want the chokepoint itself
|
// produce well-formed boundaries, but we want the chokepoint itself
|
||||||
|
|
@ -479,6 +525,12 @@ namespace ostd
|
||||||
// Skip no-op edits to avoid spurious notifications.
|
// Skip no-op edits to avoid spurious notifications.
|
||||||
if (start_byte == end_byte && with.empty()) return;
|
if (start_byte == end_byte && with.empty()) return;
|
||||||
|
|
||||||
|
// Snapshot pre-edit state for undo recording. We must do this BEFORE
|
||||||
|
// mutating m_buffer or m_cursor/m_anchor.
|
||||||
|
const u32 cursor_before = m_cursor;
|
||||||
|
const u32 anchor_before = m_anchor;
|
||||||
|
const String removed = m_buffer.new_substr(start_byte, cast<i32>(end_byte));
|
||||||
|
|
||||||
// Build the new buffer.
|
// Build the new buffer.
|
||||||
String head = m_buffer.new_substr(0, cast<i32>(start_byte));
|
String head = m_buffer.new_substr(0, cast<i32>(start_byte));
|
||||||
String tail = m_buffer.new_substr(end_byte, cast<i32>(m_buffer.len()));
|
String tail = m_buffer.new_substr(end_byte, cast<i32>(m_buffer.len()));
|
||||||
|
|
@ -489,8 +541,28 @@ namespace ostd
|
||||||
m_cursor = m_anchor = new_cursor;
|
m_cursor = m_anchor = new_cursor;
|
||||||
|
|
||||||
m_lineStartsDirty = true;
|
m_lineStartsDirty = true;
|
||||||
|
|
||||||
|
// Record this edit on the undo stack — but only if it didn't itself
|
||||||
|
// originate from undo()/redo() applying a stored entry.
|
||||||
|
if (!m_applyingHistory)
|
||||||
|
{
|
||||||
|
UndoEntry e;
|
||||||
|
e.start = start_byte;
|
||||||
|
e.removed = removed;
|
||||||
|
e.inserted = with;
|
||||||
|
e.cursor_before = cursor_before;
|
||||||
|
e.anchor_before = anchor_before;
|
||||||
|
e.kind = kind;
|
||||||
|
e.timestamp_ms = Time::getRunningTime_ms();
|
||||||
|
push_undo_entry(std::move(e));
|
||||||
|
}
|
||||||
|
|
||||||
fire_changed();
|
fire_changed();
|
||||||
fire_cursor_moved();
|
// During history application the apply_*_entry functions will set
|
||||||
|
// the final cursor/anchor and fire onCursorMoved themselves — firing
|
||||||
|
// here would emit an intermediate position that observers might act
|
||||||
|
// on (e.g. scroll the view). Skip it in that case.
|
||||||
|
if (!m_applyingHistory) fire_cursor_moved();
|
||||||
}
|
}
|
||||||
|
|
||||||
void TextBuffer::move_cursor_to(u32 byte_offset, bool extend)
|
void TextBuffer::move_cursor_to(u32 byte_offset, bool extend)
|
||||||
|
|
@ -503,7 +575,13 @@ namespace ostd
|
||||||
m_cursor = byte_offset;
|
m_cursor = byte_offset;
|
||||||
if (!extend) m_anchor = byte_offset;
|
if (!extend) m_anchor = byte_offset;
|
||||||
if (m_cursor != old_cursor || m_anchor != old_anchor)
|
if (m_cursor != old_cursor || m_anchor != old_anchor)
|
||||||
|
{
|
||||||
|
// Any deliberate cursor movement breaks the undo coalesce chain.
|
||||||
|
// Without this, typing "ab", moving the cursor, then typing "cd"
|
||||||
|
// would produce a single undo step "abcd" — surprising to the user.
|
||||||
|
m_lastEditKind = EditKind::None;
|
||||||
fire_cursor_moved();
|
fire_cursor_moved();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -730,4 +808,188 @@ namespace ostd
|
||||||
{
|
{
|
||||||
if (m_onCursorMoved) m_onCursorMoved(*this);
|
if (m_onCursorMoved) m_onCursorMoved(*this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Undo / Redo
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
bool TextBuffer::undo(void)
|
||||||
|
{
|
||||||
|
if (m_undoStack.empty()) return false;
|
||||||
|
|
||||||
|
// Move the entry from undo to redo before applying, so that the
|
||||||
|
// reentrancy guard sees a clean stack-top — we don't want
|
||||||
|
// apply_entry_inverse() to accidentally try to coalesce into the
|
||||||
|
// entry we're undoing.
|
||||||
|
UndoEntry e = std::move(m_undoStack.back());
|
||||||
|
m_undoStack.pop_back();
|
||||||
|
|
||||||
|
apply_entry_inverse(e);
|
||||||
|
|
||||||
|
m_redoStack.push_back(std::move(e));
|
||||||
|
|
||||||
|
// Any new edit after an undo starts a fresh chain — never coalesce
|
||||||
|
// across an undo boundary.
|
||||||
|
m_lastEditKind = EditKind::None;
|
||||||
|
m_desiredColumn = -1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TextBuffer::redo(void)
|
||||||
|
{
|
||||||
|
if (m_redoStack.empty()) return false;
|
||||||
|
|
||||||
|
UndoEntry e = std::move(m_redoStack.back());
|
||||||
|
m_redoStack.pop_back();
|
||||||
|
|
||||||
|
apply_entry_forward(e);
|
||||||
|
|
||||||
|
m_undoStack.push_back(std::move(e));
|
||||||
|
|
||||||
|
m_lastEditKind = EditKind::None;
|
||||||
|
m_desiredColumn = -1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TextBuffer::clearHistory(void)
|
||||||
|
{
|
||||||
|
m_undoStack.clear();
|
||||||
|
m_redoStack.clear();
|
||||||
|
m_lastEditKind = EditKind::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TextBuffer::setMaxUndoSteps(u32 max)
|
||||||
|
{
|
||||||
|
m_maxUndoSteps = max;
|
||||||
|
if (max == 0) return; // Unlimited — nothing to trim.
|
||||||
|
// Drop oldest entries from the front until we fit. Both stacks share
|
||||||
|
// the same limit; redo entries existed in undo before they were
|
||||||
|
// undone, so trimming undo while leaving redo unbounded would be
|
||||||
|
// inconsistent.
|
||||||
|
while (m_undoStack.size() > m_maxUndoSteps) m_undoStack.pop_front();
|
||||||
|
while (m_redoStack.size() > m_maxUndoSteps) m_redoStack.pop_front();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TextBuffer::push_undo_entry(UndoEntry entry)
|
||||||
|
{
|
||||||
|
// A new edit invalidates any pending redos — the linear undo model.
|
||||||
|
// (Branching/persistent undo is out of scope; the linear model is
|
||||||
|
// what every text editor does.)
|
||||||
|
m_redoStack.clear();
|
||||||
|
|
||||||
|
// Try to coalesce this edit with the one on top of the stack.
|
||||||
|
if (!try_coalesce_with_top(entry))
|
||||||
|
{
|
||||||
|
m_undoStack.push_back(std::move(entry));
|
||||||
|
// Enforce the size limit by dropping the oldest entry. We do this
|
||||||
|
// inside the non-coalesce branch because coalescing doesn't grow
|
||||||
|
// the stack.
|
||||||
|
if (m_maxUndoSteps != 0 && m_undoStack.size() > m_maxUndoSteps)
|
||||||
|
m_undoStack.pop_front();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the kind of the most-recent edit for the *next* coalesce
|
||||||
|
// decision. We do this after coalescing because the coalesced entry
|
||||||
|
// retains the same kind as the previous one.
|
||||||
|
m_lastEditKind = m_undoStack.empty() ? EditKind::None : m_undoStack.back().kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool TextBuffer::try_coalesce_with_top(const UndoEntry& candidate)
|
||||||
|
{
|
||||||
|
// Coalescing requires:
|
||||||
|
// - A previous entry exists.
|
||||||
|
// - The previous edit's kind is the same coalescible kind.
|
||||||
|
// - The candidate is itself a coalescible kind (Generic never merges).
|
||||||
|
// - The candidate occurred within the timeout window of the previous.
|
||||||
|
// - The kinds aren't EditKind::None or EditKind::Generic.
|
||||||
|
if (m_undoStack.empty()) return false;
|
||||||
|
if (m_coalesceTimeout_ms == 0) return false;
|
||||||
|
if (candidate.kind != m_lastEditKind) return false;
|
||||||
|
if (candidate.kind == EditKind::None || candidate.kind == EditKind::Generic)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
UndoEntry& top = m_undoStack.back();
|
||||||
|
|
||||||
|
// Hard timeout: we don't merge edits that are more than
|
||||||
|
// m_coalesceTimeout_ms apart, even if everything else lines up. This
|
||||||
|
// matches the behavior of pretty much every text editor — a pause in
|
||||||
|
// typing is the user's natural "step boundary".
|
||||||
|
if (candidate.timestamp_ms < top.timestamp_ms) return false; // clock weirdness
|
||||||
|
if (candidate.timestamp_ms - top.timestamp_ms > m_coalesceTimeout_ms)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
switch (candidate.kind)
|
||||||
|
{
|
||||||
|
case EditKind::InsertCodepoint:
|
||||||
|
{
|
||||||
|
// New insert must start exactly where the previous one ended.
|
||||||
|
const u32 prev_end = top.start + cast<u32>(top.inserted.len());
|
||||||
|
if (candidate.start != prev_end) return false;
|
||||||
|
// Merge: append the new inserted text. cursor_before / anchor_before
|
||||||
|
// stay as the *original* values so undo rewinds to the start of
|
||||||
|
// the typing run.
|
||||||
|
top.inserted = top.inserted + candidate.inserted;
|
||||||
|
top.timestamp_ms = candidate.timestamp_ms;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case EditKind::Backspace:
|
||||||
|
{
|
||||||
|
// Each successive backspace removes a codepoint immediately to
|
||||||
|
// the LEFT of the previous removal. So candidate's range ends
|
||||||
|
// where top's range begins.
|
||||||
|
if (candidate.start + cast<u32>(candidate.removed.len()) != top.start)
|
||||||
|
return false;
|
||||||
|
// Merge: prepend the newly-removed codepoint, slide start left.
|
||||||
|
top.removed = candidate.removed + top.removed;
|
||||||
|
top.start = candidate.start;
|
||||||
|
top.timestamp_ms = candidate.timestamp_ms;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
case EditKind::DeleteForward:
|
||||||
|
{
|
||||||
|
// Each successive delete-forward removes a codepoint at the
|
||||||
|
// SAME position (cursor doesn't move on forward delete). So
|
||||||
|
// candidate.start should equal top.start.
|
||||||
|
if (candidate.start != top.start) return false;
|
||||||
|
// Merge: append the newly-removed codepoint to the run.
|
||||||
|
top.removed = top.removed + candidate.removed;
|
||||||
|
top.timestamp_ms = candidate.timestamp_ms;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TextBuffer::apply_entry_inverse(const UndoEntry& e)
|
||||||
|
{
|
||||||
|
// Undo replaces what was inserted with what was removed, then
|
||||||
|
// restores the cursor and anchor to their pre-edit positions. We use
|
||||||
|
// the chokepoint with the reentrancy guard so notifications still
|
||||||
|
// fire but no new history is recorded. replace_range suppresses its
|
||||||
|
// own cursor-moved fire while m_applyingHistory is true; we fire it
|
||||||
|
// here exactly once with the final restored cursor/anchor.
|
||||||
|
const u32 inserted_end = e.start + cast<u32>(e.inserted.len());
|
||||||
|
m_applyingHistory = true;
|
||||||
|
replace_range(e.start, inserted_end, e.removed);
|
||||||
|
m_applyingHistory = false;
|
||||||
|
m_cursor = e.cursor_before;
|
||||||
|
m_anchor = e.anchor_before;
|
||||||
|
fire_cursor_moved();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TextBuffer::apply_entry_forward(const UndoEntry& e)
|
||||||
|
{
|
||||||
|
// Redo re-applies the original edit. replace_range's natural
|
||||||
|
// post-edit cursor (at start + inserted.len(), anchor == cursor)
|
||||||
|
// matches what an in-progress edit would leave behind, so we don't
|
||||||
|
// need to override it. Same pattern: suppress replace_range's
|
||||||
|
// cursor-moved fire and emit one ourselves.
|
||||||
|
const u32 removed_end = e.start + cast<u32>(e.removed.len());
|
||||||
|
m_applyingHistory = true;
|
||||||
|
replace_range(e.start, removed_end, e.inserted);
|
||||||
|
m_applyingHistory = false;
|
||||||
|
fire_cursor_moved();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <functional>
|
#include <functional>
|
||||||
|
#include <deque>
|
||||||
#include <ostd/data/BaseObject.hpp>
|
#include <ostd/data/BaseObject.hpp>
|
||||||
#include <ostd/string/String.hpp>
|
#include <ostd/string/String.hpp>
|
||||||
#include <ostd/math/Geometry.hpp>
|
#include <ostd/math/Geometry.hpp>
|
||||||
|
|
@ -127,6 +128,54 @@ namespace ostd
|
||||||
inline void setOnChanged(ChangeCallback cb) { m_onChanged = std::move(cb); }
|
inline void setOnChanged(ChangeCallback cb) { m_onChanged = std::move(cb); }
|
||||||
inline void setOnCursorMoved(ChangeCallback cb) { m_onCursorMoved = std::move(cb); }
|
inline void setOnCursorMoved(ChangeCallback cb) { m_onCursorMoved = std::move(cb); }
|
||||||
|
|
||||||
|
// ======================== Undo / Redo =====================
|
||||||
|
// History is recorded automatically as a side-effect of any
|
||||||
|
// content-mutating operation. Each entry stores enough state to
|
||||||
|
// either reverse the edit (for undo) or re-apply it (for redo),
|
||||||
|
// including the cursor and anchor position from before the edit
|
||||||
|
// so that selection state is faithfully restored.
|
||||||
|
//
|
||||||
|
// Coalescing: rapid sequential single-character edits of the same
|
||||||
|
// kind (typing, backspacing, forward-deleting) are merged into a
|
||||||
|
// single undo step so that Ctrl+Z reverses a *word* worth of
|
||||||
|
// typing rather than one character at a time. The chain is broken
|
||||||
|
// by: explicit cursor movement, mode switches, calls to
|
||||||
|
// commitUndoStep(), edits of a different kind, or the timeout
|
||||||
|
// elapsing between edits.
|
||||||
|
//
|
||||||
|
// Limit: setMaxUndoSteps(0) disables the limit (unlimited
|
||||||
|
// history). The default is 2048 steps, after which the oldest
|
||||||
|
// entries are dropped.
|
||||||
|
bool undo(void);
|
||||||
|
bool redo(void);
|
||||||
|
inline bool canUndo(void) const { return !m_undoStack.empty(); }
|
||||||
|
inline bool canRedo(void) const { return !m_redoStack.empty(); }
|
||||||
|
|
||||||
|
// Drops all undo and redo history. Call this after loading a
|
||||||
|
// document if you don't want the user to be able to undo *back*
|
||||||
|
// into the previous document state.
|
||||||
|
void clearHistory(void);
|
||||||
|
|
||||||
|
// Forces the next edit to start a new undo step rather than
|
||||||
|
// coalescing with the previous one. Useful before a "save" point
|
||||||
|
// or when the widget loses focus.
|
||||||
|
inline void commitUndoStep(void) { m_lastEditKind = EditKind::None; }
|
||||||
|
|
||||||
|
// 0 means unlimited. Default is 2048. Lowering the limit may
|
||||||
|
// drop old entries immediately to fit.
|
||||||
|
void setMaxUndoSteps(u32 max);
|
||||||
|
inline u32 maxUndoSteps(void) const { return m_maxUndoSteps; }
|
||||||
|
|
||||||
|
// Coalescing timeout in milliseconds. Two consecutive edits of
|
||||||
|
// the same kind are merged only if the gap between them is less
|
||||||
|
// than this. Default is 1000 ms. 0 disables coalescing entirely.
|
||||||
|
inline void setUndoCoalesceTimeout(u32 ms) { m_coalesceTimeout_ms = ms; }
|
||||||
|
inline u32 undoCoalesceTimeout(void) const { return m_coalesceTimeout_ms; }
|
||||||
|
|
||||||
|
// For diagnostics / tests.
|
||||||
|
inline u32 undoStackSize(void) const { return cast<u32>(m_undoStack.size()); }
|
||||||
|
inline u32 redoStackSize(void) const { return cast<u32>(m_redoStack.size()); }
|
||||||
|
|
||||||
// ==================== BaseObject hooks ====================
|
// ==================== BaseObject hooks ====================
|
||||||
String toString(void) const override;
|
String toString(void) const override;
|
||||||
|
|
||||||
|
|
@ -134,7 +183,21 @@ namespace ostd
|
||||||
// The two chokepoints — every public method funnels through these.
|
// The two chokepoints — every public method funnels through these.
|
||||||
// replace_range is the only place buffer content changes.
|
// replace_range is the only place buffer content changes.
|
||||||
// move_cursor_to is the only place m_cursor/m_anchor change.
|
// move_cursor_to is the only place m_cursor/m_anchor change.
|
||||||
void replace_range(u32 start_byte, u32 end_byte, const String& with);
|
//
|
||||||
|
// `kind` controls undo coalescing: see EditKind below for the
|
||||||
|
// rules. Default is Generic, which never coalesces. Public
|
||||||
|
// methods that perform single-codepoint edits override this.
|
||||||
|
enum class EditKind : u8
|
||||||
|
{
|
||||||
|
None, // Sentinel — never matches, breaks the chain.
|
||||||
|
Generic, // Selection-replace, paste, line delete, etc.
|
||||||
|
InsertCodepoint, // A single typed codepoint, no selection.
|
||||||
|
Backspace, // A single-codepoint delete-left, no selection.
|
||||||
|
DeleteForward, // A single-codepoint delete-right, no selection.
|
||||||
|
};
|
||||||
|
|
||||||
|
void replace_range(u32 start_byte, u32 end_byte, const String& with,
|
||||||
|
EditKind kind = EditKind::Generic);
|
||||||
void move_cursor_to(u32 byte_offset, bool extend);
|
void move_cursor_to(u32 byte_offset, bool extend);
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
|
|
@ -150,6 +213,34 @@ namespace ostd
|
||||||
void fire_changed(void);
|
void fire_changed(void);
|
||||||
void fire_cursor_moved(void);
|
void fire_cursor_moved(void);
|
||||||
|
|
||||||
|
// ----- Undo / redo internals -----
|
||||||
|
//
|
||||||
|
// An UndoEntry records a single atomic edit as a triple:
|
||||||
|
// "at byte offset `start`, the substring `removed` was replaced
|
||||||
|
// with `inserted`". Plus the pre-edit cursor/anchor so that undo
|
||||||
|
// can restore the selection.
|
||||||
|
//
|
||||||
|
// To undo: replace [start, start + inserted.len()) with `removed`,
|
||||||
|
// restore cursor/anchor.
|
||||||
|
// To redo: replace [start, start + removed.len()) with `inserted`,
|
||||||
|
// place cursor at start + inserted.len() with anchor == cursor
|
||||||
|
// (matches replace_range's normal post-edit state).
|
||||||
|
struct UndoEntry
|
||||||
|
{
|
||||||
|
u32 start { 0 };
|
||||||
|
String removed; // What was there before.
|
||||||
|
String inserted; // What's there now.
|
||||||
|
u32 cursor_before { 0 };
|
||||||
|
u32 anchor_before { 0 };
|
||||||
|
EditKind kind { EditKind::Generic };
|
||||||
|
u64 timestamp_ms { 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
void push_undo_entry(UndoEntry entry);
|
||||||
|
void apply_entry_inverse(const UndoEntry& e); // for undo()
|
||||||
|
void apply_entry_forward(const UndoEntry& e); // for redo()
|
||||||
|
bool try_coalesce_with_top(const UndoEntry& candidate);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
String m_buffer;
|
String m_buffer;
|
||||||
u32 m_cursor { 0 };
|
u32 m_cursor { 0 };
|
||||||
|
|
@ -170,5 +261,24 @@ namespace ostd
|
||||||
|
|
||||||
ChangeCallback m_onChanged { nullptr };
|
ChangeCallback m_onChanged { nullptr };
|
||||||
ChangeCallback m_onCursorMoved { nullptr };
|
ChangeCallback m_onCursorMoved { nullptr };
|
||||||
|
|
||||||
|
// ----- Undo / redo state -----
|
||||||
|
std::deque<UndoEntry> m_undoStack;
|
||||||
|
std::deque<UndoEntry> m_redoStack;
|
||||||
|
|
||||||
|
// Default 2048 steps. 0 means unlimited.
|
||||||
|
u32 m_maxUndoSteps { 2048 };
|
||||||
|
|
||||||
|
// Coalesce timeout in ms. 0 disables coalescing.
|
||||||
|
u32 m_coalesceTimeout_ms { 1000 };
|
||||||
|
|
||||||
|
// Tracks the kind of the most recent edit so we can decide
|
||||||
|
// whether to coalesce the next one with it.
|
||||||
|
EditKind m_lastEditKind { EditKind::None };
|
||||||
|
|
||||||
|
// Reentrancy guard: when true, replace_range skips the undo
|
||||||
|
// recording step (because we're already in the middle of an
|
||||||
|
// undo() or redo() call applying a stored entry).
|
||||||
|
bool m_applyingHistory { false };
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue