Compare commits

...
5 Commits
Author SHA1 Message Date
kyleandClaude Opus 5 18ce005ec0 Fix macOS build: use CURSES_INCLUDE_DIRS and enable wide-char decls
Two latent defects broke the kte target on macOS once TerminalInputHandler
switched from getch() to get_wch().

include_directories() referenced CURSES_INCLUDE_DIR, a name FindCurses never
sets (it exports CURSES_INCLUDE_DIRS). The variable expanded to nothing, so
no curses include path was added and the compiler silently fell back to the
macOS SDK's narrow-char header on the default include path.

Both the SDK and Homebrew headers declare get_wch and friends only when
NCURSES_WIDECHAR is set, defaulting it to 0 absent a feature-test macro, so
the build failed with "use of undeclared identifier 'get_wch'". We always
request and link the wide library, so define NCURSES_WIDECHAR=1; redefining
it identically is benign on platforms whose headers already set it.

kte and kge now compile and link against libncursesw, and the app bundle
vendors it. Full test suite passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 10:22:34 -07:00
kyleandClaude Opus 5 22f7573361 Fix macOS release build: locate Homebrew's wide-char ncurses
CMake 4.x FindCurses with CURSES_NEED_WIDE finds the macOS SDK's
libcurses.tbd but no matching wide-char header, so configure failed with
"Could NOT find Curses (missing: CURSES_INCLUDE_PATH)". Homebrew's
ncurses is keg-only, so it is not on the default search path either.

Existing build directories only worked by accident, via a hand-passed
-DCURSES_INCLUDE_PATH left in their CMakeCache; a clean configure failed,
which broke make-app-release.

Query `brew --prefix ncurses` on APPLE and prepend it to CMAKE_PREFIX_PATH
when present. A clean configure now succeeds with no manual flags and
links the real libncursesw.dylib instead of the SDK's narrow libcurses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 10:17:34 -07:00
kyleandClaude Opus 5 96e82cc6dd Bump version to 1.12.0
Releases the undo/crash-recovery/syntax-highlighting correctness work and
the GUI CRLF paste fix accumulated since v1.11.3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 09:56:29 -07:00
kyleandClaude Opus 4.8 480f1390c7 Fix GUI paste of CRLF/CR text: split all line endings
The ImGui Cmd+V/Ctrl+V paste handler split clipboard text only on '\n',
leaving '\r' inside each segment. Pasting text with CRLF (Windows) or bare
CR (classic-Mac / some macOS apps) line endings enqueued an InsertText arg
containing '\r', which the InsertText command rejects with "InsertText arg
must not contain newlines".

Extract the paste-to-commands logic into a pure, SDL-free helper
(PasteSplit.h) that treats '\n', '\r\n', and bare '\r' each as one line
break, and add unit tests covering all line-ending forms plus blank-line
and trailing-newline preservation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-03 00:20:36 -07:00
kyleandClaude Sonnet 5 3126a5e523 Fix undo correctness, crash-recovery, and syntax highlighting bugs from full codebase review
Undo/redo:
- cmd_newline recorded the undo node's position after the cursor moved past
  the split point, so undoing Enter joined the wrong pair of lines.
- Backspace/Delete line-joins recorded UndoType::Newline, whose apply()
  semantics are inverted for a join (forward=split); add UndoType::JoinLines
  with correct forward/backward behavior.
- Regex replace-all, indent/unindent region, kill-to-eol, kill-line,
  kill-region, and delete-word-prev/next mutated the buffer via the
  no-undo-recording raw APIs; they now record undo, grouped atomically via a
  shared UndoGroupGuard.
- Plain-text replace-all with an empty replacement advanced the scan
  position one character too far, skipping adjacent/overlapping matches.

Crash recovery / memory safety:
- Buffer's move ctor/assignment never carried over swap_rec_,
  on_disk_identity_, or the visual-line-mode fields, so the crash-recovery
  journal silently stopped tracking a buffer whenever Editor's
  std::vector<Buffer> reallocated or shifted (e.g. opening/closing files).
  Added SwapManager::Rehome() plus Flush()-before-mutate in
  Editor::AddBuffer/CloseBuffer so the journal's Buffer* key and the
  background writer thread never reference a stale address.
- UndoTree leaked its entire node graph (including all edit text) on every
  buffer close/reload; added ~UndoTree() to free it.
- main.cc didn't restore the terminal if an exception escaped the run loop;
  added an RAII guard around Frontend::Shutdown().

Input handling:
- Terminal frontend used getch() instead of get_wch(), silently dropping
  non-ASCII keyboard input despite linking wide-char ncurses.
- KKeymap's C-k lookup switches on an already-lowercased key, making
  `case 'E'` unreachable dead code; C-k Shift-E silently aliased to C-k e.
- ImGui file picker wasn't modal: keystrokes typed while it was open still
  reached the buffer underneath as edit commands, and Escape didn't close it.
- Gated ImGuiInputHandler's unconditional fprintf/fflush diagnostics behind
  an IMGUI_IH_DEBUG macro, matching QtInputHandler's existing convention.

Syntax highlighting:
- Go/Rust/SQL highlighters weren't stateful, so multi-line /* */ comments
  mis-highlighted as code starting on the second line.
- Python's triple-quoted-string handler didn't re-scan the remainder of a
  line after a string closed mid-line, missing a same-line reopen.
- HighlighterEngine's state_last_contig_ field was declared but unused,
  forcing an O(n) scan of state_cache_ on every stateful lookup; wired it up
  as a real fast path and fixed InvalidateFrom() to keep it in sync.
- kge's :syntax off was silently undone the next frame because
  apply_syntax_to_buffer() re-applied the config default unconditionally
  every frame; added a user-override flag mirroring the existing
  edit-mode-detection guard.

