Compare commits

..
1 Commits
Author SHA1 Message Date
kyleandClaude Opus 4.8 e2b255901e 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:03 -07:00
30 changed files with 128 additions and 1219 deletions
-22
View File
@@ -236,7 +236,6 @@ Buffer::Buffer(const Buffer &other)
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>();
@@ -288,7 +287,6 @@ Buffer::operator=(const Buffer &other)
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>();
@@ -329,9 +327,6 @@ 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_))
{ {
@@ -340,17 +335,10 @@ Buffer::Buffer(Buffer &&other) noexcept
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);
@@ -379,9 +367,6 @@ Buffer::operator=(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_);
@@ -390,17 +375,10 @@ Buffer::operator=(Buffer &&other) noexcept
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,25 +548,6 @@ 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;
@@ -692,7 +673,6 @@ 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
+2 -29
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.12.0") set(KTE_VERSION "1.11.3")
# 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,34 +71,8 @@ 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
@@ -370,7 +344,6 @@ 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 tests/test_paste_split.cc
# minimal engine sources required by Buffer # minimal engine sources required by Buffer
+39 -165
View File
@@ -159,27 +159,6 @@ 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)
@@ -285,12 +264,8 @@ 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)
@@ -307,14 +282,7 @@ 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:
@@ -335,25 +303,11 @@ 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));
} }
@@ -363,12 +317,6 @@ 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);
@@ -960,10 +908,6 @@ 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)
@@ -1033,7 +977,6 @@ 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()) {
@@ -1041,7 +984,6 @@ 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") {
@@ -2567,10 +2509,12 @@ cmd_newline(CommandContext &ctx)
} }
pos = p + with.size(); pos = p + with.size();
} else { } else {
// Replacing with empty leaves nothing inserted at the deletion // When replacing with empty, continue after the deletion point to avoid re-matching
// 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;
} }
@@ -2979,28 +2923,14 @@ 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;
} }
} }
@@ -3073,28 +3003,18 @@ 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;
} }
@@ -3278,9 +3198,7 @@ cmd_backspace(CommandContext &ctx)
x = prev_len; x = prev_len;
buf->SetCursor(x, y); buf->SetCursor(x, y);
if (u) { if (u) {
// Forward action here is a join, not a split: JoinLines has the u->Begin(UndoType::Newline);
// correct (inverted) apply() semantics, unlike Newline.
u->Begin(UndoType::JoinLines);
u->commit(); u->commit();
} }
} else { } else {
@@ -3359,9 +3277,7 @@ 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) {
// Forward action here is a join, not a split: JoinLines has the u->Begin(UndoType::Newline);
// correct (inverted) apply() semantics, unlike Newline.
u->Begin(UndoType::JoinLines);
u->commit(); u->commit();
} }
} else { } else {
@@ -3434,32 +3350,18 @@ 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
std::string seg = static_cast<std::string>(rows_view[y].substr(x)); killed_total += 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
@@ -3493,37 +3395,20 @@ 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
std::string content = static_cast<std::string>(rows_view[0]); killed_total += static_cast<std::string>(rows_view[0]);
killed_total += content; if (!rows_view[0].empty())
if (!content.empty()) { buf->delete_text(0, 0, rows_view[0].size());
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
std::string content = static_cast<std::string>(rows_view[y]); killed_total += 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()) {
@@ -3737,10 +3622,7 @@ 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())
@@ -4352,7 +4234,6 @@ 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;
@@ -4394,7 +4275,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, buf->Undo()); delete_region(*buf, x, y, start_x, start_y);
// 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;
} }
@@ -4426,7 +4307,6 @@ 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;
@@ -4466,7 +4346,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, buf->Undo()); delete_region(*buf, start_x, start_y, x, y);
y = start_y; y = start_y;
x = start_x; x = start_x;
killed_total += deleted; killed_total += deleted;
@@ -4500,16 +4380,8 @@ 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();
@@ -4533,8 +4405,6 @@ 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())
@@ -4543,26 +4413,13 @@ 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();
}
}
} }
} }
} }
@@ -4579,8 +4436,25 @@ 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.
UndoGroupGuard guard(buf->Undo()); GroupGuard 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);
+1 -40
View File
@@ -165,23 +165,9 @@ 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()));
} }
@@ -196,19 +182,8 @@ 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()));
} }
@@ -520,22 +495,8 @@ 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()) {
-18
View File
@@ -44,12 +44,6 @@ 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);
@@ -475,21 +469,9 @@ 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) {
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); target->input.ProcessSDLEvent(e);
} }
} }
}
if (!running) if (!running)
return; return;
+5 -21
View File
@@ -10,19 +10,6 @@
#include "Editor.h" #include "Editor.h"
#include "PasteSplit.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
map_key(const SDL_Keycode key, map_key(const SDL_Keycode key,
@@ -196,19 +183,18 @@ 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)
: '?'; : '?';
IH_LOGF( std::fprintf(stderr,
"[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)
@@ -519,17 +505,15 @@ 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) char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e)
? static_cast<char>(ascii_key) ? static_cast<char>(ascii_key)
: '?'; : '?';
IH_LOGF("[kge] k-prefix TEXTINPUT suffix: ascii=%d '%c' mapped=%d id=%d\n", std::fprintf(stderr,
"[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_)
+5 -9
View File
@@ -1,4 +1,6 @@
#include <iostream>
#include <ncurses.h> #include <ncurses.h>
#include <ostream>
#include "KKeymap.h" #include "KKeymap.h"
@@ -38,15 +40,6 @@ 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':
@@ -73,6 +66,9 @@ 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;
-37
View File
@@ -268,43 +268,6 @@ 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,15 +94,6 @@ 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);
+10 -45
View File
@@ -1,6 +1,4 @@
#include <cstdio> #include <cstdio>
#include <climits>
#include <cwchar>
#include <ncurses.h> #include <ncurses.h>
#include "TerminalInputHandler.h" #include "TerminalInputHandler.h"
@@ -14,22 +12,6 @@ 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;
@@ -39,7 +21,6 @@ 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,
@@ -47,13 +28,9 @@ map_key_to_command(const int ch,
Editor *ed, Editor *ed,
MappedInput &out) MappedInput &out)
{ {
// Handle special keys from ncurses. These are only meaningful when // Handle special keys from ncurses
// 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 (is_keycode ? ch : -1) { switch (ch) {
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;
@@ -282,7 +259,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 ((is_keycode && ch == KEY_BACKSPACE) || ch == 127 || ch == CTRL('H')) { if (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 '<'
@@ -304,7 +281,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 ((is_keycode && ch == KEY_BACKSPACE) || ch == 127 || ch == CTRL('H')) { if (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};
@@ -320,20 +297,11 @@ map_key_to_command(const int ch,
return true; return true;
} }
// Printable character: ASCII, or (for a regular decoded wide character, // Printable ASCII
// not a keycode) any other printable Unicode codepoint - e.g. accented if (ch >= 0x20 && ch <= 0x7E) {
// 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 = mb; out.arg.assign(1, static_cast<char>(ch));
out.count = 0; out.count = 0;
return true; return true;
} }
@@ -346,15 +314,12 @@ map_key_to_command(const int ch,
bool bool
TerminalInputHandler::decode_(MappedInput &out) TerminalInputHandler::decode_(MappedInput &out)
{ {
wint_t wch; int ch = getch();
int ret = get_wch(&wch); if (ch == ERR) {
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, is_keycode, ch,
k_prefix_, esc_meta_, k_prefix_, esc_meta_,
k_ctrl_pending_, k_ctrl_pending_,
mouse_selecting_, mouse_selecting_,
-6
View File
@@ -10,12 +10,6 @@ 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 {
+1 -14
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 || type == UndoType::JoinLines); UndoType::InsertRow);
if (always_standalone) { if (always_standalone) {
commit(); commit();
} }
@@ -77,7 +77,6 @@ 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;
} }
} }
@@ -326,16 +325,6 @@ 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;
} }
} }
@@ -435,8 +424,6 @@ 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 "?";
} }
+2 -23
View File
@@ -1,24 +1,3 @@
// Undo logic is implemented in UndoSystem; this file only owns node lifetime. // Placeholder translation unit for UndoTree struct definition.
// 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,11 +7,4 @@ 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();
}; };
+2 -14
View File
@@ -292,20 +292,6 @@ 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 */
@@ -334,6 +320,8 @@ 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();
+2 -35
View File
@@ -46,45 +46,14 @@ 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 state; return;
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];
@@ -113,8 +82,7 @@ GoHighlighter::HighlightLineStateful(const Buffer &buf,
} }
if (!closed) { if (!closed) {
push(out, i, n, TokenKind::Comment); push(out, i, n, TokenKind::Comment);
state.in_block_comment = true; break;
return state;
} else { } else {
push(out, i, j, TokenKind::Comment); push(out, i, j, TokenKind::Comment);
i = j; i = j;
@@ -184,6 +152,5 @@ GoHighlighter::HighlightLineStateful(const Buffer &buf,
push(out, i, i + 1, TokenKind::Default); push(out, i, i + 1, TokenKind::Default);
++i; ++i;
} }
return state;
} }
} // namespace kte } // namespace kte
+1 -6
View File
@@ -5,17 +5,12 @@
#include <unordered_set> #include <unordered_set>
namespace kte { namespace kte {
class GoHighlighter final : public StatefulHighlighter { class GoHighlighter final : public LanguageHighlighter {
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_;
+1 -27
View File
@@ -74,22 +74,7 @@ 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_) {
@@ -124,9 +109,6 @@ 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
@@ -157,14 +139,6 @@ 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;
}
} }
+13 -8
View File
@@ -55,26 +55,31 @@ 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())); int end = static_cast<int>(pos + static_cast<int>(state.raw_delim.size()));
push(out, 0, end, TokenKind::String); 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.in_raw_string = false;
state.raw_delim.clear(); state.raw_delim.clear();
// Resume the normal tokenizer at the closing delimiter's end, on the // Continue parsing remainder as a separate small loop
// same (unmodified) `s`/`n`, so anything after it - including a new int base = end;
// triple-quoted string opening on this same line - is re-scanned // original offset, but we already emitted to 'out' with base=0; following spans should be from 'end'
// instead of being dumped into a single opaque Default span. // For simplicity, mark rest as Default
i = end; if (n > 0)
push(out, base, base + n, TokenKind::Default);
return state;
}
} }
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];
+2 -35
View File
@@ -47,42 +47,11 @@ 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 state; return;
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') {
@@ -110,8 +79,7 @@ RustHighlighter::HighlightLineStateful(const Buffer &buf,
} }
if (!closed) { if (!closed) {
push(out, i, n, TokenKind::Comment); push(out, i, n, TokenKind::Comment);
state.in_block_comment = true; break;
return state;
} else { } else {
push(out, i, j, TokenKind::Comment); push(out, i, j, TokenKind::Comment);
i = j; i = j;
@@ -172,6 +140,5 @@ RustHighlighter::HighlightLineStateful(const Buffer &buf,
push(out, i, i + 1, TokenKind::Default); push(out, i, i + 1, TokenKind::Default);
++i; ++i;
} }
return state;
} }
} // namespace kte } // namespace kte
+1 -6
View File
@@ -5,17 +5,12 @@
#include <unordered_set> #include <unordered_set>
namespace kte { namespace kte {
class RustHighlighter final : public StatefulHighlighter { class RustHighlighter final : public LanguageHighlighter {
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_;
+3 -35
View File
@@ -47,42 +47,12 @@ 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 state; return;
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') {
@@ -98,7 +68,7 @@ SqlHighlighter::HighlightLineStateful(const Buffer &buf,
push(out, i, n, TokenKind::Comment); push(out, i, n, TokenKind::Comment);
break; break;
} }
// block comment: /* ... */ (may span multiple lines) // simple block comment on same line: /* ... */
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;
@@ -112,8 +82,7 @@ SqlHighlighter::HighlightLineStateful(const Buffer &buf,
} }
if (!closed) { if (!closed) {
push(out, i, n, TokenKind::Comment); push(out, i, n, TokenKind::Comment);
state.in_block_comment = true; break;
return state;
} else { } else {
push(out, i, j, TokenKind::Comment); push(out, i, j, TokenKind::Comment);
i = j; i = j;
@@ -182,6 +151,5 @@ SqlHighlighter::HighlightLineStateful(const Buffer &buf,
push(out, i, i + 1, TokenKind::Default); push(out, i, i + 1, TokenKind::Default);
++i; ++i;
} }
return state;
} }
} // namespace kte } // namespace kte
+1 -6
View File
@@ -5,17 +5,12 @@
#include <unordered_set> #include <unordered_set>
namespace kte { namespace kte {
class SqlHighlighter final : public StatefulHighlighter { class SqlHighlighter final : public LanguageHighlighter {
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,26 +108,3 @@ 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,21 +37,6 @@ 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{};
+3 -7
View File
@@ -414,21 +414,17 @@ TEST (Migration_EmptyBufferCheck_Pattern)
TEST (Migration_SyntaxHighlighter_Pattern) TEST (Migration_SyntaxHighlighter_Pattern)
{ {
// Test the pattern used in syntax highlighters: loop Nrows(), fetch each // Test the pattern used in syntax highlighters
// 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);
ASSERT_EQ(line, expected[row]); // Successfully accessed line - size() is always valid for std::string
} }
} }
-30
View File
@@ -101,36 +101,6 @@ 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
@@ -1,167 +0,0 @@
// 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,312 +1198,6 @@ 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;