Add TestFrontend documentation and UndoSystem buffer reference update.

- Document `TestFrontend` for programmatic testing, including examples and usage details.
- Add `UpdateBufferReference` to `UndoSystem` to support updating buffer associations.
This commit is contained in:
2025-11-30 02:56:39 -08:00
parent 91bc986e51
commit 8c8e4e59a4
21 changed files with 5889 additions and 2531 deletions

37
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch: { }
permissions:
contents: write
jobs:
homebrew:
name: Bump Homebrew formula
# Skip this job in case of git pushes to prerelease tags
if: ${{ github.event_name != 'push' || !contains(github.ref, '-') }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Extract version
id: extract-version
run: |
echo "tag-name=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- uses: mislav/bump-homebrew-formula-action@v3
with:
formula-name: kte
formula-path: Formula/kte.rb
homebrew-tap: kisom/homebrew-tap
base-branch: master
commit-message: |
{{formulaName}} {{version}}
Created by https://github.com/mislav/bump-homebrew-formula-action
env:
COMMITTER_TOKEN: ${{ secrets.GH_CPAT }}

61
.idea/workspace.xml generated
View File

@@ -25,6 +25,7 @@
<config projectName="kte" targetName="kte" />
<config projectName="kte" targetName="imgui" />
<config projectName="kte" targetName="kge" />
<config projectName="kte" targetName="test_undo" />
</generated>
</component>
<component name="CMakeSettings" AUTO_RELOAD="true">
@@ -33,21 +34,26 @@
</configurations>
</component>
<component name="ChangeListManager">
<list default="true" id="e1fe3ab0-3650-4fca-8664-a247d5dfa457" name="Changes" comment="Add `UndoSystem` implementation and refactor `UndoNode` for simplicity.">
<change beforePath="$PROJECT_DIR$/.idea/kte.iml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/kte.iml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/misc.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/misc.xml" afterDir="false" />
<list default="true" id="e1fe3ab0-3650-4fca-8664-a247d5dfa457" name="Changes" comment="Add non-linear undo/redo design documentation and improve `UndoSystem` with backspace batching and GUI integration fixes.">
<change afterPath="$PROJECT_DIR$/TestFrontend.cc" afterDir="false" />
<change afterPath="$PROJECT_DIR$/TestInputHandler.cc" afterDir="false" />
<change afterPath="$PROJECT_DIR$/TestRenderer.cc" afterDir="false" />
<change afterPath="$PROJECT_DIR$/docs/TestFrontend.md" afterDir="false" />
<change afterPath="$PROJECT_DIR$/test_undo.cc" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Buffer.cc" beforeDir="false" afterPath="$PROJECT_DIR$/Buffer.cc" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Buffer.h" beforeDir="false" afterPath="$PROJECT_DIR$/Buffer.h" afterDir="false" />
<change beforePath="$PROJECT_DIR$/CMakeLists.txt" beforeDir="false" afterPath="$PROJECT_DIR$/CMakeLists.txt" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Command.cc" beforeDir="false" afterPath="$PROJECT_DIR$/Command.cc" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Command.h" beforeDir="false" afterPath="$PROJECT_DIR$/Command.h" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Editor.cc" beforeDir="false" afterPath="$PROJECT_DIR$/Editor.cc" afterDir="false" />
<change beforePath="$PROJECT_DIR$/GUIFrontend.cc" beforeDir="false" afterPath="$PROJECT_DIR$/GUIFrontend.cc" afterDir="false" />
<change beforePath="$PROJECT_DIR$/GUIInputHandler.cc" beforeDir="false" afterPath="$PROJECT_DIR$/GUIInputHandler.cc" afterDir="false" />
<change beforePath="$PROJECT_DIR$/GUIInputHandler.h" beforeDir="false" afterPath="$PROJECT_DIR$/GUIInputHandler.h" afterDir="false" />
<change beforePath="$PROJECT_DIR$/KKeymap.cc" beforeDir="false" afterPath="$PROJECT_DIR$/KKeymap.cc" afterDir="false" />
<change beforePath="$PROJECT_DIR$/TerminalInputHandler.cc" beforeDir="false" afterPath="$PROJECT_DIR$/TerminalInputHandler.cc" afterDir="false" />
<change beforePath="$PROJECT_DIR$/TerminalInputHandler.h" beforeDir="false" afterPath="$PROJECT_DIR$/TerminalInputHandler.h" afterDir="false" />
<change beforePath="$PROJECT_DIR$/UndoSystem.cc" beforeDir="false" afterPath="$PROJECT_DIR$/UndoSystem.cc" afterDir="false" />
<change beforePath="$PROJECT_DIR$/UndoSystem.h" beforeDir="false" afterPath="$PROJECT_DIR$/UndoSystem.h" afterDir="false" />
<change beforePath="$PROJECT_DIR$/docs/ke.md" beforeDir="false" afterPath="$PROJECT_DIR$/docs/ke.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/docs/undo-state.md" beforeDir="false" afterPath="$PROJECT_DIR$/docs/undo-state.md" afterDir="false" />
<change beforePath="$PROJECT_DIR$/fonts/brassmono.h" beforeDir="false" afterPath="$PROJECT_DIR$/fonts/brassmono.h" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -63,9 +69,20 @@
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
<option name="UPDATE_TYPE" value="REBASE" />
</component>
<component name="HighlightingSettingsPerFile">
<setting file="mock:///AIAssistantSnippet.." root0="SKIP_HIGHLIGHTING" />
<setting file="mock:///AIAssistantSnippet.." root0="SKIP_HIGHLIGHTING" />
<setting file="mock:///AIAssistantSnippet.." root0="SKIP_HIGHLIGHTING" />
<setting file="mock:///dummy.cpp" root0="SKIP_HIGHLIGHTING" />
<setting file="mock:///dummy.cpp" root0="SKIP_HIGHLIGHTING" />
<setting file="mock:///dummy.cpp" root0="SKIP_HIGHLIGHTING" />
<setting file="mock:///dummy.cpp" root0="SKIP_HIGHLIGHTING" />
<setting file="mock:///dummy.cpp" root0="SKIP_HIGHLIGHTING" />
<setting file="mock:///dummy.cpp" root0="SKIP_HIGHLIGHTING" />
<setting file="mock:///dummy.cpp" root0="SKIP_HIGHLIGHTING" />
<setting file="mock:///dummy.cpp" root0="SKIP_HIGHLIGHTING" />
</component>
<component name="OptimizeOnSaveOptions">
<option name="myRunOnSave" value="true" />
@@ -80,6 +97,9 @@
"associatedIndex": 3
}]]></component>
<component name="ProjectId" id="36AlI8oyQOzOwSuZg6WxXf5LbHb" />
<component name="ProjectLevelVcsManager">
<OptionsSetting value="false" id="Update" />
</component>
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
@@ -88,7 +108,9 @@
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"CMake Application.kge.executor": "Debug",
"CMake Application.kge.executor": "Run",
"CMake Application.test_example.executor": "Run",
"CMake Application.test_undo.executor": "Run",
"ModuleVcsDetector.initialDetectionPerformed": "true",
"NIXITCH_NIXPKGS_CONFIG": "",
"NIXITCH_NIX_CONF_DIR": "",
@@ -125,7 +147,7 @@
<recent name="$PROJECT_DIR$/docs" />
</key>
</component>
<component name="RunManager" selected="CMake Application.kte">
<component name="RunManager" selected="CMake Application.test_undo">
<configuration default="true" type="CMakeRunConfiguration" factoryName="Application" REDIRECT_INPUT="false" ELEVATE="false" USE_EXTERNAL_CONSOLE="false" EMULATE_TERMINAL="false" PASS_PARENT_ENVS_2="true">
<method v="2">
<option name="com.jetbrains.cidr.execution.CidrBuildBeforeRunTaskProvider$BuildBeforeRunTask" enabled="true" />
@@ -146,10 +168,16 @@
<option name="com.jetbrains.cidr.execution.CidrBuildBeforeRunTaskProvider$BuildBeforeRunTask" enabled="true" />
</method>
</configuration>
<configuration name="test_undo" type="CMakeRunConfiguration" factoryName="Application" REDIRECT_INPUT="false" ELEVATE="false" USE_EXTERNAL_CONSOLE="false" EMULATE_TERMINAL="false" PASS_PARENT_ENVS_2="true" PROJECT_NAME="kte" TARGET_NAME="test_undo" CONFIG_NAME="Debug" RUN_TARGET_PROJECT_NAME="kte" RUN_TARGET_NAME="test_undo">
<method v="2">
<option name="com.jetbrains.cidr.execution.CidrBuildBeforeRunTaskProvider$BuildBeforeRunTask" enabled="true" />
</method>
</configuration>
<list>
<item itemvalue="CMake Application.imgui" />
<item itemvalue="CMake Application.kge" />
<item itemvalue="CMake Application.kte" />
<item itemvalue="CMake Application.test_undo" />
</list>
</component>
<component name="TaskManager">
@@ -159,7 +187,7 @@
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1764457173148</updated>
<workItem from="1764457174208" duration="37384000" />
<workItem from="1764457174208" duration="41399000" />
</task>
<task id="LOCAL-00001" summary="Add undo/redo infrastructure and buffer management additions.">
<option name="closed" value="true" />
@@ -193,7 +221,15 @@
<option name="project" value="LOCAL" />
<updated>1764489870957</updated>
</task>
<option name="localTasksCounter" value="5" />
<task id="LOCAL-00005" summary="Add non-linear undo/redo design documentation and improve `UndoSystem` with backspace batching and GUI integration fixes.">
<option name="closed" value="true" />
<created>1764496151303</created>
<option name="number" value="00005" />
<option name="presentableId" value="LOCAL-00005" />
<option name="project" value="LOCAL" />
<updated>1764496151303</updated>
</task>
<option name="localTasksCounter" value="6" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -211,7 +247,8 @@
<MESSAGE value="Handle end-of-file newline semantics and improve scroll alignment logic." />
<MESSAGE value="Enable installation targets." />
<MESSAGE value="Add `UndoSystem` implementation and refactor `UndoNode` for simplicity." />
<option name="LAST_COMMIT_MESSAGE" value="Add `UndoSystem` implementation and refactor `UndoNode` for simplicity." />
<MESSAGE value="Add non-linear undo/redo design documentation and improve `UndoSystem` with backspace batching and GUI integration fixes." />
<option name="LAST_COMMIT_MESSAGE" value="Add non-linear undo/redo design documentation and improve `UndoSystem` with backspace batching and GUI integration fixes." />
</component>
<component name="XSLT-Support.FileAssociations.UIState">
<expand />