Added regression tests for all of the above (test_undo.cc, test_kkeymap.cc,
test_command_semantics.cc, test_search_replace_flow.cc, and a new
test_syntax_highlighting.cc). Full suite (163 tests) passes, including under
AddressSanitizer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 14:19:15 -07:00
32 changed files with 1378 additions and 153 deletions
+42 -20
View File
@@ -235,7 +235,8 @@ Buffer::Buffer(const Buffer &other)
edit_mode_ = other.edit_mode_; edit_mode_ = other.edit_mode_;
edit_mode_detected_ = other.edit_mode_detected_; edit_mode_detected_ = other.edit_mode_detected_;
version_ = other.version_; version_ = other.version_;
syntax_enabled_ = other.syntax_enabled_; syntax_enabled_ = other.syntax_enabled_;
syntax_user_override_ = other.syntax_user_override_;
filetype_ = other.filetype_; filetype_ = other.filetype_;
// Fresh undo system for the copy // Fresh undo system for the copy
undo_tree_ = std::make_unique<UndoTree>(); undo_tree_ = std::make_unique<UndoTree>();
@@ -286,7 +287,8 @@ Buffer::operator=(const Buffer &other)
edit_mode_ = other.edit_mode_; edit_mode_ = other.edit_mode_;
edit_mode_detected_ = other.edit_mode_detected_; edit_mode_detected_ = other.edit_mode_detected_;
version_ = other.version_; version_ = other.version_;
syntax_enabled_ = other.syntax_enabled_; syntax_enabled_ = other.syntax_enabled_;
syntax_user_override_ = other.syntax_user_override_;
filetype_ = other.filetype_; filetype_ = other.filetype_;
// Recreate undo system for this instance // Recreate undo system for this instance
undo_tree_ = std::make_unique<UndoTree>(); undo_tree_ = std::make_unique<UndoTree>();
@@ -327,6 +329,9 @@ Buffer::Buffer(Buffer &&other) noexcept
mark_set_(other.mark_set_), mark_set_(other.mark_set_),
mark_curx_(other.mark_curx_), mark_curx_(other.mark_curx_),
mark_cury_(other.mark_cury_), mark_cury_(other.mark_cury_),
visual_line_active_(other.visual_line_active_),
visual_line_anchor_y_(other.visual_line_anchor_y_),
visual_line_active_y_(other.visual_line_active_y_),
undo_tree_(std::move(other.undo_tree_)), undo_tree_(std::move(other.undo_tree_)),
undo_sys_(std::move(other.undo_sys_)) undo_sys_(std::move(other.undo_sys_))
{ {
@@ -334,11 +339,18 @@ Buffer::Buffer(Buffer &&other) noexcept
edit_mode_ = other.edit_mode_; edit_mode_ = other.edit_mode_;
edit_mode_detected_ = other.edit_mode_detected_; edit_mode_detected_ = other.edit_mode_detected_;
version_ = other.version_; version_ = other.version_;
syntax_enabled_ = other.syntax_enabled_; syntax_enabled_ = other.syntax_enabled_;
syntax_user_override_ = other.syntax_user_override_;
filetype_ = std::move(other.filetype_); filetype_ = std::move(other.filetype_);
highlighter_ = std::move(other.highlighter_); highlighter_ = std::move(other.highlighter_);
content_ = std::move(other.content_); content_ = std::move(other.content_);
rows_cache_dirty_ = other.rows_cache_dirty_; rows_cache_dirty_ = other.rows_cache_dirty_;
on_disk_identity_ = other.on_disk_identity_;
// Non-owning: the recorder object itself is owned by SwapManager and outlives
// this move. The caller (Editor) is responsible for calling SwapManager::Rehome()
// so the journal's Buffer* key follows this object to its new address.
swap_rec_ = other.swap_rec_;
other.swap_rec_ = nullptr;
// Update UndoSystem's buffer reference to point to this object // Update UndoSystem's buffer reference to point to this object
if (undo_sys_) { if (undo_sys_) {
undo_sys_->UpdateBufferReference(*this); undo_sys_->UpdateBufferReference(*this);
@@ -353,32 +365,42 @@ Buffer::operator=(Buffer &&other) noexcept
if (this == &other) if (this == &other)
return *this; return *this;
curx_ = other.curx_; curx_ = other.curx_;
cury_ = other.cury_; cury_ = other.cury_;
rx_ = other.rx_; rx_ = other.rx_;
nrows_ = other.nrows_; nrows_ = other.nrows_;
rowoffs_ = other.rowoffs_; rowoffs_ = other.rowoffs_;
coloffs_ = other.coloffs_; coloffs_ = other.coloffs_;
rows_ = std::move(other.rows_); rows_ = std::move(other.rows_);
filename_ = std::move(other.filename_); filename_ = std::move(other.filename_);
is_file_backed_ = other.is_file_backed_; is_file_backed_ = other.is_file_backed_;
dirty_ = other.dirty_; dirty_ = other.dirty_;
read_only_ = other.read_only_; read_only_ = other.read_only_;
mark_set_ = other.mark_set_; mark_set_ = other.mark_set_;
mark_curx_ = other.mark_curx_; mark_curx_ = other.mark_curx_;
mark_cury_ = other.mark_cury_; mark_cury_ = other.mark_cury_;
undo_tree_ = std::move(other.undo_tree_); visual_line_active_ = other.visual_line_active_;
undo_sys_ = std::move(other.undo_sys_); visual_line_anchor_y_ = other.visual_line_anchor_y_;
visual_line_active_y_ = other.visual_line_active_y_;
undo_tree_ = std::move(other.undo_tree_);
undo_sys_ = std::move(other.undo_sys_);
// Move edit mode + syntax/highlighting state // Move edit mode + syntax/highlighting state
edit_mode_ = other.edit_mode_; edit_mode_ = other.edit_mode_;
edit_mode_detected_ = other.edit_mode_detected_; edit_mode_detected_ = other.edit_mode_detected_;
version_ = other.version_; version_ = other.version_;
syntax_enabled_ = other.syntax_enabled_; syntax_enabled_ = other.syntax_enabled_;
syntax_user_override_ = other.syntax_user_override_;
filetype_ = std::move(other.filetype_); filetype_ = std::move(other.filetype_);
highlighter_ = std::move(other.highlighter_); highlighter_ = std::move(other.highlighter_);
content_ = std::move(other.content_); content_ = std::move(other.content_);
rows_cache_dirty_ = other.rows_cache_dirty_; rows_cache_dirty_ = other.rows_cache_dirty_;
on_disk_identity_ = other.on_disk_identity_;
// Non-owning: the recorder object itself is owned by SwapManager and outlives
// this move. The caller (Editor) is responsible for calling SwapManager::Rehome()
// so the journal's Buffer* key follows this object to its new address.
swap_rec_ = other.swap_rec_;
other.swap_rec_ = nullptr;
// Update UndoSystem's buffer reference to point to this object // Update UndoSystem's buffer reference to point to this object
if (undo_sys_) { if (undo_sys_) {
undo_sys_->UpdateBufferReference(*this); undo_sys_->UpdateBufferReference(*this);
+20
View File
@@ -548,6 +548,25 @@ public:
} }
// Marks that the user explicitly set syntax state via a command (:syntax
// on/off, :set filetype=...), as opposed to it being auto-applied from
// GUIConfig. Frontends that re-apply the config-driven default every
// frame (e.g. ImGuiFrontend::apply_syntax_to_buffer) must check this
// first, mirroring EditModeDetected()'s "don't stomp a manual toggle"
// pattern - otherwise a manual :syntax off is silently undone on the very
// next frame.
void SetSyntaxUserOverride(bool on)
{
syntax_user_override_ = on;
}
[[nodiscard]] bool SyntaxUserOverride() const
{
return syntax_user_override_;
}
void SetFiletype(const std::string &ft) void SetFiletype(const std::string &ft)
{ {
filetype_ = ft; filetype_ = ft;
@@ -673,6 +692,7 @@ private:
// Syntax/highlighting state // Syntax/highlighting state
std::uint64_t version_ = 0; // increment on edits std::uint64_t version_ = 0; // increment on edits
bool syntax_enabled_ = true; bool syntax_enabled_ = true;
bool syntax_user_override_ = false; // true once user explicitly set syntax state via a command
std::string filetype_; std::string filetype_;
std::unique_ptr<kte::HighlighterEngine> highlighter_; std::unique_ptr<kte::HighlighterEngine> highlighter_;
// Non-owning pointer to swap recorder managed by Editor/SwapManager // Non-owning pointer to swap recorder managed by Editor/SwapManager
+30 -2
View File
@@ -4,7 +4,7 @@ project(kte)
include(GNUInstallDirs) include(GNUInstallDirs)
set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD 20)
set(KTE_VERSION "1.11.2") set(KTE_VERSION "1.12.0")
# Default to terminal-only build to avoid SDL/OpenGL dependency by default. # Default to terminal-only build to avoid SDL/OpenGL dependency by default.
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available. # Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
@@ -71,8 +71,34 @@ endif ()
# NCurses for terminal mode # NCurses for terminal mode
set(CURSES_NEED_NCURSES TRUE) set(CURSES_NEED_NCURSES TRUE)
set(CURSES_NEED_WIDE TRUE) set(CURSES_NEED_WIDE TRUE)
# macOS ships only a narrow-char ncurses in the SDK, and Homebrew's wide-char
# build is keg-only, so FindCurses can't see it without a hint.
if (APPLE)
find_program(BREW_EXECUTABLE brew)
if (BREW_EXECUTABLE)
execute_process(COMMAND "${BREW_EXECUTABLE}" --prefix ncurses
OUTPUT_VARIABLE HOMEBREW_NCURSES_PREFIX
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_QUIET)
if (HOMEBREW_NCURSES_PREFIX AND IS_DIRECTORY "${HOMEBREW_NCURSES_PREFIX}")
message(STATUS "Using Homebrew ncursesw: ${HOMEBREW_NCURSES_PREFIX}")
list(PREPEND CMAKE_PREFIX_PATH "${HOMEBREW_NCURSES_PREFIX}")
endif ()
endif ()
endif ()
find_package(Curses REQUIRED) find_package(Curses REQUIRED)
include_directories(${CURSES_INCLUDE_DIR})
# FindCurses exports CURSES_INCLUDE_DIRS; the singular CURSES_INCLUDE_DIR was
# never set, so this silently expanded to nothing and the compiler fell back
# to whatever curses header happened to be on the default include path.
include_directories(${CURSES_INCLUDE_DIRS})
# The wide-char entry points we rely on (get_wch, etc.) are declared only when
# NCURSES_WIDECHAR is set; the header defaults it to 0 unless a feature-test
# macro says otherwise. We always link the wide library, so ask for them.
add_compile_definitions(NCURSES_WIDECHAR=1)
# On Alpine Linux, CMake's FindCurses looks in wrong paths # On Alpine Linux, CMake's FindCurses looks in wrong paths
# Manually find the correct ncurses library # Manually find the correct ncurses library
@@ -344,6 +370,8 @@ if (BUILD_TESTS)
tests/test_migration_coverage.cc tests/test_migration_coverage.cc
tests/test_smart_newline.cc tests/test_smart_newline.cc
tests/test_reflow_undo.cc tests/test_reflow_undo.cc
tests/test_syntax_highlighting.cc
tests/test_paste_split.cc
# minimal engine sources required by Buffer # minimal engine sources required by Buffer
PieceTable.cc PieceTable.cc
+165 -39
View File
@@ -159,6 +159,27 @@ ensure_at_least_one_line(Buffer &buf)
} }
// RAII helper: brackets a multi-edit command so its individual undo nodes
// undo/redo as a single atomic step, per the project's undo-grouping convention.
struct UndoGroupGuard {
UndoSystem *u;
explicit UndoGroupGuard(UndoSystem *u_) : u(u_)
{
if (u)
u->BeginGroup();
}
~UndoGroupGuard()
{
if (u)
u->EndGroup();
}
};
// Determine if a command mutates the buffer contents (text edits) // Determine if a command mutates the buffer contents (text edits)
static bool static bool
is_mutating_command(CommandId id) is_mutating_command(CommandId id)
@@ -264,8 +285,12 @@ extract_region_text(const Buffer &buf, std::size_t sx, std::size_t sy, std::size
// Helper: delete region and leave cursor at start (sx,sy). Adjust lines appropriately. // Helper: delete region and leave cursor at start (sx,sy). Adjust lines appropriately.
// If `u` is non-null, each underlying mutation is recorded as its own undo node;
// callers that want the whole region-delete to undo/redo as one step should
// wrap the call in an UndoGroupGuard.
static void static void
delete_region(Buffer &buf, std::size_t sx, std::size_t sy, std::size_t ex, std::size_t ey) delete_region(Buffer &buf, std::size_t sx, std::size_t sy, std::size_t ex, std::size_t ey,
UndoSystem *u = nullptr)
{ {
std::size_t nrows = buf.Nrows(); std::size_t nrows = buf.Nrows();
if (nrows == 0) if (nrows == 0)
@@ -282,7 +307,14 @@ delete_region(Buffer &buf, std::size_t sx, std::size_t sy, std::size_t ex, std::
std::size_t xe = std::min(ex, line.size()); std::size_t xe = std::min(ex, line.size());
if (xe < xs) if (xe < xs)
std::swap(xs, xe); std::swap(xs, xe);
std::string deleted = std::string(line.substr(xs, xe - xs));
buf.delete_text(static_cast<int>(sy), static_cast<int>(xs), xe - xs); buf.delete_text(static_cast<int>(sy), static_cast<int>(xs), xe - xs);
if (u && !deleted.empty()) {
buf.SetCursor(xs, sy);
u->Begin(UndoType::Delete);
u->Append(std::string_view(deleted));
u->commit();
}
} else { } else {
// Multi-line: delete from (sx,sy) to (ex,ey) // Multi-line: delete from (sx,sy) to (ex,ey)
// Strategy: // Strategy:
@@ -303,11 +335,25 @@ delete_region(Buffer &buf, std::size_t sx, std::size_t sy, std::size_t ex, std::
// Delete tail of first line (from xs to end) // Delete tail of first line (from xs to end)
if (xs < first_line_len) { if (xs < first_line_len) {
std::string tail = std::string(rows[sy].substr(xs));
buf.delete_text(static_cast<int>(sy), static_cast<int>(xs), first_line_len - xs); buf.delete_text(static_cast<int>(sy), static_cast<int>(xs), first_line_len - xs);
if (u && !tail.empty()) {
buf.SetCursor(xs, sy);
u->Begin(UndoType::Delete);
u->Append(std::string_view(tail));
u->commit();
}
} }
// Delete lines from ey down to sy+1 (reverse order to preserve indices) // Delete lines from ey down to sy+1 (reverse order to preserve indices)
for (std::size_t i = ey; i > sy; --i) { for (std::size_t i = ey; i > sy; --i) {
if (u) {
std::string row_text = static_cast<std::string>(buf.Rows()[i]);
buf.SetCursor(0, i);
u->Begin(UndoType::DeleteRow);
u->Append(std::string_view(row_text));
u->commit();
}
buf.delete_row(static_cast<int>(i)); buf.delete_row(static_cast<int>(i));
} }
@@ -317,6 +363,12 @@ delete_region(Buffer &buf, std::size_t sx, std::size_t sy, std::size_t ex, std::
const auto &rows_after = buf.Rows(); const auto &rows_after = buf.Rows();
std::size_t line_len = rows_after[sy].size(); std::size_t line_len = rows_after[sy].size();
buf.insert_text(static_cast<int>(sy), static_cast<int>(line_len), suffix); buf.insert_text(static_cast<int>(sy), static_cast<int>(line_len), suffix);
if (u) {
buf.SetCursor(line_len, sy);
u->Begin(UndoType::Insert);
u->Append(std::string_view(suffix));
u->commit();
}
} }
} }
buf.SetCursor(sx, sy); buf.SetCursor(sx, sy);
@@ -908,6 +960,10 @@ cmd_unknown_esc_command(CommandContext &ctx)
static void static void
apply_filetype(Buffer &buf, const std::string &ft) apply_filetype(Buffer &buf, const std::string &ft)
{ {
// Only reachable from explicit user commands (:syntax on, :set
// filetype=...) - mark so frontends that re-apply config-driven syntax
// defaults every frame (e.g. ImGuiFrontend) don't stomp this choice.
buf.SetSyntaxUserOverride(true);
buf.EnsureHighlighter(); buf.EnsureHighlighter();
auto *eng = buf.Highlighter(); auto *eng = buf.Highlighter();
if (!eng) if (!eng)
@@ -977,6 +1033,7 @@ cmd_syntax(CommandContext &ctx)
}; };
trim(arg); trim(arg);
if (arg == "on") { if (arg == "on") {
b->SetSyntaxUserOverride(true);
b->SetSyntaxEnabled(true); b->SetSyntaxEnabled(true);
// If no highlighter but filetype is cpp by extension, set it // If no highlighter but filetype is cpp by extension, set it
if (!b->Highlighter() || !b->Highlighter()->HasHighlighter()) { if (!b->Highlighter() || !b->Highlighter()->HasHighlighter()) {
@@ -984,6 +1041,7 @@ cmd_syntax(CommandContext &ctx)
} }
ctx.editor.SetStatus("syntax: on"); ctx.editor.SetStatus("syntax: on");
} else if (arg == "off") { } else if (arg == "off") {
b->SetSyntaxUserOverride(true);
b->SetSyntaxEnabled(false); b->SetSyntaxEnabled(false);
ctx.editor.SetStatus("syntax: off"); ctx.editor.SetStatus("syntax: off");
} else if (arg == "reload") { } else if (arg == "reload") {
@@ -2509,12 +2567,10 @@ cmd_newline(CommandContext &ctx)
} }
pos = p + with.size(); pos = p + with.size();
} else { } else {
// When replacing with empty, continue after the deletion point to avoid re-matching // Replacing with empty leaves nothing inserted at the deletion
// point, so resume scanning from `p` itself (not p+1) to catch
// adjacent/overlapping matches, e.g. "aaaa" -> "" replacing "aa".
pos = p; pos = p;
if (pos < static_cast<std::size_t>(buf->Rows()[y].size()))
++pos;
else
break;
} }
++total; ++total;
} }
@@ -2923,14 +2979,28 @@ cmd_newline(CommandContext &ctx)
return true; return true;
} }
std::size_t changed = 0; std::size_t changed = 0;
UndoSystem *ru = buf->Undo();
UndoGroupGuard rguard(ru);
// Iterate by index to allow modifications via PieceTable helpers // Iterate by index to allow modifications via PieceTable helpers
for (std::size_t y = 0; y < buf->Rows().size(); ++y) { for (std::size_t y = 0; y < buf->Rows().size(); ++y) {
std::string before = static_cast<std::string>(buf->Rows()[y]); std::string before = static_cast<std::string>(buf->Rows()[y]);
std::string after = std::regex_replace(before, rx, repl); std::string after = std::regex_replace(before, rx, repl);
if (after != before) { if (after != before) {
// Replace entire line y with 'after' using PieceTable ops // Replace entire line y with 'after' using PieceTable ops
if (ru) {
buf->SetCursor(0, y);
ru->Begin(UndoType::DeleteRow);
ru->Append(std::string_view(before));
ru->commit();
}
buf->delete_row(static_cast<int>(y)); buf->delete_row(static_cast<int>(y));
buf->insert_row(static_cast<int>(y), std::string_view(after)); buf->insert_row(static_cast<int>(y), std::string_view(after));
if (ru) {
buf->SetCursor(0, y);
ru->Begin(UndoType::InsertRow);
ru->Append(std::string_view(after));
ru->commit();
}
++changed; ++changed;
} }
} }
@@ -3003,18 +3073,28 @@ cmd_newline(CommandContext &ctx)
ensure_cursor_visible(ctx.editor, *buf); ensure_cursor_visible(ctx.editor, *buf);
return true; return true;
} }
UndoSystem *u = buf->Undo();
if (u && repeat > 1)
u->BeginGroup();
for (int i = 0; i < repeat; ++i) { for (int i = 0; i < repeat; ++i) {
// Sync the buffer's cursor to the split point before Begin(), which
// records its node's row/col from the buffer's *current* cursor. Undo
// must reverse this exact split, not wherever the cursor ends up after
// the whole loop finishes.
buf->SetCursor(x, y);
if (u) {
u->Begin(UndoType::Newline);
u->commit();
}
buf->split_line(static_cast<int>(y), static_cast<int>(x)); buf->split_line(static_cast<int>(y), static_cast<int>(x));
// Move to start of next line // Move to start of next line
y += 1; y += 1;
x = 0; x = 0;
} }
if (u && repeat > 1)
u->EndGroup();
buf->SetCursor(x, y); buf->SetCursor(x, y);
buf->SetDirty(true); buf->SetDirty(true);
if (auto *u = buf->Undo()) {
u->Begin(UndoType::Newline);
u->commit();
}
ensure_cursor_visible(ctx.editor, *buf); ensure_cursor_visible(ctx.editor, *buf);
return true; return true;
} }
@@ -3198,7 +3278,9 @@ cmd_backspace(CommandContext &ctx)
x = prev_len; x = prev_len;
buf->SetCursor(x, y); buf->SetCursor(x, y);
if (u) { if (u) {
u->Begin(UndoType::Newline); // Forward action here is a join, not a split: JoinLines has the
// correct (inverted) apply() semantics, unlike Newline.
u->Begin(UndoType::JoinLines);
u->commit(); u->commit();
} }
} else { } else {
@@ -3277,7 +3359,9 @@ cmd_delete_char(CommandContext &ctx)
} else if (y + 1 < rows_view.size()) { } else if (y + 1 < rows_view.size()) {
buf->join_lines(static_cast<int>(y)); buf->join_lines(static_cast<int>(y));
if (u) { if (u) {
u->Begin(UndoType::Newline); // Forward action here is a join, not a split: JoinLines has the
// correct (inverted) apply() semantics, unlike Newline.
u->Begin(UndoType::JoinLines);
u->commit(); u->commit();
} }
} else { } else {
@@ -3350,18 +3434,32 @@ cmd_kill_to_eol(CommandContext &ctx)
std::size_t x = buf->Curx(); std::size_t x = buf->Curx();
int repeat = ctx.count > 0 ? ctx.count : 1; int repeat = ctx.count > 0 ? ctx.count : 1;
std::string killed_total; std::string killed_total;
UndoSystem *u = buf->Undo();
UndoGroupGuard guard(u);
for (int i = 0; i < repeat; ++i) { for (int i = 0; i < repeat; ++i) {
const auto &rows_view = buf->Rows(); const auto &rows_view = buf->Rows();
if (y >= rows_view.size()) if (y >= rows_view.size())
break; break;
if (x < rows_view[y].size()) { if (x < rows_view[y].size()) {
// delete from cursor to end of line // delete from cursor to end of line
killed_total += rows_view[y].substr(x); std::string seg = static_cast<std::string>(rows_view[y].substr(x));
killed_total += seg;
std::size_t len = rows_view[y].size() - x; std::size_t len = rows_view[y].size() - x;
buf->delete_text(static_cast<int>(y), static_cast<int>(x), len); buf->delete_text(static_cast<int>(y), static_cast<int>(x), len);
if (u) {
buf->SetCursor(x, y);
u->Begin(UndoType::Delete);
u->Append(std::string_view(seg));
u->commit();
}
} else if (y + 1 < rows_view.size()) { } else if (y + 1 < rows_view.size()) {
// at EOL: delete the newline (join with next line) // at EOL: delete the newline (join with next line)
killed_total += "\n"; killed_total += "\n";
if (u) {
buf->SetCursor(x, y);
u->Begin(UndoType::JoinLines);
u->commit();
}
buf->join_lines(static_cast<int>(y)); buf->join_lines(static_cast<int>(y));
} else { } else {
// nothing to delete // nothing to delete
@@ -3395,20 +3493,37 @@ cmd_kill_line(CommandContext &ctx)
(void) x; // cursor x will be reset to 0 (void) x; // cursor x will be reset to 0
int repeat = ctx.count > 0 ? ctx.count : 1; int repeat = ctx.count > 0 ? ctx.count : 1;
std::string killed_total; std::string killed_total;
UndoSystem *u = buf->Undo();
UndoGroupGuard guard(u);
for (int i = 0; i < repeat; ++i) { for (int i = 0; i < repeat; ++i) {
const auto &rows_view = buf->Rows(); const auto &rows_view = buf->Rows();
if (rows_view.empty()) if (rows_view.empty())
break; break;
if (rows_view.size() == 1) { if (rows_view.size() == 1) {
// last remaining line: clear its contents // last remaining line: clear its contents
killed_total += static_cast<std::string>(rows_view[0]); std::string content = static_cast<std::string>(rows_view[0]);
if (!rows_view[0].empty()) killed_total += content;
buf->delete_text(0, 0, rows_view[0].size()); if (!content.empty()) {
buf->delete_text(0, 0, content.size());
if (u) {
buf->SetCursor(0, 0);
u->Begin(UndoType::Delete);
u->Append(std::string_view(content));
u->commit();
}
}
y = 0; y = 0;
} else if (y < rows_view.size()) { } else if (y < rows_view.size()) {
// erase current line; keep y pointing at the next line // erase current line; keep y pointing at the next line
killed_total += static_cast<std::string>(rows_view[y]); std::string content = static_cast<std::string>(rows_view[y]);
killed_total += content;
killed_total += "\n"; killed_total += "\n";
if (u) {
buf->SetCursor(0, y);
u->Begin(UndoType::DeleteRow);
u->Append(std::string_view(content));
u->commit();
}
buf->delete_row(static_cast<int>(y)); buf->delete_row(static_cast<int>(y));
const auto &rows_after = buf->Rows(); const auto &rows_after = buf->Rows();
if (y >= rows_after.size()) { if (y >= rows_after.size()) {
@@ -3622,7 +3737,10 @@ cmd_kill_region(CommandContext &ctx)
return false; return false;
} }
std::string text = extract_region_text(*buf, sx, sy, ex, ey); std::string text = extract_region_text(*buf, sx, sy, ex, ey);
delete_region(*buf, sx, sy, ex, ey); {
UndoGroupGuard guard(buf->Undo());
delete_region(*buf, sx, sy, ex, ey, buf->Undo());
}
ensure_cursor_visible(ctx.editor, *buf); ensure_cursor_visible(ctx.editor, *buf);
if (!text.empty()) { if (!text.empty()) {
if (ctx.editor.KillChain()) if (ctx.editor.KillChain())
@@ -4234,6 +4352,7 @@ cmd_delete_word_prev(CommandContext &ctx)
std::size_t x = buf->Curx(); std::size_t x = buf->Curx();
int repeat = ctx.count > 0 ? ctx.count : 1; int repeat = ctx.count > 0 ? ctx.count : 1;
std::string killed_total; std::string killed_total;
UndoGroupGuard guard(buf->Undo());
for (int i = 0; i < repeat; ++i) { for (int i = 0; i < repeat; ++i) {
if (y >= rows.size()) { if (y >= rows.size()) {
y = rows.empty() ? 0 : rows.size() - 1; y = rows.empty() ? 0 : rows.size() - 1;
@@ -4275,7 +4394,7 @@ cmd_delete_word_prev(CommandContext &ctx)
} }
// Now delete from (x, y) to (start_x, start_y) using PieceTable // Now delete from (x, y) to (start_x, start_y) using PieceTable
std::string deleted = extract_region_text(*buf, x, y, start_x, start_y); std::string deleted = extract_region_text(*buf, x, y, start_x, start_y);
delete_region(*buf, x, y, start_x, start_y); delete_region(*buf, x, y, start_x, start_y, buf->Undo());
// Prepend to killed_total (since we're deleting backwards) // Prepend to killed_total (since we're deleting backwards)
killed_total = deleted + killed_total; killed_total = deleted + killed_total;
} }
@@ -4307,6 +4426,7 @@ cmd_delete_word_next(CommandContext &ctx)
std::size_t x = buf->Curx(); std::size_t x = buf->Curx();
int repeat = ctx.count > 0 ? ctx.count : 1; int repeat = ctx.count > 0 ? ctx.count : 1;
std::string killed_total; std::string killed_total;
UndoGroupGuard guard(buf->Undo());
for (int i = 0; i < repeat; ++i) { for (int i = 0; i < repeat; ++i) {
if (y >= rows.size()) if (y >= rows.size())
break; break;
@@ -4346,7 +4466,7 @@ cmd_delete_word_next(CommandContext &ctx)
} }
// Now delete from (start_x, start_y) to (x, y) using PieceTable // Now delete from (start_x, start_y) to (x, y) using PieceTable
std::string deleted = extract_region_text(*buf, start_x, start_y, x, y); std::string deleted = extract_region_text(*buf, start_x, start_y, x, y);
delete_region(*buf, start_x, start_y, x, y); delete_region(*buf, start_x, start_y, x, y, buf->Undo());
y = start_y; y = start_y;
x = start_x; x = start_x;
killed_total += deleted; killed_total += deleted;
@@ -4380,8 +4500,16 @@ cmd_indent_region(CommandContext &ctx)
ctx.editor.SetStatus("No region to indent"); ctx.editor.SetStatus("No region to indent");
return false; return false;
} }
UndoSystem *u = buf->Undo();
UndoGroupGuard guard(u);
for (std::size_t y = sy; y <= ey && y < buf->Rows().size(); ++y) { for (std::size_t y = sy; y <= ey && y < buf->Rows().size(); ++y) {
buf->insert_text(static_cast<int>(y), 0, std::string_view("\t")); buf->insert_text(static_cast<int>(y), 0, std::string_view("\t"));
if (u) {
buf->SetCursor(0, y);
u->Begin(UndoType::Insert);
u->Append('\t');
u->commit();
}
} }
buf->SetDirty(true); buf->SetDirty(true);
buf->ClearMark(); buf->ClearMark();
@@ -4405,6 +4533,8 @@ cmd_unindent_region(CommandContext &ctx)
ctx.editor.SetStatus("No region to unindent"); ctx.editor.SetStatus("No region to unindent");
return false; return false;
} }
UndoSystem *u = buf->Undo();
UndoGroupGuard guard(u);
for (std::size_t y = sy; y <= ey && y < buf->Rows().size(); ++y) { for (std::size_t y = sy; y <= ey && y < buf->Rows().size(); ++y) {
const auto &rows_view = buf->Rows(); const auto &rows_view = buf->Rows();
if (y >= rows_view.size()) if (y >= rows_view.size())
@@ -4413,13 +4543,26 @@ cmd_unindent_region(CommandContext &ctx)
if (!line.empty()) { if (!line.empty()) {
if (line[0] == '\t') { if (line[0] == '\t') {
buf->delete_text(static_cast<int>(y), 0, 1); buf->delete_text(static_cast<int>(y), 0, 1);
if (u) {
buf->SetCursor(0, y);
u->Begin(UndoType::Delete);
u->Append('\t');
u->commit();
}
} else if (line[0] == ' ') { } else if (line[0] == ' ') {
std::size_t spaces = 0; std::size_t spaces = 0;
while (spaces < line.size() && spaces < 8 && line[spaces] == ' ') { while (spaces < line.size() && spaces < 8 && line[spaces] == ' ') {
++spaces; ++spaces;
} }
if (spaces > 0) if (spaces > 0) {
buf->delete_text(static_cast<int>(y), 0, spaces); buf->delete_text(static_cast<int>(y), 0, spaces);
if (u) {
buf->SetCursor(0, y);
u->Begin(UndoType::Delete);
u->Append(line.substr(0, spaces));
u->commit();
}
}
} }
} }
} }
@@ -4436,25 +4579,8 @@ cmd_reflow_paragraph(CommandContext &ctx)
Buffer *buf = ctx.editor.CurrentBuffer(); Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf) if (!buf)
return false; return false;
struct GroupGuard {
UndoSystem *u;
explicit GroupGuard(UndoSystem *u_) : u(u_)
{
if (u)
u->BeginGroup();
}
~GroupGuard()
{
if (u)
u->EndGroup();
}
};
// Reflow performs a multi-edit transformation; make it a single standalone undo/redo step. // Reflow performs a multi-edit transformation; make it a single standalone undo/redo step.
GroupGuard guard(buf->Undo()); UndoGroupGuard guard(buf->Undo());
if (auto *u = buf->Undo()) if (auto *u = buf->Undo())
u->commit(); u->commit();
ensure_at_least_one_line(*buf); ensure_at_least_one_line(*buf);
+40 -1
View File
@@ -165,9 +165,23 @@ std::size_t
Editor::AddBuffer(const Buffer &buf) Editor::AddBuffer(const Buffer &buf)
{ {
auto &bufs = Buffers(); auto &bufs = Buffers();
// push_back may reallocate the vector's storage, moving every existing
// Buffer to a new address. Drain any in-flight swap records first so the
// writer thread never dereferences an address that's about to move, then
// rehome each attached buffer that actually moved.
if (swap_ && !bufs.empty())
swap_->Flush();
std::vector<Buffer *> old_addrs;
old_addrs.reserve(bufs.size());
for (auto &b: bufs)
old_addrs.push_back(&b);
bufs.push_back(buf); bufs.push_back(buf);
// Attach swap recorder
if (swap_) { if (swap_) {
for (std::size_t i = 0; i < old_addrs.size(); ++i) {
Buffer *new_addr = &bufs[i];
if (new_addr != old_addrs[i])
bufs[i].SetSwapRecorder(swap_->Rehome(old_addrs[i], new_addr));
}
swap_->Attach(&bufs.back()); swap_->Attach(&bufs.back());
bufs.back().SetSwapRecorder(swap_->RecorderFor(&bufs.back())); bufs.back().SetSwapRecorder(swap_->RecorderFor(&bufs.back()));
} }
@@ -182,8 +196,19 @@ std::size_t
Editor::AddBuffer(Buffer &&buf) Editor::AddBuffer(Buffer &&buf)
{ {
auto &bufs = Buffers(); auto &bufs = Buffers();
if (swap_ && !bufs.empty())
swap_->Flush();
std::vector<Buffer *> old_addrs;
old_addrs.reserve(bufs.size());
for (auto &b: bufs)
old_addrs.push_back(&b);
bufs.push_back(std::move(buf)); bufs.push_back(std::move(buf));
if (swap_) { if (swap_) {
for (std::size_t i = 0; i < old_addrs.size(); ++i) {
Buffer *new_addr = &bufs[i];
if (new_addr != old_addrs[i])
bufs[i].SetSwapRecorder(swap_->Rehome(old_addrs[i], new_addr));
}
swap_->Attach(&bufs.back()); swap_->Attach(&bufs.back());
bufs.back().SetSwapRecorder(swap_->RecorderFor(&bufs.back())); bufs.back().SetSwapRecorder(swap_->RecorderFor(&bufs.back()));
} }
@@ -495,8 +520,22 @@ Editor::CloseBuffer(std::size_t index)
// This prevents stale swap files from accumulating (e.g., when used as git editor). // This prevents stale swap files from accumulating (e.g., when used as git editor).
swap_->Detach(&bufs[index], true); swap_->Detach(&bufs[index], true);
bufs[index].SetSwapRecorder(nullptr); bufs[index].SetSwapRecorder(nullptr);
// Drain in-flight records before the erase-shift below moves other
// buffers to new addresses (vector::erase never reallocates, but it does
// move-assign each trailing buffer into the previous slot).
swap_->Flush();
} }
bufs.erase(bufs.begin() + static_cast<std::ptrdiff_t>(index)); bufs.erase(bufs.begin() + static_cast<std::ptrdiff_t>(index));
if (swap_) {
// erase() shifts every buffer after `index` down by one slot in-place
// (no reallocation), so the buffer now at slot i used to live at slot
// i+1 (same underlying storage, since data() doesn't move on erase).
for (std::size_t i = index; i < bufs.size(); ++i) {
Buffer *new_addr = &bufs[i];
Buffer *old_addr = new_addr + 1;
bufs[i].SetSwapRecorder(swap_->Rehome(old_addr, new_addr));
}
}
if (bufs.empty()) { if (bufs.empty()) {
curbuf_ = 0; curbuf_ = 0;
} else if (curbuf_ >= bufs.size()) { } else if (curbuf_ >= bufs.size()) {
+19 -1
View File
@@ -44,6 +44,12 @@ apply_syntax_to_buffer(Buffer *b, const GUIConfig &cfg)
if (!b->EditModeDetected() && !b->Filename().empty()) if (!b->EditModeDetected() && !b->Filename().empty())
b->SetEditMode(DetectEditMode(b->Filename())); b->SetEditMode(DetectEditMode(b->Filename()));
// If the user explicitly set syntax state via a command (:syntax on/off,
// :set filetype=...), leave it alone - otherwise this runs every frame
// and silently undoes a manual ":syntax off" on the very next frame.
if (b->SyntaxUserOverride())
return;
// Writing mode disables syntax; otherwise follow the global config. // Writing mode disables syntax; otherwise follow the global config.
if (cfg.syntax && b->GetEditMode() != EditMode::Writing) { if (cfg.syntax && b->GetEditMode() != EditMode::Writing) {
b->SetSyntaxEnabled(true); b->SetSyntaxEnabled(true);
@@ -469,7 +475,19 @@ GUIFrontend::Step(Editor &ed, bool &running)
// Route input events to the correct window's input handler // Route input events to the correct window's input handler
if (target) { if (target) {
target->input.ProcessSDLEvent(e); Editor &tgt_ed = (target_idx == 0) ? ed : target->editor;
if (tgt_ed.FilePickerVisible()) {
// Modal: don't let keystrokes fall through as edit commands
// to the buffer underneath. Escape closes the picker;
// everything else (navigation, filtering, double-click) is
// handled by the picker's own ImGui widgets via
// ImGui_ImplSDL2_ProcessEvent above.
if (e.type == SDL_KEYDOWN && e.key.keysym.sym == SDLK_ESCAPE) {
tgt_ed.SetFilePickerVisible(false);
}
} else {
target->input.ProcessSDLEvent(e);
}
} }
} }
+36 -39
View File
@@ -8,6 +8,20 @@
#include "ImGuiInputHandler.h" #include "ImGuiInputHandler.h"
#include "KKeymap.h" #include "KKeymap.h"
#include "Editor.h" #include "Editor.h"
#include "PasteSplit.h"
// Verbose k-prefix suffix logging for debugging macOS/SDL key translation
// issues. Default to off; enable by defining IMGUI_IH_DEBUG=1 at compile
// time. Mirrors QtInputHandler.cc's QT_IH_DEBUG gate.
#ifndef IMGUI_IH_DEBUG
#define IMGUI_IH_DEBUG 0
#endif
#if IMGUI_IH_DEBUG
#define IH_LOGF(...) do { std::fprintf(stderr, __VA_ARGS__); std::fflush(stderr); } while (0)
#else
#define IH_LOGF(...) ((void) 0)
#endif
static bool static bool
@@ -182,18 +196,19 @@ map_key(const SDL_Keycode key,
k_ctrl_pending = false; k_ctrl_pending = false;
CommandId id; CommandId id;
bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id); bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id);
#if IMGUI_IH_DEBUG
// Diagnostics for u/U // Diagnostics for u/U
if (lower == 'u') { if (lower == 'u') {
char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e) char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e)
? static_cast<char>(ascii_key) ? static_cast<char>(ascii_key)
: '?'; : '?';
std::fprintf(stderr, IH_LOGF(
"[kge] k-prefix suffix: sym=%d mods=0x%x ascii=%d '%c' ctrl2=%d pass_ctrl=%d mapped=%d id=%d\n", "[kge] k-prefix suffix: sym=%d mods=0x%x ascii=%d '%c' ctrl2=%d pass_ctrl=%d mapped=%d id=%d\n",
static_cast<int>(key), static_cast<unsigned int>(mod), ascii_key, disp, static_cast<int>(key), static_cast<unsigned int>(mod), ascii_key, disp,
ctrl2 ? 1 : 0, pass_ctrl ? 1 : 0, mapped ? 1 : 0, ctrl2 ? 1 : 0, pass_ctrl ? 1 : 0, mapped ? 1 : 0,
mapped ? static_cast<int>(id) : -1); mapped ? static_cast<int>(id) : -1);
std::fflush(stderr);
} }
#endif
if (mapped) { if (mapped) {
out = {true, id, "", 0}; out = {true, id, "", 0};
if (ed) if (ed)
@@ -376,32 +391,12 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
if (clip) { if (clip) {
std::string text(clip); std::string text(clip);
SDL_free(clip); SDL_free(clip);
// Split on '\n' and enqueue as InsertText/Newline commands // Turn line breaks (\n, \r\n, or bare \r) into Newline
// commands and the rest into InsertText; InsertText itself
// rejects any embedded '\r'/'\n'.
std::lock_guard<std::mutex> lk(mu_); std::lock_guard<std::mutex> lk(mu_);
std::size_t start = 0; for (const auto &cmd : SplitPasteIntoCommands(text))
while (start <= text.size()) { q_.push(cmd);
std::size_t pos = text.find('\n', start);
std::string_view segment;
bool has_nl = (pos != std::string::npos);
if (has_nl) {
segment = std::string_view(text).substr(start, pos - start);
} else {
segment = std::string_view(text).substr(start);
}
if (!segment.empty()) {
MappedInput ins{
true, CommandId::InsertText, std::string(segment), 0
};
q_.push(ins);
}
if (has_nl) {
MappedInput nl{true, CommandId::Newline, std::string(), 0};
q_.push(nl);
start = pos + 1;
} else {
break;
}
}
// Suppress the corresponding TEXTINPUT that may follow // Suppress the corresponding TEXTINPUT that may follow
suppress_text_input_once_ = true; suppress_text_input_once_ = true;
return true; // consumed return true; // consumed
@@ -524,15 +519,17 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
bool pass_ctrl = k_ctrl_pending_; bool pass_ctrl = k_ctrl_pending_;
k_ctrl_pending_ = false; k_ctrl_pending_ = false;
bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id); bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id);
#if IMGUI_IH_DEBUG
// Diagnostics: log any k-prefix TEXTINPUT suffix mapping // Diagnostics: log any k-prefix TEXTINPUT suffix mapping
char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e) {
? static_cast<char>(ascii_key) char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e)
: '?'; ? static_cast<char>(ascii_key)
std::fprintf(stderr, : '?';
"[kge] k-prefix TEXTINPUT suffix: ascii=%d '%c' mapped=%d id=%d\n", IH_LOGF("[kge] k-prefix TEXTINPUT suffix: ascii=%d '%c' mapped=%d id=%d\n",
ascii_key, disp, mapped ? 1 : 0, ascii_key, disp, mapped ? 1 : 0,
mapped ? static_cast<int>(id) : -1); mapped ? static_cast<int>(id) : -1);
std::fflush(stderr); }
#endif
if (mapped) { if (mapped) {
mi = {true, id, "", 0}; mi = {true, id, "", 0};
if (ed_) if (ed_)
+9 -5
View File
@@ -1,6 +1,4 @@
#include <iostream>
#include <ncurses.h> #include <ncurses.h>
#include <ostream>
#include "KKeymap.h" #include "KKeymap.h"
@@ -40,6 +38,15 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
out = CommandId::ToggleReadOnly; // C-k ' (toggle read-only) out = CommandId::ToggleReadOnly; // C-k ' (toggle read-only)
return true; return true;
} }
if (ascii_key == 'E') {
// Explicitly rejected and kept distinct from 'e' (OpenFileStart): the
// switch below operates on the lowercased key, so it can't otherwise
// tell 'E' and 'e' apart. Return false and let the caller show its
// normal "unknown k-command" status-line message - curses already
// owns the terminal here, so writing to stderr would corrupt the
// screen.
return false;
}
switch (k_lower) { switch (k_lower) {
case 'a': case 'a':
@@ -66,9 +73,6 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
case 'e': case 'e':
out = CommandId::OpenFileStart; out = CommandId::OpenFileStart;
return true; return true;
case 'E':
std::cerr << "E is not a valid command" << std::endl;
return false;
case 'f': case 'f':
out = CommandId::FlushKillRing; out = CommandId::FlushKillRing;
return true; return true;
+48
View File
@@ -0,0 +1,48 @@
/*
* PasteSplit.h - split pasted/clipboard text into editor commands
*
* Pure logic (no SDL/GUI dependencies) so it can be unit tested and shared
* across frontends. Line breaks in the pasted text must become Newline
* commands; InsertText itself rejects embedded '\r'/'\n' (see Command.cc).
*/
#pragma once
#include <string>
#include <vector>
#include "InputHandler.h"
// Translate a block of pasted text into a sequence of editor commands.
//
// Any of "\n", "\r\n", or a bare "\r" is treated as a single line break and
// emitted as a Newline command; the text between breaks becomes InsertText
// commands. Empty text segments are skipped, but line breaks are always
// emitted so blank lines round-trip correctly.
inline std::vector<MappedInput> SplitPasteIntoCommands(const std::string &text)
{
std::vector<MappedInput> out;
std::string segment;
auto flush_segment = [&]() {
if (!segment.empty()) {
out.push_back(MappedInput{true, CommandId::InsertText, segment, 0});
segment.clear();
}
};
for (std::size_t i = 0; i < text.size(); ++i) {
const char c = text[i];
if (c == '\n' || c == '\r') {
flush_segment();
out.push_back(MappedInput{true, CommandId::Newline, std::string(), 0});
// Collapse a "\r\n" pair into one line break.
if (c == '\r' && i + 1 < text.size() && text[i + 1] == '\n')
++i;
} else {
segment.push_back(c);
}
}
flush_segment();
return out;
}
+37
View File
@@ -268,6 +268,43 @@ SwapManager::RecorderFor(Buffer *buf)
} }
SwapRecorder *
SwapManager::Rehome(Buffer *old_addr, Buffer *new_addr)
{
if (!old_addr || !new_addr || old_addr == new_addr)
return nullptr;
std::lock_guard<std::mutex> lg(mtx_);
SwapRecorder *result = nullptr;
auto jit = journals_.find(old_addr);
if (jit != journals_.end()) {
JournalCtx ctx = std::move(jit->second);
journals_.erase(jit);
journals_[new_addr] = std::move(ctx);
}
auto rit = recorders_.find(old_addr);
if (rit != recorders_.end()) {
recorders_.erase(rit);
// BufferRecorder binds a Buffer& at construction, so it can't be
// repointed in place; rebuild it against the buffer's new address.
auto rec = std::make_unique<BufferRecorder>(*this, *new_addr);
result = rec.get();
recorders_[new_addr] = std::move(rec);
}
// Defensive: any record still queued (not yet drained by the writer thread)
// for the old address must follow the buffer to its new location. Callers
// are expected to Flush() before rehoming so this should normally be a no-op.
for (auto &p: queue_) {
if (p.buf == old_addr)
p.buf = new_addr;
}
return result;
}
void void
SwapManager::Attach(Buffer *buf) SwapManager::Attach(Buffer *buf)
{ {
+9
View File
@@ -94,6 +94,15 @@ public:
// Detach(buf) or SwapManager destruction. // Detach(buf) or SwapManager destruction.
SwapRecorder *RecorderFor(Buffer *buf); SwapRecorder *RecorderFor(Buffer *buf);
// Re-key an attached buffer's journal/recorder entries after its Buffer object
// has moved to a new address (e.g. std::vector<Buffer> reallocation/erase-shift).
// Callers must ensure no swap records for old_addr are in flight (see Flush())
// before calling this, and must not call it with an address that isn't
// currently attached. Returns the recorder for new_addr (nullptr if old_addr
// wasn't attached); the caller is responsible for calling
// new_buf->SetSwapRecorder() with the result.
SwapRecorder *Rehome(Buffer *old_addr, Buffer *new_addr);
// Notify that the buffer's filename changed (e.g., SaveAs) // Notify that the buffer's filename changed (e.g., SaveAs)
void NotifyFilenameChanged(Buffer &buf); void NotifyFilenameChanged(Buffer &buf);
+46 -11
View File
@@ -1,4 +1,6 @@
#include <cstdio> #include <cstdio>
#include <climits>
#include <cwchar>
#include <ncurses.h> #include <ncurses.h>
#include "TerminalInputHandler.h" #include "TerminalInputHandler.h"
@@ -12,6 +14,22 @@ CTRL(char c)
{ {
return c & 0x1F; return c & 0x1F;
} }
// Encode a single wide character in the process locale's multibyte encoding
// (UTF-8, given main.cc's setlocale(LC_ALL, "")). Returns false if the
// codepoint can't be represented, leaving `out` untouched.
bool
wchar_to_mb(wchar_t wc, std::string &out)
{
std::mbstate_t state{};
char buf[MB_LEN_MAX];
std::size_t n = std::wcrtomb(buf, wc, &state);
if (n == static_cast<std::size_t>(-1))
return false;
out.assign(buf, n);
return true;
}
} }
TerminalInputHandler::TerminalInputHandler() = default; TerminalInputHandler::TerminalInputHandler() = default;
@@ -21,6 +39,7 @@ TerminalInputHandler::~TerminalInputHandler() = default;
static bool static bool
map_key_to_command(const int ch, map_key_to_command(const int ch,
const bool is_keycode,
bool &k_prefix, bool &k_prefix,
bool &esc_meta, bool &esc_meta,
bool &k_ctrl_pending, bool &k_ctrl_pending,
@@ -28,9 +47,13 @@ map_key_to_command(const int ch,
Editor *ed, Editor *ed,
MappedInput &out) MappedInput &out)
{ {
// Handle special keys from ncurses // Handle special keys from ncurses. These are only meaningful when
// get_wch() reported KEY_CODE_YES: a regular (possibly non-ASCII) wide
// character can numerically collide with a KEY_* constant otherwise
// (e.g. U+0107 'ć' equals KEY_BACKSPACE's value), which would wrongly
// swallow it as a special key instead of inserting it.
// These keys exit k-prefix mode if active (user pressed C-k then a special key). // These keys exit k-prefix mode if active (user pressed C-k then a special key).
switch (ch) { switch (is_keycode ? ch : -1) {
case KEY_ENTER: case KEY_ENTER:
// Some terminals send KEY_ENTER distinct from '\n'/'\r' // Some terminals send KEY_ENTER distinct from '\n'/'\r'
k_prefix = false; k_prefix = false;
@@ -259,7 +282,7 @@ map_key_to_command(const int ch,
esc_meta = false; esc_meta = false;
int ascii_key = ch; int ascii_key = ch;
// Handle ESC + BACKSPACE (meta-backspace, Alt-Backspace) // Handle ESC + BACKSPACE (meta-backspace, Alt-Backspace)
if (ch == KEY_BACKSPACE || ch == 127 || ch == CTRL('H')) { if ((is_keycode && ch == KEY_BACKSPACE) || ch == 127 || ch == CTRL('H')) {
ascii_key = KEY_BACKSPACE; // normalized value for lookup ascii_key = KEY_BACKSPACE; // normalized value for lookup
} else if (ch == ',') { } else if (ch == ',') {
// Some terminals emit ',' when Shift state is lost after ESC; treat as '<' // Some terminals emit ',' when Shift state is lost after ESC; treat as '<'
@@ -281,7 +304,7 @@ map_key_to_command(const int ch,
} }
// Backspace in ncurses can be KEY_BACKSPACE or 127 // Backspace in ncurses can be KEY_BACKSPACE or 127
if (ch == KEY_BACKSPACE || ch == 127 || ch == CTRL('H')) { if ((is_keycode && ch == KEY_BACKSPACE) || ch == 127 || ch == CTRL('H')) {
k_prefix = false; k_prefix = false;
k_ctrl_pending = false; k_ctrl_pending = false;
out = {true, CommandId::Backspace, "", 0}; out = {true, CommandId::Backspace, "", 0};
@@ -297,12 +320,21 @@ map_key_to_command(const int ch,
return true; return true;
} }
// Printable ASCII // Printable character: ASCII, or (for a regular decoded wide character,
if (ch >= 0x20 && ch <= 0x7E) { // not a keycode) any other printable Unicode codepoint - e.g. accented
// Latin, Cyrillic, CJK, etc.
if (!is_keycode && ch >= 0x20 && ch != 0x7F) {
std::string mb;
if (ch <= 0x7E) {
mb.assign(1, static_cast<char>(ch));
} else if (!wchar_to_mb(static_cast<wchar_t>(ch), mb)) {
out.hasCommand = false;
return true;
}
out.hasCommand = true; out.hasCommand = true;
out.id = CommandId::InsertText; out.id = CommandId::InsertText;
out.arg.assign(1, static_cast<char>(ch)); out.arg = mb;
out.count = 0; out.count = 0;
return true; return true;
} }
@@ -314,12 +346,15 @@ map_key_to_command(const int ch,
bool bool
TerminalInputHandler::decode_(MappedInput &out) TerminalInputHandler::decode_(MappedInput &out)
{ {
int ch = getch(); wint_t wch;
if (ch == ERR) { int ret = get_wch(&wch);
if (ret == ERR) {
return false; // no input return false; // no input
} }
const bool is_keycode = (ret == KEY_CODE_YES);
const int ch = static_cast<int>(wch);
bool consumed = map_key_to_command( bool consumed = map_key_to_command(
ch, ch, is_keycode,
k_prefix_, esc_meta_, k_prefix_, esc_meta_,
k_ctrl_pending_, k_ctrl_pending_,
mouse_selecting_, mouse_selecting_,
+6
View File
@@ -10,6 +10,12 @@ enum class UndoType : std::uint8_t {
Newline, Newline,
DeleteRow, DeleteRow,
InsertRow, InsertRow,
// Inverse of Newline: forward = join_lines(row) [removes the newline at the
// end of `row`], backward = split_line(row, col) [recreates the original
// two lines]. Used by backspace-at-col-0 and delete-at-eol, whose forward
// action is a join, not a split — Newline's apply() semantics are the
// wrong direction for those.
JoinLines,
}; };
struct UndoNode { struct UndoNode {
+14 -1
View File
@@ -37,7 +37,7 @@ UndoSystem::Begin(UndoType type)
// Some operations should always be standalone undo steps. // Some operations should always be standalone undo steps.
const bool always_standalone = (type == UndoType::Newline || type == UndoType::DeleteRow || type == const bool always_standalone = (type == UndoType::Newline || type == UndoType::DeleteRow || type ==
UndoType::InsertRow); UndoType::InsertRow || type == UndoType::JoinLines);
if (always_standalone) { if (always_standalone) {
commit(); commit();
} }
@@ -77,6 +77,7 @@ UndoSystem::Begin(UndoType type)
case UndoType::Newline: case UndoType::Newline:
case UndoType::DeleteRow: case UndoType::DeleteRow:
case UndoType::InsertRow: case UndoType::InsertRow:
case UndoType::JoinLines:
break; break;
} }
} }
@@ -325,6 +326,16 @@ UndoSystem::apply(const UndoNode *node, int direction)
buf_->SetCursor(0, static_cast<std::size_t>(node->row)); buf_->SetCursor(0, static_cast<std::size_t>(node->row));
} }
break; break;
case UndoType::JoinLines:
// Mirror image of Newline: forward removes a newline, backward restores it.
if (direction > 0) {
buf_->join_lines(node->row);
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
} else {
buf_->split_line(node->row, node->col);
buf_->SetCursor(0, static_cast<std::size_t>(node->row + 1));
}
break;
} }
} }
@@ -424,6 +435,8 @@ UndoSystem::type_str(UndoType t)
return "DeleteRow"; return "DeleteRow";
case UndoType::InsertRow: case UndoType::InsertRow:
return "InsertRow"; return "InsertRow";
case UndoType::JoinLines:
return "JoinLines";
} }
return "?"; return "?";
} }
+23 -2
View File
@@ -1,3 +1,24 @@
// Placeholder translation unit for UndoTree struct definition. // Undo logic is implemented in UndoSystem; this file only owns node lifetime.
// Undo logic is implemented in UndoSystem.
#include "UndoTree.h" #include "UndoTree.h"
namespace {
void
free_node_graph(UndoNode *node)
{
// Walk the sibling (redo-branch) list; for each node, recursively free its
// child subtree first, then the node itself.
while (node) {
UndoNode *next = node->next;
free_node_graph(node->child);
delete node;
node = next;
}
}
} // namespace
UndoTree::~UndoTree()
{
free_node_graph(root);
delete pending;
}
+7
View File
@@ -7,4 +7,11 @@ struct UndoTree {
UndoNode *current = nullptr; // current state of buffer UndoNode *current = nullptr; // current state of buffer
UndoNode *saved = nullptr; // points to node matching last save (for dirty flag) UndoNode *saved = nullptr; // points to node matching last save (for dirty flag)
UndoNode *pending = nullptr; // in-progress batch (detached) UndoNode *pending = nullptr; // in-progress batch (detached)
// Frees the entire node graph (root's subtree/branches plus any detached
// pending node). `current`/`saved` are aliases into that same graph, not
// separately owned. Without this, closing/reloading/resetting a buffer
// (which replaces or destroys its UndoTree) leaks every UndoNode ever
// created for it.
~UndoTree();
}; };
+14 -2
View File
@@ -292,6 +292,20 @@ main(int argc, char *argv[])
fe = std::make_unique<TerminalFrontend>(); fe = std::make_unique<TerminalFrontend>();
} }
// Guarantee Shutdown() runs on every exit path from here on (normal
// return, early return, or exception unwinding e.g. from Init/Execute/
// Step) so a crash never leaves the terminal in raw/cbreak mode.
struct FrontendShutdownGuard {
Frontend *fe;
~FrontendShutdownGuard()
{
if (fe)
fe->Shutdown();
}
} shutdown_guard{fe.get()};
#if defined(KTE_BUILD_GUI) && defined(__APPLE__) #if defined(KTE_BUILD_GUI) && defined(__APPLE__)
if (use_gui) { if (use_gui) {
/* likely using the .app, so need to cd */ /* likely using the .app, so need to cd */
@@ -320,8 +334,6 @@ main(int argc, char *argv[])
fe->Step(editor, running); fe->Step(editor, running);
} }
fe->Shutdown();
return 0; return 0;
} catch (const std::exception &e) { } catch (const std::exception &e) {
std::string msg = std::string("Unhandled exception: ") + e.what(); std::string msg = std::string("Unhandled exception: ") + e.what();
+35 -2
View File
@@ -46,14 +46,45 @@ GoHighlighter::GoHighlighter()
void void
GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
{ {
StatefulHighlighter::LineState prev;
(void) HighlightLineStateful(buf, row, prev, out);
}
StatefulHighlighter::LineState
GoHighlighter::HighlightLineStateful(const Buffer &buf,
int row,
const LineState &prev,
std::vector<HighlightSpan> &out) const
{
StatefulHighlighter::LineState state = prev;
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows()) if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
return; return state;
std::string s = buf.GetLineString(static_cast<std::size_t>(row)); std::string s = buf.GetLineString(static_cast<std::size_t>(row));
int n = static_cast<int>(s.size()); int n = static_cast<int>(s.size());
int i = 0; int i = 0;
int bol = 0; int bol = 0;
while (bol < n && (s[bol] == ' ' || s[bol] == '\t')) while (bol < n && (s[bol] == ' ' || s[bol] == '\t'))
++bol; ++bol;
// Continue a multi-line block comment from the previous line.
if (state.in_block_comment) {
int j = i;
while (i + 1 < n) {
if (s[i] == '*' && s[i + 1] == '/') {
i += 2;
push(out, j, i, TokenKind::Comment);
state.in_block_comment = false;
break;
}
++i;
}
if (state.in_block_comment) {
push(out, j, n, TokenKind::Comment);
return state;
}
}
// line comment // line comment
while (i < n) { while (i < n) {
char c = s[i]; char c = s[i];
@@ -82,7 +113,8 @@ GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSp
} }
if (!closed) { if (!closed) {
push(out, i, n, TokenKind::Comment); push(out, i, n, TokenKind::Comment);
break; state.in_block_comment = true;
return state;
} else { } else {
push(out, i, j, TokenKind::Comment); push(out, i, j, TokenKind::Comment);
i = j; i = j;
@@ -152,5 +184,6 @@ GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSp
push(out, i, i + 1, TokenKind::Default); push(out, i, i + 1, TokenKind::Default);
++i; ++i;
} }
return state;
} }
} // namespace kte } // namespace kte
+6 -1
View File
@@ -5,12 +5,17 @@
#include <unordered_set> #include <unordered_set>
namespace kte { namespace kte {
class GoHighlighter final : public LanguageHighlighter { class GoHighlighter final : public StatefulHighlighter {
public: public:
GoHighlighter(); GoHighlighter();
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override; void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
LineState HighlightLineStateful(const Buffer &buf,
int row,
const LineState &prev,
std::vector<HighlightSpan> &out) const override;
private: private:
std::unordered_set<std::string> kws_; std::unordered_set<std::string> kws_;
std::unordered_set<std::string> types_; std::unordered_set<std::string> types_;
+27 -1
View File
@@ -74,7 +74,22 @@ HighlighterEngine::GetLine(const Buffer &buf, int row, std::uint64_t buf_version
StatefulHighlighter::LineState prev_state; StatefulHighlighter::LineState prev_state;
int start_row = -1; int start_row = -1;
if (!state_cache_.empty()) {
// Fast path: state_last_contig_ tracks the highest row we've cached state
// for, per version. If that row is already below our target, it's
// necessarily the best anchor the O(n) scan below would have found, so we
// can skip the scan entirely. Re-validated against state_cache_ before
// use since InvalidateFrom()/SetHighlighter() may have raced/cleared it.
auto contig_it = state_last_contig_.find(buf_version);
if (contig_it != state_last_contig_.end() && contig_it->second < row) {
auto sc_it = state_cache_.find(contig_it->second);
if (sc_it != state_cache_.end() && sc_it->second.version == buf_version) {
start_row = contig_it->second;
prev_state = sc_it->second.state;
}
}
if (start_row < 0 && !state_cache_.empty()) {
// linear search over map (unordered), track best candidate // linear search over map (unordered), track best candidate
int best = -1; int best = -1;
for (const auto &kv: state_cache_) { for (const auto &kv: state_cache_) {
@@ -109,6 +124,9 @@ HighlighterEngine::GetLine(const Buffer &buf, int row, std::uint64_t buf_version
se.state = next_state; se.state = next_state;
state_cache_[r] = se; state_cache_[r] = se;
cur_state = next_state; cur_state = next_state;
int &contig = state_last_contig_[buf_version];
if (r > contig)
contig = r;
} }
// Store in cache and return by value // Store in cache and return by value
@@ -139,6 +157,14 @@ HighlighterEngine::InvalidateFrom(int row)
++it; ++it;
} }
} }
// A version's tracked contiguous-state row is no longer valid once any
// row at or above it has been evicted from state_cache_ above.
for (auto it = state_last_contig_.begin(); it != state_last_contig_.end();) {
if (it->second >= row)
it = state_last_contig_.erase(it);
else
++it;
}
} }
+11 -16
View File
@@ -55,31 +55,26 @@ PythonHighlighter::HighlightLineStateful(const Buffer &buf, int row, const LineS
std::string s = buf.GetLineString(static_cast<std::size_t>(row)); std::string s = buf.GetLineString(static_cast<std::size_t>(row));
int n = static_cast<int>(s.size()); int n = static_cast<int>(s.size());
int i = 0;
// Triple-quoted string continuation uses in_raw_string with raw_delim either "'''" or "\"\"\"" // Triple-quoted string continuation uses in_raw_string with raw_delim either "'''" or "\"\"\""
if (state.in_raw_string && (state.raw_delim == "'''" || state.raw_delim == "\"\"\"")) { if (state.in_raw_string && (state.raw_delim == "'''" || state.raw_delim == "\"\"\"")) {
auto pos = s.find(state.raw_delim); auto pos = s.find(state.raw_delim);
if (pos == std::string::npos) { if (pos == std::string::npos) {
push(out, 0, n, TokenKind::String); push(out, 0, n, TokenKind::String);
return state; // still inside return state; // still inside
} else {
int end = static_cast<int>(pos + static_cast<int>(state.raw_delim.size()));
push(out, 0, end, TokenKind::String);
// remainder processed normally
s = s.substr(end);
n = static_cast<int>(s.size());
state.in_raw_string = false;
state.raw_delim.clear();
// Continue parsing remainder as a separate small loop
int base = end;
// original offset, but we already emitted to 'out' with base=0; following spans should be from 'end'
// For simplicity, mark rest as Default
if (n > 0)
push(out, base, base + n, TokenKind::Default);
return state;
} }
int end = static_cast<int>(pos + static_cast<int>(state.raw_delim.size()));
push(out, 0, end, TokenKind::String);
state.in_raw_string = false;
state.raw_delim.clear();
// Resume the normal tokenizer at the closing delimiter's end, on the
// same (unmodified) `s`/`n`, so anything after it - including a new
// triple-quoted string opening on this same line - is re-scanned
// instead of being dumped into a single opaque Default span.
i = end;
} }
int i = 0;
// Detect comment start '#', ignoring inside strings // Detect comment start '#', ignoring inside strings
while (i < n) { while (i < n) {
char c = s[i]; char c = s[i];
+35 -2
View File
@@ -47,11 +47,42 @@ RustHighlighter::RustHighlighter()
void void
RustHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const RustHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
{ {
StatefulHighlighter::LineState prev;
(void) HighlightLineStateful(buf, row, prev, out);
}
StatefulHighlighter::LineState
RustHighlighter::HighlightLineStateful(const Buffer &buf,
int row,
const LineState &prev,
std::vector<HighlightSpan> &out) const
{
StatefulHighlighter::LineState state = prev;
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows()) if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
return; return state;
std::string s = buf.GetLineString(static_cast<std::size_t>(row)); std::string s = buf.GetLineString(static_cast<std::size_t>(row));
int n = static_cast<int>(s.size()); int n = static_cast<int>(s.size());
int i = 0; int i = 0;
// Continue a multi-line block comment from the previous line.
if (state.in_block_comment) {
int j = i;
while (i + 1 < n) {
if (s[i] == '*' && s[i + 1] == '/') {
i += 2;
push(out, j, i, TokenKind::Comment);
state.in_block_comment = false;
break;
}
++i;
}
if (state.in_block_comment) {
push(out, j, n, TokenKind::Comment);
return state;
}
}
while (i < n) { while (i < n) {
char c = s[i]; char c = s[i];
if (c == ' ' || c == '\t') { if (c == ' ' || c == '\t') {
@@ -79,7 +110,8 @@ RustHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<Highlight
} }
if (!closed) { if (!closed) {
push(out, i, n, TokenKind::Comment); push(out, i, n, TokenKind::Comment);
break; state.in_block_comment = true;
return state;
} else { } else {
push(out, i, j, TokenKind::Comment); push(out, i, j, TokenKind::Comment);
i = j; i = j;
@@ -140,5 +172,6 @@ RustHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<Highlight
push(out, i, i + 1, TokenKind::Default); push(out, i, i + 1, TokenKind::Default);
++i; ++i;
} }
return state;
} }
} // namespace kte } // namespace kte
+6 -1
View File
@@ -5,12 +5,17 @@
#include <unordered_set> #include <unordered_set>
namespace kte { namespace kte {
class RustHighlighter final : public LanguageHighlighter { class RustHighlighter final : public StatefulHighlighter {
public: public:
RustHighlighter(); RustHighlighter();
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override; void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
LineState HighlightLineStateful(const Buffer &buf,
int row,
const LineState &prev,
std::vector<HighlightSpan> &out) const override;
private: private:
std::unordered_set<std::string> kws_; std::unordered_set<std::string> kws_;
std::unordered_set<std::string> types_; std::unordered_set<std::string> types_;
+35 -3
View File
@@ -47,12 +47,42 @@ SqlHighlighter::SqlHighlighter()
void void
SqlHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const SqlHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
{ {
StatefulHighlighter::LineState prev;
(void) HighlightLineStateful(buf, row, prev, out);
}
StatefulHighlighter::LineState
SqlHighlighter::HighlightLineStateful(const Buffer &buf,
int row,
const LineState &prev,
std::vector<HighlightSpan> &out) const
{
StatefulHighlighter::LineState state = prev;
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows()) if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
return; return state;
std::string s = buf.GetLineString(static_cast<std::size_t>(row)); std::string s = buf.GetLineString(static_cast<std::size_t>(row));
int n = static_cast<int>(s.size()); int n = static_cast<int>(s.size());
int i = 0; int i = 0;
// Continue a multi-line block comment from the previous line.
if (state.in_block_comment) {
int j = i;
while (i + 1 < n) {
if (s[i] == '*' && s[i + 1] == '/') {
i += 2;
push(out, j, i, TokenKind::Comment);
state.in_block_comment = false;
break;
}
++i;
}
if (state.in_block_comment) {
push(out, j, n, TokenKind::Comment);
return state;
}
}
while (i < n) { while (i < n) {
char c = s[i]; char c = s[i];
if (c == ' ' || c == '\t') { if (c == ' ' || c == '\t') {
@@ -68,7 +98,7 @@ SqlHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightS
push(out, i, n, TokenKind::Comment); push(out, i, n, TokenKind::Comment);
break; break;
} }
// simple block comment on same line: /* ... */ // block comment: /* ... */ (may span multiple lines)
if (c == '/' && i + 1 < n && s[i + 1] == '*') { if (c == '/' && i + 1 < n && s[i + 1] == '*') {
int j = i + 2; int j = i + 2;
bool closed = false; bool closed = false;
@@ -82,7 +112,8 @@ SqlHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightS
} }
if (!closed) { if (!closed) {
push(out, i, n, TokenKind::Comment); push(out, i, n, TokenKind::Comment);
break; state.in_block_comment = true;
return state;
} else { } else {
push(out, i, j, TokenKind::Comment); push(out, i, j, TokenKind::Comment);
i = j; i = j;
@@ -151,5 +182,6 @@ SqlHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightS
push(out, i, i + 1, TokenKind::Default); push(out, i, i + 1, TokenKind::Default);
++i; ++i;
} }
return state;
} }
} // namespace kte } // namespace kte
+6 -1
View File
@@ -5,12 +5,17 @@
#include <unordered_set> #include <unordered_set>
namespace kte { namespace kte {
class SqlHighlighter final : public LanguageHighlighter { class SqlHighlighter final : public StatefulHighlighter {
public: public:
SqlHighlighter(); SqlHighlighter();
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override; void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
LineState HighlightLineStateful(const Buffer &buf,
int row,
const LineState &prev,
std::vector<HighlightSpan> &out) const override;
private: private:
std::unordered_set<std::string> kws_; std::unordered_set<std::string> kws_;
std::unordered_set<std::string> types_; std::unordered_set<std::string> types_;
+23
View File
@@ -108,3 +108,26 @@ TEST(CommandSemantics_CopyRegion_And_KillRegion)
ASSERT_EQ(b.MarkSet(), false); ASSERT_EQ(b.MarkSet(), false);
ASSERT_EQ(h.Text(), std::string("hello ")); ASSERT_EQ(h.Text(), std::string("hello "));
} }
TEST(CommandSemantics_Syntax_OnOff_SetsUserOverride)
{
TestHarness h;
Editor &ed = h.EditorRef();
Buffer &b = h.Buf();
// Before any explicit :syntax command, nothing has overridden the
// frontend's config-driven default.
ASSERT_EQ(b.SyntaxUserOverride(), false);
ASSERT_TRUE(Execute(ed, CommandId::Syntax, "off"));
ASSERT_EQ(b.SyntaxEnabled(), false);
// This flag is what a frontend's per-frame "apply config default" pass
// must check before re-enabling syntax, or a manual :syntax off gets
// silently undone on the next frame.
ASSERT_EQ(b.SyntaxUserOverride(), true);
ASSERT_TRUE(Execute(ed, CommandId::Syntax, "on"));
ASSERT_EQ(b.SyntaxEnabled(), true);
ASSERT_EQ(b.SyntaxUserOverride(), true);
}
+15
View File
@@ -37,6 +37,21 @@ TEST(KKeymap_KPrefix_CanonicalChords)
} }
TEST(KKeymap_KPrefix_UppercaseE_RejectedAndDistinctFromLowercaseE)
{
CommandId id{};
// 'e' is a real binding (OpenFileStart).
ASSERT_TRUE(KLookupKCommand('e', false, id));
ASSERT_EQ(id, CommandId::OpenFileStart);
// 'E' must be rejected, not silently aliased to 'e' via case-insensitive
// lookup (the switch normalizes to lowercase, so this has to be special-
// cased before it).
ASSERT_EQ(KLookupKCommand('E', false, id), false);
}
TEST(KKeymap_CtrlChords_CanonicalChords) TEST(KKeymap_CtrlChords_CanonicalChords)
{ {
CommandId id{}; CommandId id{};
+7 -3
View File
@@ -414,17 +414,21 @@ TEST (Migration_EmptyBufferCheck_Pattern)
TEST (Migration_SyntaxHighlighter_Pattern) TEST (Migration_SyntaxHighlighter_Pattern)
{ {
// Test the pattern used in syntax highlighters // Test the pattern used in syntax highlighters: loop Nrows(), fetch each
// line via GetLineString(row). Assert the pattern actually returns the
// right content, not just that it doesn't crash - see syntax highlighter
// correctness tests (test_syntax_highlighting.cc) for tokenization coverage.
Buffer buf; Buffer buf;
buf.insert_text(0, 0, std::string("int main() {\n return 0;\n}")); buf.insert_text(0, 0, std::string("int main() {\n return 0;\n}"));
ASSERT_EQ(buf.Nrows(), static_cast<std::size_t>(3));
const std::vector<std::string> expected = {"int main() {", " return 0;", "}"};
for (std::size_t row = 0; row < buf.Nrows(); ++row) { for (std::size_t row = 0; row < buf.Nrows(); ++row) {
// This is the pattern used in all migrated highlighters
if (row >= buf.Nrows()) { if (row >= buf.Nrows()) {
break; // Should never happen break; // Should never happen
} }
std::string line = buf.GetLineString(row); std::string line = buf.GetLineString(row);
// Successfully accessed line - size() is always valid for std::string ASSERT_EQ(line, expected[row]);
} }
} }
+104
View File
@@ -0,0 +1,104 @@
// Tests for SplitPasteIntoCommands: pasted text must turn embedded line
// breaks into Newline commands so InsertText never receives a '\r' or '\n'.
#include "Test.h"
#include "PasteSplit.h"
namespace {
// Assert that no InsertText command carries an embedded newline/carriage
// return, mirroring the rejection in Command.cc's InsertText handler.
void
assert_no_newlines_in_inserts(const std::vector<MappedInput> &cmds)
{
for (const auto &c : cmds) {
if (c.id == CommandId::InsertText) {
ASSERT_TRUE(c.arg.find('\n') == std::string::npos);
ASSERT_TRUE(c.arg.find('\r') == std::string::npos);
}
}
}
} // namespace
TEST(PasteSplit_PlainTextNoNewline)
{
auto cmds = SplitPasteIntoCommands("hello world");
ASSERT_EQ(cmds.size(), 1u);
ASSERT_TRUE(cmds[0].id == CommandId::InsertText);
ASSERT_TRUE(cmds[0].arg == "hello world");
}
TEST(PasteSplit_UnixNewlines)
{
auto cmds = SplitPasteIntoCommands("a\nb");
assert_no_newlines_in_inserts(cmds);
ASSERT_EQ(cmds.size(), 3u);
ASSERT_TRUE(cmds[0].id == CommandId::InsertText && cmds[0].arg == "a");
ASSERT_TRUE(cmds[1].id == CommandId::Newline);
ASSERT_TRUE(cmds[2].id == CommandId::InsertText && cmds[2].arg == "b");
}
// Regression: CRLF clipboards left a '\r' in the InsertText segment, which
// InsertText rejected with "InsertText arg must not contain newlines".
TEST(PasteSplit_WindowsCRLF)
{
auto cmds = SplitPasteIntoCommands("a\r\nb");
assert_no_newlines_in_inserts(cmds);
ASSERT_EQ(cmds.size(), 3u);
ASSERT_TRUE(cmds[0].id == CommandId::InsertText && cmds[0].arg == "a");
ASSERT_TRUE(cmds[1].id == CommandId::Newline);
ASSERT_TRUE(cmds[2].id == CommandId::InsertText && cmds[2].arg == "b");
}
// Regression: a bare '\r' (classic Mac / some macOS apps) must also break lines.
TEST(PasteSplit_BareCR)
{
auto cmds = SplitPasteIntoCommands("a\rb");
assert_no_newlines_in_inserts(cmds);
ASSERT_EQ(cmds.size(), 3u);
ASSERT_TRUE(cmds[0].id == CommandId::InsertText && cmds[0].arg == "a");
ASSERT_TRUE(cmds[1].id == CommandId::Newline);
ASSERT_TRUE(cmds[2].id == CommandId::InsertText && cmds[2].arg == "b");
}
TEST(PasteSplit_BlankLinesPreserved)
{
// Two line breaks between "a" and "b" => one empty line.
auto cmds = SplitPasteIntoCommands("a\n\nb");
assert_no_newlines_in_inserts(cmds);
ASSERT_EQ(cmds.size(), 4u);
ASSERT_TRUE(cmds[0].id == CommandId::InsertText && cmds[0].arg == "a");
ASSERT_TRUE(cmds[1].id == CommandId::Newline);
ASSERT_TRUE(cmds[2].id == CommandId::Newline);
ASSERT_TRUE(cmds[3].id == CommandId::InsertText && cmds[3].arg == "b");
}
TEST(PasteSplit_TrailingNewline)
{
auto cmds = SplitPasteIntoCommands("abc\n");
assert_no_newlines_in_inserts(cmds);
ASSERT_EQ(cmds.size(), 2u);
ASSERT_TRUE(cmds[0].id == CommandId::InsertText && cmds[0].arg == "abc");
ASSERT_TRUE(cmds[1].id == CommandId::Newline);
}
TEST(PasteSplit_MultilineCRLF)
{
auto cmds = SplitPasteIntoCommands("line1\r\nline2\r\nline3");
assert_no_newlines_in_inserts(cmds);
ASSERT_EQ(cmds.size(), 5u);
ASSERT_TRUE(cmds[0].arg == "line1");
ASSERT_TRUE(cmds[1].id == CommandId::Newline);
ASSERT_TRUE(cmds[2].arg == "line2");
ASSERT_TRUE(cmds[3].id == CommandId::Newline);
ASSERT_TRUE(cmds[4].arg == "line3");
}
+30
View File
@@ -101,6 +101,36 @@ TEST(SearchFlow_SearchReplace_EmptyFind_DoesNotMutateBuffer_And_ClearsState)
} }
TEST(SearchFlow_SearchReplace_EmptyWith_ReplacesAdjacentOverlappingMatches)
{
TestHarness h;
Editor &ed = h.EditorRef();
Buffer &b = h.Buf();
// "aaaa" with "aa" -> "" must remove both non-overlapping occurrences, not
// just the first: after deleting the match at column 0, the next "aa" now
// sits at column 0 too (not column 1), so the scan must resume at the
// deletion point rather than one character past it.
b.insert_text(0, 0, "aaaa\n");
b.SetCursor(0, 0);
ASSERT_TRUE(h.Exec(CommandId::SearchReplace));
ASSERT_TRUE(ed.PromptActive());
ASSERT_EQ(ed.CurrentPromptKind(), Editor::PromptKind::ReplaceFind);
ASSERT_TRUE(h.Exec(CommandId::InsertText, "aa"));
ASSERT_TRUE(h.Exec(CommandId::Newline));
ASSERT_TRUE(ed.PromptActive());
ASSERT_EQ(ed.CurrentPromptKind(), Editor::PromptKind::ReplaceWith);
// Leave the replacement empty and accept.
ASSERT_TRUE(h.Exec(CommandId::Newline));
ASSERT_TRUE(!ed.PromptActive());
ASSERT_EQ(std::string(b.Rows()[0]), std::string(""));
}
TEST(SearchFlow_RegexFind_InvalidPattern_FailsSafely_And_ClearsStateOnEnter) TEST(SearchFlow_RegexFind_InvalidPattern_FailsSafely_And_ClearsStateOnEnter)
{ {
TestHarness h; TestHarness h;
+167
View File
@@ -0,0 +1,167 @@
// test_syntax_highlighting.cc - tokenization correctness for LanguageHighlighter
// implementations, focused on multi-line state propagation (block comments,
// triple-quoted strings) that stateless-looking per-line scans easily get wrong.
#include "Test.h"
#include "Buffer.h"
#include "Highlight.h"
#include "syntax/LanguageHighlighter.h"
#include "syntax/GoHighlighter.h"
#include "syntax/RustHighlighter.h"
#include "syntax/SqlHighlighter.h"
#include "syntax/PythonHighlighter.h"
#include <string>
#include <vector>
using kte::HighlightSpan;
using kte::StatefulHighlighter;
using kte::TokenKind;
namespace {
bool
has_kind(const std::vector<HighlightSpan> &spans, TokenKind k)
{
for (const auto &sp: spans) {
if (sp.kind == k)
return true;
}
return false;
}
} // namespace
TEST (Syntax_Go_MultiLineBlockComment_PropagatesAcrossLines)
{
Buffer b;
b.replace_all_bytes("/* start\nmiddle line\nend */\nfunc foo() {}\n");
kte::GoHighlighter hl;
StatefulHighlighter::LineState state;
std::vector<HighlightSpan> spans;
// Line 0 opens an unclosed block comment; state must carry forward.
state = hl.HighlightLineStateful(b, 0, state, spans);
ASSERT_TRUE(state.in_block_comment);
// Line 1 is entirely inside the comment; the whole line must be Comment,
// not re-tokenized as code (this is the bug: without state propagation
// "middle" and "line" would come back as identifiers).
spans.clear();
state = hl.HighlightLineStateful(b, 1, state, spans);
ASSERT_TRUE(state.in_block_comment);
ASSERT_TRUE(!spans.empty());
for (const auto &sp: spans) {
ASSERT_TRUE(sp.kind == TokenKind::Comment);
}
// Line 2 closes the comment.
spans.clear();
state = hl.HighlightLineStateful(b, 2, state, spans);
ASSERT_TRUE(!state.in_block_comment);
ASSERT_TRUE(has_kind(spans, TokenKind::Comment));
// Line 3 is ordinary code again: "func" must be a Keyword, not a Comment.
spans.clear();
state = hl.HighlightLineStateful(b, 3, state, spans);
ASSERT_TRUE(!state.in_block_comment);
ASSERT_TRUE(has_kind(spans, TokenKind::Keyword));
ASSERT_TRUE(!has_kind(spans, TokenKind::Comment));
}
TEST (Syntax_Rust_MultiLineBlockComment_PropagatesAcrossLines)
{
Buffer b;
b.replace_all_bytes("/* start\nmiddle line\nend */\nfn foo() {}\n");
kte::RustHighlighter hl;
StatefulHighlighter::LineState state;
std::vector<HighlightSpan> spans;
state = hl.HighlightLineStateful(b, 0, state, spans);
ASSERT_TRUE(state.in_block_comment);
spans.clear();
state = hl.HighlightLineStateful(b, 1, state, spans);
ASSERT_TRUE(state.in_block_comment);
for (const auto &sp: spans) {
ASSERT_TRUE(sp.kind == TokenKind::Comment);
}
spans.clear();
state = hl.HighlightLineStateful(b, 2, state, spans);
ASSERT_TRUE(!state.in_block_comment);
spans.clear();
state = hl.HighlightLineStateful(b, 3, state, spans);
ASSERT_TRUE(has_kind(spans, TokenKind::Keyword));
ASSERT_TRUE(!has_kind(spans, TokenKind::Comment));
}
TEST (Syntax_Sql_MultiLineBlockComment_PropagatesAcrossLines)
{
Buffer b;
b.replace_all_bytes("/* start\nmiddle line\nend */\nSELECT 1;\n");
kte::SqlHighlighter hl;
StatefulHighlighter::LineState state;
std::vector<HighlightSpan> spans;
state = hl.HighlightLineStateful(b, 0, state, spans);
ASSERT_TRUE(state.in_block_comment);
spans.clear();
state = hl.HighlightLineStateful(b, 1, state, spans);
ASSERT_TRUE(state.in_block_comment);
for (const auto &sp: spans) {
ASSERT_TRUE(sp.kind == TokenKind::Comment);
}
spans.clear();
state = hl.HighlightLineStateful(b, 2, state, spans);
ASSERT_TRUE(!state.in_block_comment);
spans.clear();
state = hl.HighlightLineStateful(b, 3, state, spans);
ASSERT_TRUE(has_kind(spans, TokenKind::Keyword));
ASSERT_TRUE(!has_kind(spans, TokenKind::Comment));
}
TEST (Syntax_Python_TripleQuote_ClosesAndReopensOnSameLine)
{
Buffer b;
// Line 0 opens a triple-quoted string that stays open.
// Line 1 closes the first string ("end'''") and, on the SAME line, opens a
// second triple-quoted string ("'''start of") that stays open into line 2.
b.replace_all_bytes("x = '''abc\nend''' + '''start of\nnext string'''\n");
kte::PythonHighlighter hl;
StatefulHighlighter::LineState state;
std::vector<HighlightSpan> spans;
state = hl.HighlightLineStateful(b, 0, state, spans);
ASSERT_TRUE(state.in_raw_string);
ASSERT_EQ(state.raw_delim, std::string("'''"));
spans.clear();
state = hl.HighlightLineStateful(b, 1, state, spans);
// The bug: without re-scanning the remainder after the closing ''' the
// second opening ''' on this line is never noticed, so state.in_raw_string
// would incorrectly come back false here.
ASSERT_TRUE(state.in_raw_string);
ASSERT_EQ(state.raw_delim, std::string("'''"));
// Line 2 is inside the second string; it must be highlighted as String,
// not as ordinary code.
spans.clear();
state = hl.HighlightLineStateful(b, 2, state, spans);
ASSERT_TRUE(!spans.empty());
bool all_string = true;
for (const auto &sp: spans) {
if (sp.kind != TokenKind::String)
all_string = false;
}
ASSERT_TRUE(all_string);
}
+306
View File
@@ -1198,6 +1198,312 @@ TEST (Undo_Command_RedoCountSelectsBranch)
} }
TEST (Undo_Command_Newline_UndoRejoinsCorrectLines)
{
InstallDefaultCommands();
Editor ed;
ed.SetDimensions(24, 80);
Buffer b;
ed.AddBuffer(std::move(b));
Buffer *buf = ed.CurrentBuffer();
ASSERT_TRUE(buf != nullptr);
buf->replace_all_bytes("abcdef\nghijkl");
buf->SetCursor(3, 0);
ASSERT_TRUE(Execute(ed, CommandId::Newline));
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(3));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc"));
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("def"));
ASSERT_EQ(std::string(buf->Rows()[2]), std::string("ghijkl"));
ASSERT_TRUE(Execute(ed, CommandId::Undo));
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(2));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abcdef"));
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("ghijkl"));
validate_undo_tree(*buf->Undo());
}
TEST (Undo_Command_Backspace_JoinUndoRedo)
{
InstallDefaultCommands();
Editor ed;
ed.SetDimensions(24, 80);
Buffer b;
ed.AddBuffer(std::move(b));
Buffer *buf = ed.CurrentBuffer();
ASSERT_TRUE(buf != nullptr);
buf->replace_all_bytes("abc\ndef");
buf->SetCursor(0, 1);
ASSERT_TRUE(Execute(ed, CommandId::Backspace));
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(1));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abcdef"));
ASSERT_TRUE(Execute(ed, CommandId::Undo));
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(2));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc"));
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("def"));
ASSERT_TRUE(Execute(ed, CommandId::Redo));
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(1));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abcdef"));
validate_undo_tree(*buf->Undo());
}
TEST (Undo_Command_DeleteChar_JoinUndoRedo)
{
InstallDefaultCommands();
Editor ed;
ed.SetDimensions(24, 80);
Buffer b;
ed.AddBuffer(std::move(b));
Buffer *buf = ed.CurrentBuffer();
ASSERT_TRUE(buf != nullptr);
buf->replace_all_bytes("abc\ndef");
buf->SetCursor(3, 0);
ASSERT_TRUE(Execute(ed, CommandId::DeleteChar));
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(1));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abcdef"));
ASSERT_TRUE(Execute(ed, CommandId::Undo));
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(2));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc"));
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("def"));
ASSERT_TRUE(Execute(ed, CommandId::Redo));
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(1));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abcdef"));
validate_undo_tree(*buf->Undo());
}
TEST (Undo_Command_RegexReplaceAll_UndoRedo)
{
InstallDefaultCommands();
Editor ed;
ed.SetDimensions(24, 80);
Buffer b;
ed.AddBuffer(std::move(b));
Buffer *buf = ed.CurrentBuffer();
ASSERT_TRUE(buf != nullptr);
buf->replace_all_bytes("foo one\nfoo two\nbar three");
buf->SetCursor(0, 0);
ASSERT_TRUE(Execute(ed, CommandId::RegexpReplace));
ASSERT_TRUE(ed.PromptActive());
ed.SetPromptText("foo");
ASSERT_TRUE(Execute(ed, CommandId::Newline));
ASSERT_TRUE(ed.PromptActive());
ed.SetPromptText("baz");
ASSERT_TRUE(Execute(ed, CommandId::Newline));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("baz one"));
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("baz two"));
ASSERT_EQ(std::string(buf->Rows()[2]), std::string("bar three"));
ASSERT_TRUE(Execute(ed, CommandId::Undo));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("foo one"));
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("foo two"));
ASSERT_EQ(std::string(buf->Rows()[2]), std::string("bar three"));
ASSERT_TRUE(Execute(ed, CommandId::Redo));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("baz one"));
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("baz two"));
ASSERT_EQ(std::string(buf->Rows()[2]), std::string("bar three"));
validate_undo_tree(*buf->Undo());
}
TEST (Undo_Command_IndentUnindentRegion_UndoRedo)
{
InstallDefaultCommands();
Editor ed;
ed.SetDimensions(24, 80);
Buffer b;
ed.AddBuffer(std::move(b));
Buffer *buf = ed.CurrentBuffer();
ASSERT_TRUE(buf != nullptr);
buf->replace_all_bytes("one\ntwo\nthree");
buf->SetMark(0, 0);
buf->SetCursor(0, 2);
ASSERT_TRUE(Execute(ed, CommandId::IndentRegion));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("\tone"));
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("\ttwo"));
ASSERT_EQ(std::string(buf->Rows()[2]), std::string("\tthree"));
ASSERT_TRUE(Execute(ed, CommandId::Undo));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("one"));
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("two"));
ASSERT_EQ(std::string(buf->Rows()[2]), std::string("three"));
ASSERT_TRUE(Execute(ed, CommandId::Redo));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("\tone"));
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("\ttwo"));
ASSERT_EQ(std::string(buf->Rows()[2]), std::string("\tthree"));
buf->SetMark(0, 0);
buf->SetCursor(0, 2);
ASSERT_TRUE(Execute(ed, CommandId::UnindentRegion));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("one"));
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("two"));
ASSERT_EQ(std::string(buf->Rows()[2]), std::string("three"));
ASSERT_TRUE(Execute(ed, CommandId::Undo));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("\tone"));
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("\ttwo"));
ASSERT_EQ(std::string(buf->Rows()[2]), std::string("\tthree"));
validate_undo_tree(*buf->Undo());
}
TEST (Undo_Command_KillToEol_UndoRedo)
{
InstallDefaultCommands();
Editor ed;
ed.SetDimensions(24, 80);
Buffer b;
ed.AddBuffer(std::move(b));
Buffer *buf = ed.CurrentBuffer();
ASSERT_TRUE(buf != nullptr);
buf->replace_all_bytes("abcdef\nghijkl");
buf->SetCursor(3, 0);
ASSERT_TRUE(Execute(ed, CommandId::KillToEOL));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc"));
ASSERT_TRUE(Execute(ed, CommandId::Undo));
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(2));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abcdef"));
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("ghijkl"));
ASSERT_TRUE(Execute(ed, CommandId::Redo));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc"));
validate_undo_tree(*buf->Undo());
}
TEST (Undo_Command_KillLine_UndoRedo)
{
InstallDefaultCommands();
Editor ed;
ed.SetDimensions(24, 80);
Buffer b;
ed.AddBuffer(std::move(b));
Buffer *buf = ed.CurrentBuffer();
ASSERT_TRUE(buf != nullptr);
buf->replace_all_bytes("abc\ndef\nghi");
buf->SetCursor(0, 1);
ASSERT_TRUE(Execute(ed, CommandId::KillLine));
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(2));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc"));
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("ghi"));
ASSERT_TRUE(Execute(ed, CommandId::Undo));
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(3));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc"));
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("def"));
ASSERT_EQ(std::string(buf->Rows()[2]), std::string("ghi"));
ASSERT_TRUE(Execute(ed, CommandId::Redo));
ASSERT_EQ(buf->Nrows(), static_cast<std::size_t>(2));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc"));
ASSERT_EQ(std::string(buf->Rows()[1]), std::string("ghi"));
validate_undo_tree(*buf->Undo());
}
TEST (Undo_Command_KillRegion_UndoRedo)
{
InstallDefaultCommands();
Editor ed;
ed.SetDimensions(24, 80);
Buffer b;
ed.AddBuffer(std::move(b));
Buffer *buf = ed.CurrentBuffer();
ASSERT_TRUE(buf != nullptr);
buf->replace_all_bytes("abc def ghi");
buf->SetMark(4, 0);
buf->SetCursor(8, 0);
ASSERT_TRUE(Execute(ed, CommandId::KillRegion));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc ghi"));
ASSERT_TRUE(Execute(ed, CommandId::Undo));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc def ghi"));
ASSERT_TRUE(Execute(ed, CommandId::Redo));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc ghi"));
validate_undo_tree(*buf->Undo());
}
TEST (Undo_Command_DeleteWordPrevNext_UndoRedo)
{
InstallDefaultCommands();
Editor ed;
ed.SetDimensions(24, 80);
Buffer b;
ed.AddBuffer(std::move(b));
Buffer *buf = ed.CurrentBuffer();
ASSERT_TRUE(buf != nullptr);
buf->replace_all_bytes("abc def ghi");
buf->SetCursor(8, 0);
ASSERT_TRUE(Execute(ed, CommandId::DeleteWordPrev));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc ghi"));
ASSERT_TRUE(Execute(ed, CommandId::Undo));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc def ghi"));
buf->SetCursor(4, 0);
ASSERT_TRUE(Execute(ed, CommandId::DeleteWordNext));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc ghi"));
ASSERT_TRUE(Execute(ed, CommandId::Undo));
ASSERT_EQ(std::string(buf->Rows()[0]), std::string("abc def ghi"));
validate_undo_tree(*buf->Undo());
}
TEST (Undo_InsertRow_UndoDeletesRow) TEST (Undo_InsertRow_UndoDeletesRow)
{ {
Buffer b; Buffer b;