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:
37
.github/workflows/release.yml
vendored
Normal file
37
.github/workflows/release.yml
vendored
Normal 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
61
.idea/workspace.xml
generated
@@ -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 />
|
||||
|
||||
57
Buffer.cc
57
Buffer.cc
@@ -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)
|
||||
{
|
||||
|
||||
4
Buffer.h
4
Buffer.h
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
473
Command.cc
473
Command.cc
@@ -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};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
28
KKeymap.cc
28
KKeymap.cc
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
34
TestFrontend.cc
Normal 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
38
TestInputHandler.cc
Normal 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
10
TestRenderer.cc
Normal file
@@ -0,0 +1,10 @@
|
||||
#include "TestRenderer.h"
|
||||
#include "Editor.h"
|
||||
|
||||
|
||||
void
|
||||
TestRenderer::Draw(Editor &ed)
|
||||
{
|
||||
(void) ed;
|
||||
++draw_count_;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
105
docs/TestFrontend.md
Normal 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
|
||||
12
docs/ke.md
12
docs/ke.md
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
7331
fonts/brassmono.h
7331
fonts/brassmono.h
File diff suppressed because it is too large
Load Diff
102
test_undo.cc
Normal file
102
test_undo.cc
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user