View File

@@ -69,6 +69,63 @@ Buffer::operator=(const Buffer &other)
}
// Move constructor: move all fields and update UndoSystem's buffer reference
Buffer::Buffer(Buffer &&other) noexcept
: curx_(other.curx_),
cury_(other.cury_),
rx_(other.rx_),
nrows_(other.nrows_),
rowoffs_(other.rowoffs_),
coloffs_(other.coloffs_),
rows_(std::move(other.rows_)),
filename_(std::move(other.filename_)),
is_file_backed_(other.is_file_backed_),
dirty_(other.dirty_),
mark_set_(other.mark_set_),
mark_curx_(other.mark_curx_),
mark_cury_(other.mark_cury_),
undo_tree_(std::move(other.undo_tree_)),
undo_sys_(std::move(other.undo_sys_))
{
// Update UndoSystem's buffer reference to point to this object
if (undo_sys_) {
undo_sys_->UpdateBufferReference(*this);
}
}
// Move assignment: move all fields and update UndoSystem's buffer reference
Buffer &
Buffer::operator=(Buffer &&other) noexcept
{
if (this == &other)
return *this;
curx_ = other.curx_;
cury_ = other.cury_;
rx_ = other.rx_;
nrows_ = other.nrows_;
rowoffs_ = other.rowoffs_;
coloffs_ = other.coloffs_;
rows_ = std::move(other.rows_);
filename_ = std::move(other.filename_);
is_file_backed_ = other.is_file_backed_;
dirty_ = other.dirty_;
mark_set_ = other.mark_set_;
mark_curx_ = other.mark_curx_;
mark_cury_ = other.mark_cury_;
undo_tree_ = std::move(other.undo_tree_);
undo_sys_ = std::move(other.undo_sys_);
// Update UndoSystem's buffer reference to point to this object
if (undo_sys_) {
undo_sys_->UpdateBufferReference(*this);
}
return *this;
}
bool
Buffer::OpenFromFile(const std::string &path, std::string &err)
{

View File

@@ -20,9 +20,9 @@ public:
Buffer &operator=(const Buffer &other);
Buffer(Buffer &&) noexcept = default;
Buffer(Buffer &&other) noexcept;
Buffer &operator=(Buffer &&) noexcept = default;
Buffer &operator=(Buffer &&other) noexcept;
explicit Buffer(const std::string &path);

View File

@@ -10,6 +10,7 @@ set(KTE_VERSION "0.0.1")
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
set(BUILD_GUI OFF CACHE BOOL "Enable building the graphical version.")
option(KTE_USE_PIECE_TABLE "Use PieceTable instead of GapBuffer implementation" OFF)
set(KTE_FONT_SIZE "18.0" CACHE STRING "Default font size for GUI")
if (CMAKE_HOST_UNIX)
message(STATUS "Build system is POSIX.")
@@ -55,6 +56,9 @@ set(COMMON_SOURCES
TerminalInputHandler.cc
TerminalRenderer.cc
TerminalFrontend.cc
TestInputHandler.cc
TestRenderer.cc
TestFrontend.cc
UndoNode.cc
UndoTree.cc
UndoSystem.cc
@@ -74,6 +78,9 @@ set(COMMON_HEADERS
TerminalRenderer.h
Frontend.h
TerminalFrontend.h
TestInputHandler.h
TestRenderer.h
TestFrontend.h
UndoNode.h
UndoTree.h
UndoSystem.h
@@ -96,6 +103,19 @@ install(TARGETS kte
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
)
# test_undo executable for testing undo/redo system
add_executable(test_undo
test_undo.cc
${COMMON_SOURCES}
${COMMON_HEADERS}
)
if (KTE_USE_PIECE_TABLE)
target_compile_definitions(test_undo PRIVATE KTE_USE_PIECE_TABLE=1)
endif ()
target_link_libraries(test_undo ${CURSES_LIBRARIES})
if (${BUILD_GUI})
target_sources(kte PRIVATE
Font.h
@@ -119,7 +139,7 @@ if (${BUILD_GUI})
GUIInputHandler.h
GUIFrontend.cc
GUIFrontend.h)
target_compile_definitions(kge PRIVATE KTE_BUILD_GUI=1 KTE_DEFAULT_GUI=1)
target_compile_definitions(kge PRIVATE KTE_BUILD_GUI=1 KTE_DEFAULT_GUI=1 KTE_FONT_SIZE=${KTE_FONT_SIZE})
target_link_libraries(kge ${CURSES_LIBRARIES} imgui)
install(TARGETS kge

View File

@@ -426,8 +426,10 @@ cmd_save(CommandContext &ctx)
ctx.editor.SetStatus("Saved " + buf->Filename());
return true;
}
ctx.editor.SetStatus("Buffer is not file-backed; use save-as");
return false;
// If buffer has no name, prompt for a filename
ctx.editor.StartPrompt(Editor::PromptKind::SaveAs, "Save as", "");
ctx.editor.SetStatus("Save as: ");
return true;
}
if (!buf->Save(err)) {
ctx.editor.SetStatus(err);
@@ -738,11 +740,6 @@ cmd_insert_text(CommandContext &ctx)
ctx.editor.SetStatus("No buffer to edit");
return false;
}
// Start/extend an insert batch for undo
if (auto *u = buf->Undo()) {
u->Begin(UndoType::Insert);
u->Append(std::string_view(ctx.arg));
}
// If a prompt is active, edit prompt text
if (ctx.editor.PromptActive()) {
// Special-case: buffer switch prompt supports Tab-completion
@@ -859,8 +856,15 @@ cmd_insert_text(CommandContext &ctx)
rows[y].insert(x, ctx.arg);
x += ctx.arg.size();
}
buf->SetCursor(x, y);
buf->SetDirty(true);
// Record undo after buffer modification but before cursor update
if (auto *u = buf->Undo()) {
u->Begin(UndoType::Insert);
for (int i = 0; i < repeat; ++i) {
u->Append(std::string_view(ctx.arg));
}
}
buf->SetCursor(x, y);
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
@@ -922,8 +926,24 @@ cmd_newline(CommandContext &ctx)
+ (cur ? buffer_display_name(*cur) : std::string("")));
}
} else if (kind == Editor::PromptKind::SaveAs) {
// Optional: not wired yet
ctx.editor.SetStatus("Save As not implemented");
if (value.empty()) {
ctx.editor.SetStatus("Save canceled (empty filename)");
} else {
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf) {
ctx.editor.SetStatus("No buffer to save");
} else {
std::string err;
if (!buf->SaveAs(value, err)) {
ctx.editor.SetStatus(err);
} else {
buf->SetDirty(false);
ctx.editor.SetStatus("Saved as " + value);
if (auto *u = buf->Undo())
u->mark_saved();
}
}
}
}
return true;
}
@@ -943,10 +963,6 @@ cmd_newline(CommandContext &ctx)
ctx.editor.SetStatus("No buffer to edit");
return false;
}
// Start a newline batch for undo at current cursor
if (auto *u = buf->Undo()) {
u->Begin(UndoType::Newline);
}
ensure_at_least_one_line(*buf);
auto &rows = buf->Rows();
std::size_t y = buf->Cury();
@@ -967,6 +983,11 @@ cmd_newline(CommandContext &ctx)
}
buf->SetCursor(x, y);
buf->SetDirty(true);
// Record newline after buffer modification; commit immediately for single-step undo
if (auto *u = buf->Undo()) {
u->Begin(UndoType::Newline);
u->commit();
}
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
@@ -1017,26 +1038,27 @@ cmd_backspace(CommandContext &ctx)
int repeat = ctx.count > 0 ? ctx.count : 1;
for (int i = 0; i < repeat; ++i) {
if (x > 0) {
// Batch contiguous character deletes (backspace)
if (u)
u->Begin(UndoType::Delete);
// Delete character before cursor
char deleted = rows[y][x - 1];
rows[y].erase(x - 1, 1);
--x;
if (u)
// Record undo after deletion and cursor update
if (u) {
u->Begin(UndoType::Delete);
u->Append(deleted);
}
} else if (y > 0) {
// join with previous line
std::size_t prev_len = rows[y - 1].size();
if (u) {
// Record a newline deletion that joined lines; commit immediately
u->Begin(UndoType::Newline);
u->commit();
}
rows[y - 1] += rows[y];
rows.erase(rows.begin() + static_cast<std::ptrdiff_t>(y));
y = y - 1;
x = prev_len;
// Record a newline deletion that joined lines; commit immediately
if (u) {
u->Begin(UndoType::Newline);
u->commit();
}
} else {
// at very start; nothing to do
break;
@@ -1067,22 +1089,23 @@ cmd_delete_char(CommandContext &ctx)
if (y >= rows.size())
break;
if (x < rows[y].size()) {
// Forward delete at cursor, batch contiguous
if (u)
u->Begin(UndoType::Delete);
// Forward delete at cursor
char deleted = rows[y][x];
rows[y].erase(x, 1);
if (u)
// Record undo after deletion (cursor stays at same position)
if (u) {
u->Begin(UndoType::Delete);
u->Append(deleted);
}
} else if (y + 1 < rows.size()) {
// join next line
rows[y] += rows[y + 1];
rows.erase(rows.begin() + static_cast<std::ptrdiff_t>(y + 1));
// Record newline deletion at end of this line; commit immediately
if (u) {
// Record newline deletion at end of this line; commit immediately
u->Begin(UndoType::Newline);
u->commit();
}
rows[y] += rows[y + 1];
rows.erase(rows.begin() + static_cast<std::ptrdiff_t>(y + 1));
} else {
break;
}
@@ -1812,6 +1835,373 @@ cmd_word_next(CommandContext &ctx)
}
static bool
cmd_delete_word_prev(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
if (auto *u = buf->Undo())
u->commit();
ensure_at_least_one_line(*buf);
auto &rows = buf->Rows();
std::size_t y = buf->Cury();
std::size_t x = buf->Curx();
int repeat = ctx.count > 0 ? ctx.count : 1;
std::string killed_total;
for (int i = 0; i < repeat; ++i) {
if (y >= rows.size()) {
y = rows.empty() ? 0 : rows.size() - 1;
x = rows[y].size();
}
std::size_t start_y = y;
std::size_t start_x = x;
// If at start of line and not first line, move to end of previous line
if (x == 0) {
if (y == 0)
break;
--y;
x = rows[y].size();
}
// Move left one first
if (x > 0)
--x;
// Skip any whitespace leftwards
while (y < rows.size() && (x > 0 || (x == 0 && y > 0))) {
if (x == 0) {
--y;
x = rows[y].size();
if (x == 0)
continue;
}
unsigned char c = x > 0 ? static_cast<unsigned char>(rows[y][x - 1]) : 0;
if (!std::isspace(c))
break;
--x;
}
// Skip word characters leftwards
while (y < rows.size() && (x > 0 || (x == 0 && y > 0))) {
if (x == 0)
break;
unsigned char c = static_cast<unsigned char>(rows[y][x - 1]);
if (!is_word_char(c))
break;
--x;
}
// Now delete from (x, y) to (start_x, start_y)
std::string deleted;
if (y == start_y) {
// same line
if (x < start_x) {
deleted = rows[y].substr(x, start_x - x);
rows[y].erase(x, start_x - x);
}
} else {
// spans multiple lines
// First, collect text from (x, y) to end of line y
deleted = rows[y].substr(x);
rows[y].erase(x);
// Then collect complete lines between y and start_y
for (std::size_t ly = y + 1; ly < start_y; ++ly) {
deleted += "\n";
deleted += rows[ly];
}
// Finally, collect from beginning of start_y to start_x
if (start_y < rows.size()) {
deleted += "\n";
deleted += rows[start_y].substr(0, start_x);
rows[y] += rows[start_y].substr(start_x);
// Remove lines from y+1 to start_y inclusive
rows.erase(rows.begin() + static_cast<std::ptrdiff_t>(y + 1),
rows.begin() + static_cast<std::ptrdiff_t>(start_y + 1));
}
}
// Prepend to killed_total (since we're deleting backwards)
killed_total = deleted + killed_total;
}
buf->SetCursor(x, y);
buf->SetDirty(true);
ensure_cursor_visible(ctx.editor, *buf);
if (!killed_total.empty()) {
if (ctx.editor.KillChain())
ctx.editor.KillRingAppend(killed_total);
else
ctx.editor.KillRingPush(killed_total);
ctx.editor.SetKillChain(true);
}
return true;
}
static bool
cmd_delete_word_next(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
if (auto *u = buf->Undo())
u->commit();
ensure_at_least_one_line(*buf);
auto &rows = buf->Rows();
std::size_t y = buf->Cury();
std::size_t x = buf->Curx();
int repeat = ctx.count > 0 ? ctx.count : 1;
std::string killed_total;
for (int i = 0; i < repeat; ++i) {
if (y >= rows.size())
break;
std::size_t start_y = y;
std::size_t start_x = x;
// First, if currently on a word, skip to its end
while (y < rows.size()) {
if (x < rows[y].size() && is_word_char(static_cast<unsigned char>(rows[y][x]))) {
++x;
continue;
}
if (x >= rows[y].size()) {
if (y + 1 >= rows.size())
break;
++y;
x = 0;
continue;
}
break;
}
// Then, skip any non-word characters (including punctuation and whitespace)
while (y < rows.size()) {
if (x < rows[y].size()) {
unsigned char c = static_cast<unsigned char>(rows[y][x]);
if (is_word_char(c))
break;
++x;
continue;
}
if (x >= rows[y].size()) {
if (y + 1 >= rows.size())
break;
++y;
x = 0;
continue;
}
}
// Now delete from (start_x, start_y) to (x, y)
std::string deleted;
if (start_y == y) {
// same line
if (start_x < x) {
deleted = rows[y].substr(start_x, x - start_x);
rows[y].erase(start_x, x - start_x);
x = start_x;
}
} else {
// spans multiple lines
// First, collect text from start_x to end of line start_y
deleted = rows[start_y].substr(start_x);
rows[start_y].erase(start_x);
// Then collect complete lines between start_y and y
for (std::size_t ly = start_y + 1; ly < y; ++ly) {
deleted += "\n";
deleted += rows[ly];
}
// Finally, collect from beginning of y to x
if (y < rows.size()) {
deleted += "\n";
deleted += rows[y].substr(0, x);
rows[start_y] += rows[y].substr(x);
// Remove lines from start_y+1 to y inclusive
rows.erase(rows.begin() + static_cast<std::ptrdiff_t>(start_y + 1),
rows.begin() + static_cast<std::ptrdiff_t>(y + 1));
}
y = start_y;
x = start_x;
}
killed_total += deleted;
}
buf->SetCursor(x, y);
buf->SetDirty(true);
ensure_cursor_visible(ctx.editor, *buf);
if (!killed_total.empty()) {
if (ctx.editor.KillChain())
ctx.editor.KillRingAppend(killed_total);
else
ctx.editor.KillRingPush(killed_total);
ctx.editor.SetKillChain(true);
}
return true;
}
static bool
cmd_indent_region(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
if (!buf->MarkSet()) {
ctx.editor.SetStatus("No mark set");
return false;
}
std::size_t sx, sy, ex, ey;
if (!compute_mark_region(*buf, sx, sy, ex, ey)) {
ctx.editor.SetStatus("No region to indent");
return false;
}
auto &rows = buf->Rows();
for (std::size_t y = sy; y <= ey && y < rows.size(); ++y) {
rows[y].insert(0, "\t");
}
buf->SetDirty(true);
buf->ClearMark();
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
static bool
cmd_unindent_region(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
if (!buf->MarkSet()) {
ctx.editor.SetStatus("No mark set");
return false;
}
std::size_t sx, sy, ex, ey;
if (!compute_mark_region(*buf, sx, sy, ex, ey)) {
ctx.editor.SetStatus("No region to unindent");
return false;
}
auto &rows = buf->Rows();
for (std::size_t y = sy; y <= ey && y < rows.size(); ++y) {
auto &line = rows[y];
if (!line.empty()) {
if (line[0] == '\t') {
line.erase(0, 1);
} else if (line[0] == ' ') {
std::size_t spaces = 0;
while (spaces < line.size() && spaces < 8 && line[spaces] == ' ') {
++spaces;
}
if (spaces > 0)
line.erase(0, spaces);
}
}
}
buf->SetDirty(true);
buf->ClearMark();
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
static bool
cmd_reflow_paragraph(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
ensure_at_least_one_line(*buf);
auto &rows = buf->Rows();
std::size_t y = buf->Cury();
int width = ctx.count > 0 ? ctx.count : 72;
std::size_t para_start = y;
while (para_start > 0 && !rows[para_start - 1].empty())
--para_start;
std::size_t para_end = y;
while (para_end + 1 < rows.size() && !rows[para_end + 1].empty())
++para_end;
if (para_start > para_end)
return false;
std::string text;
for (std::size_t i = para_start; i <= para_end; ++i) {
if (i > para_start && !text.empty() && text.back() != ' ')
text += ' ';
const auto &line = rows[i];
for (std::size_t j = 0; j < line.size(); ++j) {
char c = line[j];
if (c == '\t')
text += ' ';
else
text += c;
}
}
std::vector<std::string> new_lines;
std::string line;
std::size_t pos = 0;
while (pos < text.size()) {
while (pos < text.size() && text[pos] == ' ')
++pos;
if (pos >= text.size())
break;
std::size_t word_start = pos;
while (pos < text.size() && text[pos] != ' ')
++pos;
std::string word = text.substr(word_start, pos - word_start);
if (line.empty()) {
line = word;
} else if (static_cast<int>(line.size() + 1 + word.size()) <= width) {
line += ' ';
line += word;
} else {
new_lines.push_back(line);
line = word;
}
}
if (!line.empty())
new_lines.push_back(line);
if (new_lines.empty())
new_lines.push_back("");
rows.erase(rows.begin() + static_cast<std::ptrdiff_t>(para_start),
rows.begin() + static_cast<std::ptrdiff_t>(para_end + 1));
rows.insert(rows.begin() + static_cast<std::ptrdiff_t>(para_start),
new_lines.begin(), new_lines.end());
buf->SetCursor(0, para_start);
buf->SetDirty(true);
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
static bool
cmd_reload_buffer(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
const std::string &filename = buf->Filename();
if (filename.empty()) {
ctx.editor.SetStatus("Cannot reload unnamed buffer");
return false;
}
std::string err;
if (!buf->OpenFromFile(filename, err)) {
ctx.editor.SetStatus(std::string("Reload failed: ") + err);
return false;
}
ctx.editor.SetStatus(std::string("Reloaded ") + filename);
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
static bool
cmd_mark_all_and_jump_end(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
ensure_at_least_one_line(*buf);
buf->SetMark(0, 0);
auto &rows = buf->Rows();
std::size_t last_y = rows.empty() ? 0 : rows.size() - 1;
std::size_t last_x = last_y < rows.size() ? rows[last_y].size() : 0;
buf->SetCursor(last_x, last_y);
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
std::vector<Command> &
CommandRegistry::storage_()
{
@@ -1927,12 +2317,33 @@ InstallDefaultCommands()
CommandRegistry::Register({CommandId::PageDown, "page-down", "Page down", cmd_page_down});
CommandRegistry::Register({CommandId::WordPrev, "word-prev", "Move to previous word", cmd_word_prev});
CommandRegistry::Register({CommandId::WordNext, "word-next", "Move to next word", cmd_word_next});
CommandRegistry::Register({
CommandId::DeleteWordPrev, "delete-word-prev", "Delete previous word", cmd_delete_word_prev
});
CommandRegistry::Register({
CommandId::DeleteWordNext, "delete-word-next", "Delete next word", cmd_delete_word_next
});
CommandRegistry::Register({
CommandId::MoveCursorTo, "move-cursor-to", "Move cursor to y:x", cmd_move_cursor_to
});
// Undo/Redo
CommandRegistry::Register({CommandId::Undo, "undo", "Undo last edit", cmd_undo});
CommandRegistry::Register({CommandId::Redo, "redo", "Redo edit", cmd_redo});
// Region formatting
CommandRegistry::Register({CommandId::IndentRegion, "indent-region", "Indent region", cmd_indent_region});
CommandRegistry::Register(
{CommandId::UnindentRegion, "unindent-region", "Unindent region", cmd_unindent_region});
CommandRegistry::Register({
CommandId::ReflowParagraph, "reflow-paragraph", "Reflow paragraph to column width", cmd_reflow_paragraph
});
// Buffer operations
CommandRegistry::Register({
CommandId::ReloadBuffer, "reload-buffer", "Reload buffer from disk", cmd_reload_buffer
});
CommandRegistry::Register({
CommandId::MarkAllAndJumpEnd, "mark-all-jump-end", "Set mark at beginning and jump to end",
cmd_mark_all_and_jump_end
});
// UI helpers
CommandRegistry::Register(
{CommandId::UArgStatus, "uarg-status", "Update universal-arg status", cmd_uarg_status});
@@ -1952,7 +2363,7 @@ Execute(Editor &ed, CommandId id, const std::string &arg, int count)
}
// Reset kill chain unless this is a kill-like command (so consecutive kills append)
if (id != CommandId::KillToEOL && id != CommandId::KillLine && id != CommandId::KillRegion && id !=
CommandId::CopyRegion) {
CommandId::CopyRegion && id != CommandId::DeleteWordPrev && id != CommandId::DeleteWordNext) {
ed.SetKillChain(false);
}
CommandContext ctx{ed, arg, count};

View File

@@ -56,6 +56,8 @@ enum class CommandId {
PageDown,
WordPrev,
WordNext,
DeleteWordPrev, // delete previous word (ESC BACKSPACE)
DeleteWordNext, // delete next word (ESC d)
// Direct cursor placement
MoveCursorTo, // arg: "y:x" (zero-based row:col)
// Undo/Redo
@@ -63,6 +65,13 @@ enum class CommandId {
Redo,
// UI/status helpers
UArgStatus, // update status line during universal-argument collection
// Region formatting
IndentRegion, // indent region (C-k =)
UnindentRegion, // unindent region (C-k -)
ReflowParagraph, // reflow paragraph to column width (ESC q)
// Buffer operations
ReloadBuffer, // reload buffer from disk (C-k l)
MarkAllAndJumpEnd, // set mark at beginning, jump to end (C-k a)
// Meta
UnknownKCommand, // arg: single character that was not recognized after C-k
};

View File

@@ -65,7 +65,10 @@ GUIFrontend::Init(Editor &ed)
height_ = h;
// Initialize GUI font from embedded default
LoadGuiFont_(nullptr, 16.f);
#ifndef KTE_FONT_SIZE
#define KTE_FONT_SIZE 16.0f
#endif
LoadGuiFont_(nullptr, (float) KTE_FONT_SIZE);
return true;
}

View File

@@ -447,8 +447,8 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
// Unknown k-command via TEXTINPUT path
int shown = KLowerAscii(ascii_key);
char c = (shown >= 0x20 && shown <= 0x7e)
? static_cast<char>(shown)
: '?';
? static_cast<char>(shown)
: '?';
std::string arg(1, c);
mi = {true, CommandId::UnknownKCommand, arg, 0};
produced = true;

View File

@@ -1,4 +1,5 @@
#include "KKeymap.h"
#include <ncurses.h>
auto
@@ -27,8 +28,8 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
}
// 2) Case-sensitive bindings must be checked before case-insensitive table.
if (ascii_key == 'U') {
out = CommandId::Redo; // C-k U (redo)
if (ascii_key == 'r') {
out = CommandId::Redo; // C-k r (redo)
return true;
}
@@ -73,6 +74,18 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
case 'u':
out = CommandId::Undo;
return true; // C-k u (undo)
case '-':
out = CommandId::UnindentRegion;
return true; // C-k - (unindent region)
case '=':
out = CommandId::IndentRegion;
return true; // C-k = (indent region)
case 'l':
out = CommandId::ReloadBuffer;
return true; // C-k l (reload buffer)
case 'a':
out = CommandId::MarkAllAndJumpEnd;
return true; // C-k a (mark all and jump to end)
default:
break;
}
@@ -135,6 +148,11 @@ auto
KLookupEscCommand(const int ascii_key, CommandId &out) -> bool
{
const int k = KLowerAscii(ascii_key);
// Handle KEY_BACKSPACE (ESC BACKSPACE, Alt-Backspace)
if (ascii_key == KEY_BACKSPACE) {
out = CommandId::DeleteWordPrev;
return true;
}
switch (k) {
case '<':
out = CommandId::MoveFileStart; // Esc <
@@ -154,6 +172,12 @@ KLookupEscCommand(const int ascii_key, CommandId &out) -> bool
case 'f':
out = CommandId::WordNext;
return true;
case 'd':
out = CommandId::DeleteWordNext; // Esc d (Alt-d)
return true;
case 'q':
out = CommandId::ReflowParagraph; // Esc q (reflow paragraph)
return true;
default:
break;
}

View File

@@ -199,19 +199,16 @@ map_key_to_command(const int ch,
out = {true, CommandId::Newline, "", 0};
return true;
}
// Backspace in ncurses can be KEY_BACKSPACE or 127
if (ch == KEY_BACKSPACE || ch == 127 || ch == CTRL('H')) {
k_prefix = false;
out = {true, CommandId::Backspace, "", 0};
return true;
}
// If previous key was ESC, interpret as meta and use ESC keymap
if (esc_meta) {
esc_meta = false;
int ascii_key = ch;
if (ascii_key >= 'A' && ascii_key <= 'Z')
// Handle ESC + BACKSPACE (meta-backspace, Alt-Backspace)
if (ch == KEY_BACKSPACE || ch == 127 || ch == CTRL('H')) {
ascii_key = KEY_BACKSPACE; // normalized value for lookup
} else if (ascii_key >= 'A' && ascii_key <= 'Z') {
ascii_key = ascii_key - 'A' + 'a';
}
CommandId id;
if (KLookupEscCommand(ascii_key, id)) {
out = {true, id, "", 0};
@@ -222,6 +219,13 @@ map_key_to_command(const int ch,
return true;
}
// Backspace in ncurses can be KEY_BACKSPACE or 127
if (ch == KEY_BACKSPACE || ch == 127 || ch == CTRL('H')) {
k_prefix = false;
out = {true, CommandId::Backspace, "", 0};
return true;
}
// k_prefix handled earlier
// If collecting universal arg, handle digits and optional leading '-'

34
TestFrontend.cc Normal file
View File

@@ -0,0 +1,34 @@
#include "TestFrontend.h"
#include "Editor.h"
#include "Command.h"
#include <iostream>
bool
TestFrontend::Init(Editor &ed)
{
ed.SetDimensions(24, 80);
return true;
}
void
TestFrontend::Step(Editor &ed, bool &running)
{
MappedInput mi;
if (input_.Poll(mi)) {
if (mi.hasCommand) {
Execute(ed, mi.id, mi.arg, mi.count);
}
}
if (ed.QuitRequested()) {
running = false;
}
renderer_.Draw(ed);
}
void
TestFrontend::Shutdown() {}

38
TestInputHandler.cc Normal file
View File

@@ -0,0 +1,38 @@
#include "TestInputHandler.h"
bool
TestInputHandler::Poll(MappedInput &out)
{
if (queue_.empty())
return false;
out = queue_.front();
queue_.pop();
return true;
}
void
TestInputHandler::QueueCommand(CommandId id, const std::string &arg, int count)
{
MappedInput mi;
mi.hasCommand = true;
mi.id = id;
mi.arg = arg;
mi.count = count;
queue_.push(mi);
}
void
TestInputHandler::QueueText(const std::string &text)
{
for (char ch: text) {
MappedInput mi;
mi.hasCommand = true;
mi.id = CommandId::InsertText;
mi.arg = std::string(1, ch);
mi.count = 0;
queue_.push(mi);
}
}

10
TestRenderer.cc Normal file
View File

@@ -0,0 +1,10 @@
#include "TestRenderer.h"
#include "Editor.h"
void
TestRenderer::Draw(Editor &ed)
{
(void) ed;
++draw_count_;
}

View File

@@ -3,15 +3,15 @@
UndoSystem::UndoSystem(Buffer &owner, UndoTree &tree)
: buf_(owner), tree_(tree) {}
: buf_(&owner), tree_(tree) {}
void
UndoSystem::Begin(UndoType type)
{
// Reuse pending if batching conditions are met
const int row = static_cast<int>(buf_.Cury());
const int col = static_cast<int>(buf_.Curx());
const int row = static_cast<int>(buf_->Cury());
const int col = static_cast<int>(buf_->Curx());
if (tree_.pending && tree_.pending->type == type && tree_.pending->row == row) {
if (type == UndoType::Delete) {
// Support batching both forward deletes (DeleteChar) and backspace (prepend case)
@@ -187,30 +187,30 @@ UndoSystem::apply(const UndoNode *node, int direction)
case UndoType::Insert:
case UndoType::Paste:
if (direction > 0) {
buf_.insert_text(node->row, node->col, node->text);
buf_->insert_text(node->row, node->col, node->text);
} else {
buf_.delete_text(node->row, node->col, node->text.size());
buf_->delete_text(node->row, node->col, node->text.size());
}
break;
case UndoType::Delete:
if (direction > 0) {
buf_.delete_text(node->row, node->col, node->text.size());
buf_->delete_text(node->row, node->col, node->text.size());
} else {
buf_.insert_text(node->row, node->col, node->text);
buf_->insert_text(node->row, node->col, node->text);
}
break;
case UndoType::Newline:
if (direction > 0) {
buf_.split_line(node->row, node->col);
buf_->split_line(node->row, node->col);
} else {
buf_.join_lines(node->row);
buf_->join_lines(node->row);
}
break;
case UndoType::DeleteRow:
if (direction > 0) {
buf_.delete_row(node->row);
buf_->delete_row(node->row);
} else {
buf_.insert_row(node->row, node->text);
buf_->insert_row(node->row, node->text);
}
break;
}
@@ -284,5 +284,12 @@ UndoSystem::update_dirty_flag()
{
// dirty if current != saved
bool dirty = (tree_.current != tree_.saved);
buf_.SetDirty(dirty);
buf_->SetDirty(dirty);
}
void
UndoSystem::UpdateBufferReference(Buffer &new_buf)
{
buf_ = &new_buf;
}

View File

@@ -28,6 +28,8 @@ public:
void clear();
void UpdateBufferReference(Buffer &new_buf);
private:
void apply(const UndoNode *node, int direction); // +1 redo, -1 undo
void free_node(UndoNode *node);
@@ -38,7 +40,7 @@ private:
void update_dirty_flag();
private:
Buffer &buf_;
Buffer *buf_;
UndoTree &tree_;
// Internal hint for Delete batching: whether next Append() should prepend
bool pending_prepend_ = false;

105
docs/TestFrontend.md Normal file
View File

@@ -0,0 +1,105 @@
# TestFrontend - Headless Frontend for Testing
## Overview
`TestFrontend` is a headless implementation of the `Frontend` interface designed to facilitate programmatic testing of editor features. It allows you to queue commands and text input manually, execute them step-by-step, and inspect the editor/buffer state.
## Components
### TestInputHandler
A programmable input handler that uses a queue-based system:
- `QueueCommand(CommandId id, const std::string &arg = "", int count = 0)` - Queue a specific command
- `QueueText(const std::string &text)` - Queue text for insertion (character by character)
- `Poll(MappedInput &out)` - Returns queued commands one at a time
- `IsEmpty()` - Check if the input queue is empty
### TestRenderer
A minimal no-op renderer for testing:
- `Draw(Editor &ed)` - No-op implementation, just increments draw counter
- `GetDrawCount()` - Returns the number of times Draw() was called
- `ResetDrawCount()` - Resets the draw counter
### TestFrontend
The main frontend class that integrates TestInputHandler and TestRenderer:
- `Init(Editor &ed)` - Initializes the frontend (sets editor dimensions to 24x80)
- `Step(Editor &ed, bool &running)` - Processes one command from the queue and renders
- `Shutdown()` - Cleanup (no-op for TestFrontend)
- `Input()` - Access the TestInputHandler
- `Renderer()` - Access the TestRenderer
## Usage Example
```cpp
#include "Editor.h"
#include "TestFrontend.h"
#include "Command.h"
int main() {
// IMPORTANT: Install default commands first!
InstallDefaultCommands();
Editor editor;
TestFrontend frontend;
// Initialize
frontend.Init(editor);
// Setup: create a buffer (open a file or add buffer)
std::string err;
if (!editor.OpenFile("/tmp/test.txt", err)) {
return 1;
}
// Queue some commands
frontend.Input().QueueText("Hello");
frontend.Input().QueueCommand(CommandId::Newline);
frontend.Input().QueueText("World");
// Execute queued commands
bool running = true;
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
// Inspect results
Buffer *buf = editor.CurrentBuffer();
if (buf && buf->Rows().size() >= 2) {
std::cout << "Line 1: " << std::string(buf->Rows()[0]) << "\n";
std::cout << "Line 2: " << std::string(buf->Rows()[1]) << "\n";
}
frontend.Shutdown();
return 0;
}
```
## Key Features
1. **Programmable Input**: Queue any sequence of commands or text programmatically
2. **Step-by-Step Execution**: Run the editor one command at a time
3. **State Inspection**: Access and verify editor/buffer state between commands
4. **No UI Dependencies**: Headless operation, no terminal or GUI required
5. **Integration Testing**: Test command sequences, undo/redo, multi-line editing, etc.
## Available Commands
All commands from `CommandId` enum can be queued, including:
- `CommandId::InsertText` - Insert text (use `QueueText()` helper)
- `CommandId::Newline` - Insert newline
- `CommandId::Backspace` - Delete character before cursor
- `CommandId::DeleteChar` - Delete character at cursor
- `CommandId::MoveLeft`, `MoveRight`, `MoveUp`, `MoveDown` - Cursor movement
- `CommandId::Undo`, `CommandId::Redo` - Undo/redo operations
- `CommandId::Save`, `CommandId::Quit` - File operations
- And many more (see Command.h)
## Integration
TestFrontend is built into both `kte` and `kge` executables as part of the common source files. You can create standalone test programs by linking against the same source files and ncurses.
## Notes
- Always call `InstallDefaultCommands()` before using any commands
- Buffer must be initialized (via `OpenFile()` or `AddBuffer()`) before queuing edit commands
- Undo/redo requires the buffer to have an UndoSystem attached
- The test frontend sets editor dimensions to 24x80 by default

View File

@@ -14,7 +14,7 @@ someone's writeup of the process of writing a text editor from scratch.
It has keybindings inspired by VDE (and the Wordstar family) and emacs;
its spiritual parent is mg(1).
## KEYBINDINGS
## KEYBINDINGS
K-command mode is entered using C-k. This is taken from
Wordstar and just so happens to be blessed with starting with a most
@@ -30,6 +30,8 @@ k-command mode can be exited with ESC or C-g.
* C-k SPACE: Toggle the mark.
* C-k -: If the mark is set, unindent the region.
* C-k =: If the mark is set, indent the region.
* C-k a: Set the mark at the beginning of the file, then jump to the end of
the file.
* C-k b: Switch to a buffer.
* C-k c: Close the current buffer. If no other buffers are open, an empty
buffer will be opened. To exit, use C-k q.
@@ -39,16 +41,16 @@ k-command mode can be exited with ESC or C-g.
* C-k f: Flush the kill ring.
* C-k g: Go to a specific line.
* C-k j: Jump to the mark.
* C-k l: List the number of lines of code in a saved file.
* C-k l: Reload the current buffer from disk.
* C-k m: Run make(1), reporting success or failure.
* C-k p: Switch to the next buffer.
* C-k q: Exit the editor. If the file has unsaved changes, a warning will
be printed; a second C-k q will exit.
* C-k C-q: Immediately exit the editor.
* C-k C-r: Reload the current buffer from disk.
* C-k l: Reload the current buffer from disk.
* C-k s: Save the file, prompting for a filename if needed.
* C-k u: Undo.
* C-k U: Redo changes (not implemented; marking this k-command as taken).
* C-k r: Redo changes.
* C-k x: save the file and exit. Also C-k C-x.
* C-k y: Yank the kill ring.
* C-k \\: Dump core.
@@ -67,6 +69,8 @@ k-command mode can be exited with ESC or C-g.
* ESC b: Move to the previous word.
* ESC d: Delete the next word.
* ESC f: Move to the next word.
* ESC q: Reflow the paragraph to 72 columns or the value of the universal
argument.
* ESC w: Save the region (if the mark is set) to the kill ring.
## FIND

View File

@@ -93,3 +93,36 @@ Owner pointers & file locations
- Undo batching entry points: Command.cc (cmd_insert_text, cmd_backspace, cmd_delete_char, cmd_newline)
End of snapshot — safe to resume from here.
---
RESOLUTION (2025-11-30)
Root Cause Identified and Fixed
The undo system failure was caused by incorrect timing of UndoSystem::Begin() and Append() calls relative to buffer modifications in Command.cc.
Problem:
- In cmd_insert_text, cmd_backspace, cmd_delete_char, and cmd_newline, the undo recording (Begin/Append) was called BEFORE the actual buffer modification and cursor update.
- UndoSystem::Begin() checks the current cursor position to determine if it can batch with the pending node.
- For Insert type: Begin() checks if col == pending->col + pending->text.size()
- For Delete type: Begin() checks if the cursor is at the expected position based on whether it's forward delete or backspace
- When Begin/Append were called before cursor updates, the batching condition would fail on the second character because the cursor hadn't moved yet from the first insertion.
- This caused each character to create a separate batch, but since commit() was never called between characters (only at k-prefix or undo), the pending node would be overwritten rather than committed, resulting in no undo history.
Fix Applied:
- cmd_insert_text: Moved Begin/Append to AFTER buffer insertion (lines 854-856) and cursor update (line 857).
- cmd_backspace: Moved Begin/Append to AFTER character deletion (lines 1024-1025) and cursor decrement (line 1026).
- cmd_delete_char: Moved Begin/Append to AFTER character deletion (lines 1074-1076).
- cmd_newline: Moved Begin/commit to AFTER line split (lines 956-966) and cursor update (lines 963-967).
Result:
- Begin() now sees the correct cursor position after each edit, allowing proper batching of consecutive characters.
- Typing "Hello" will now create a single pending batch with all 5 characters that can be undone as one unit.
- The fix applies to both terminal (kte) and GUI (kge) builds.
Testing Recommendation:
- Type several characters (e.g., "Hello")
- Press C-k u to undo - the entire word should disappear
- Press C-k U to redo - the word should reappear
- Test backspace batching: type several characters, then backspace multiple times, then undo - should undo the backspace batch
- Test delete batching similarly

File diff suppressed because it is too large Load Diff

102
test_undo.cc Normal file
View File

@@ -0,0 +1,102 @@
#include <iostream>
#include <cassert>
#include <fstream>
#include "Editor.h"
#include "TestFrontend.h"
#include "Command.h"
#include "Buffer.h"
int
main()
{
// Install default commands
InstallDefaultCommands();
Editor editor;
TestFrontend frontend;
// Initialize frontend
if (!frontend.Init(editor)) {
std::cerr << "Failed to initialize frontend\n";
return 1;
}
// Create a temporary test file
std::string err;
const char *tmpfile = "/tmp/kte_test_undo.txt";
{
std::ofstream f(tmpfile);
if (!f) {
std::cerr << "Failed to create temp file\n";
return 1;
}
f << "\n"; // Write one newline so file isn't empty
f.close();
}
if (!editor.OpenFile(tmpfile, err)) {
std::cerr << "Failed to open test file: " << err << "\n";
return 1;
}
Buffer *buf = editor.CurrentBuffer();
assert(buf != nullptr);
// Initialize cursor to (0,0) explicitly
buf->SetCursor(0, 0);
std::cout << "test_undo: Testing undo/redo system\n";
std::cout << "====================================\n\n";
bool running = true;
// Test 1: Insert text and verify buffer contains expected text
std::cout << "Test 1: Insert text 'Hello'\n";
frontend.Input().QueueText("Hello");
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(buf->Rows().size() >= 1);
std::string line_after_insert = std::string(buf->Rows()[0]);
assert(line_after_insert == "Hello");
std::cout << " Buffer content: '" << line_after_insert << "'\n";
std::cout << " ✓ Text insertion verified\n\n";
// Test 2: Undo insertion - text should be removed
std::cout << "Test 2: Undo insertion\n";
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(buf->Rows().size() >= 1);
std::string line_after_undo = std::string(buf->Rows()[0]);
assert(line_after_undo == "");
std::cout << " Buffer content: '" << line_after_undo << "'\n";
std::cout << " ✓ Undo successful - text removed\n\n";
// Test 3: Redo insertion - text should be restored
std::cout << "Test 3: Redo insertion\n";
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(buf->Rows().size() >= 1);
std::string line_after_redo = std::string(buf->Rows()[0]);
assert(line_after_redo == "Hello");
std::cout << " Buffer content: '" << line_after_redo << "'\n";
std::cout << " ✓ Redo successful - text restored\n\n";
frontend.Shutdown();
std::cout << "====================================\n";
std::cout << "All tests passed!\n";
return 0;
}