11 Commits

Author SHA1 Message Date
d98785e825 bump
Some checks failed
Release / Bump Homebrew formula (push) Has been cancelled
Release / Build Linux amd64 (push) Has been cancelled
Release / Build Linux arm64 (push) Has been cancelled
Release / Build macOS arm64 (.app) (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
2025-12-01 12:00:52 -08:00
970a31e0d9 Add Nord theme for real 2025-12-01 12:00:10 -08:00
464ad8d1ae Nord theme and undo system refinements
- Improve text input/event batching
- Enhance debugging with optional instrumentation
- Begin implementation of non-linear undo tree structure.
2025-12-01 11:59:51 -08:00
0cb7d36f2a bump version
Some checks failed
Release / Bump Homebrew formula (push) Has been cancelled
Release / Build Linux amd64 (push) Has been cancelled
Release / Build Linux arm64 (push) Has been cancelled
Release / Build macOS arm64 (.app) (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
2025-12-01 11:22:47 -08:00
09a6df0c33 Add regex search, search/replace, and buffer read-only mode functionality with help text 2025-12-01 02:54:40 -08:00
69457c424c add regex and search/replace functionality to editor 2025-11-30 23:33:17 -08:00
24c8040d8a trash flake-gui 2025-11-30 22:02:43 -08:00
e869249a7c thaaaaanks jeremy 2025-11-30 22:02:23 -08:00
35e957b326 Fix void crash in kge.
Some checks failed
Release / Bump Homebrew formula (push) Has been cancelled
Release / Build Linux amd64 (push) Has been cancelled
Release / Build Linux arm64 (push) Has been cancelled
Release / Build macOS arm64 (.app) (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
2025-11-30 21:51:03 -08:00
e7eb35626c NixOS build 2025-11-30 21:07:41 -08:00
f9128a336d better support for smaller screens
Some checks failed
Release / Bump Homebrew formula (push) Has been cancelled
Release / Build Linux amd64 (push) Has been cancelled
Release / Build Linux arm64 (push) Has been cancelled
Release / Build macOS arm64 (.app) (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
editor_prompt should replace the status line when active
2025-11-30 20:25:53 -08:00
29 changed files with 2575 additions and 831 deletions

2
.gitignore vendored
View File

@@ -1,3 +1,5 @@
!.idea !.idea
cmake-build* cmake-build*
build build
/imgui.ini
result

139
.idea/workspace.xml generated
View File

@@ -33,9 +33,8 @@
</configurations> </configurations>
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="e1fe3ab0-3650-4fca-8664-a247d5dfa457" name="Changes" comment="Actually add the screenshot."> <list default="true" id="e1fe3ab0-3650-4fca-8664-a247d5dfa457" name="Changes" comment="Add Nord theme for real">
<change beforePath="$PROJECT_DIR$/.github/workflows/release.yml" beforeDir="false" afterPath="$PROJECT_DIR$/.github/workflows/release.yml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/CMakeLists.txt" beforeDir="false" afterPath="$PROJECT_DIR$/CMakeLists.txt" afterDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -60,12 +59,17 @@
<option name="UPDATE_TYPE" value="REBASE" /> <option name="UPDATE_TYPE" value="REBASE" />
</component> </component>
<component name="HighlightingSettingsPerFile"> <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:///AIAssistantSnippet.." root0="SKIP_HIGHLIGHTING" />
<setting file="mock:///AIAssistantSnippet.." root0="SKIP_HIGHLIGHTING" /> <setting file="mock:///AIAssistantSnippet.." root0="SKIP_HIGHLIGHTING" />
</component> </component>
<component name="OptimizeOnSaveOptions"> <component name="OptimizeOnSaveOptions">
<option name="myRunOnSave" value="true" /> <option name="myRunOnSave" value="true" />
</component> </component>
<component name="ProblemsViewState">
<option name="selectedTabId" value="AISelfReview" />
</component>
<component name="ProjectApplicationVersion"> <component name="ProjectApplicationVersion">
<option name="ide" value="CLion" /> <option name="ide" value="CLion" />
<option name="majorVersion" value="2025" /> <option name="majorVersion" value="2025" />
@@ -85,52 +89,52 @@
<option name="sortByType" value="true" /> <option name="sortByType" value="true" />
<option name="sortKey" value="BY_TYPE" /> <option name="sortKey" value="BY_TYPE" />
</component> </component>
<component name="PropertiesComponent">{ <component name="PropertiesComponent"><![CDATA[{
&quot;keyToString&quot;: { "keyToString": {
&quot;CMake Application.kge.executor&quot;: &quot;Run&quot;, "CMake Application.kge.executor": "Run",
&quot;CMake Application.test_example.executor&quot;: &quot;Run&quot;, "CMake Application.test_example.executor": "Run",
&quot;CMake Application.test_undo.executor&quot;: &quot;Run&quot;, "CMake Application.test_undo.executor": "Run",
&quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;, "ModuleVcsDetector.initialDetectionPerformed": "true",
&quot;NIXITCH_NIXPKGS_CONFIG&quot;: &quot;&quot;, "NIXITCH_NIXPKGS_CONFIG": "",
&quot;NIXITCH_NIX_CONF_DIR&quot;: &quot;&quot;, "NIXITCH_NIX_CONF_DIR": "",
&quot;NIXITCH_NIX_OTHER_STORES&quot;: &quot;&quot;, "NIXITCH_NIX_OTHER_STORES": "",
&quot;NIXITCH_NIX_PATH&quot;: &quot;&quot;, "NIXITCH_NIX_PATH": "",
&quot;NIXITCH_NIX_PROFILES&quot;: &quot;&quot;, "NIXITCH_NIX_PROFILES": "",
&quot;NIXITCH_NIX_REMOTE&quot;: &quot;&quot;, "NIXITCH_NIX_REMOTE": "",
&quot;NIXITCH_NIX_USER_PROFILE_DIR&quot;: &quot;&quot;, "NIXITCH_NIX_USER_PROFILE_DIR": "",
&quot;RunOnceActivity.RadMigrateCodeStyle&quot;: &quot;true&quot;, "RunOnceActivity.RadMigrateCodeStyle": "true",
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;, "RunOnceActivity.ShowReadmeOnStart": "true",
&quot;RunOnceActivity.cidr.known.project.marker&quot;: &quot;true&quot;, "RunOnceActivity.cidr.known.project.marker": "true",
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;, "RunOnceActivity.git.unshallow": "true",
&quot;RunOnceActivity.readMode.enableVisualFormatting&quot;: &quot;true&quot;, "RunOnceActivity.readMode.enableVisualFormatting": "true",
&quot;RunOnceActivity.west.config.association.type.startup.service&quot;: &quot;true&quot;, "RunOnceActivity.west.config.association.type.startup.service": "true",
&quot;cf.first.check.clang-format&quot;: &quot;false&quot;, "cf.first.check.clang-format": "false",
&quot;cidr.known.project.marker&quot;: &quot;true&quot;, "cidr.known.project.marker": "true",
&quot;code.cleanup.on.save&quot;: &quot;true&quot;, "code.cleanup.on.save": "true",
&quot;com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1&quot;: &quot;true&quot;, "com.intellij.ml.llm.matterhorn.ej.ui.settings.DefaultModelSelectionForGA.v1": "true",
&quot;git-widget-placeholder&quot;: &quot;master&quot;, "git-widget-placeholder": "master",
&quot;junie.onboarding.icon.badge.shown&quot;: &quot;true&quot;, "junie.onboarding.icon.badge.shown": "true",
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;, "node.js.detected.package.eslint": "true",
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;, "node.js.detected.package.tslint": "true",
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;, "node.js.selected.package.eslint": "(autodetect)",
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;, "node.js.selected.package.tslint": "(autodetect)",
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;, "nodejs_package_manager_path": "npm",
&quot;onboarding.tips.debug.path&quot;: &quot;/Users/kyle/src/kte/main.cpp&quot;, "onboarding.tips.debug.path": "/Users/kyle/src/kte/main.cpp",
&quot;rearrange.code.on.save&quot;: &quot;true&quot;, "rearrange.code.on.save": "true",
&quot;settings.editor.selected.configurable&quot;: &quot;editor.preferences.fonts.default&quot;, "settings.editor.selected.configurable": "CMakeSettings",
&quot;to.speed.mode.migration.done&quot;: &quot;true&quot;, "to.speed.mode.migration.done": "true",
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot; "vue.rearranger.settings.migration": "true"
} }
}</component> }]]></component>
<component name="RecentsManager"> <component name="RecentsManager">
<key name="MoveFile.RECENT_KEYS"> <key name="MoveFile.RECENT_KEYS">
<recent name="$PROJECT_DIR$/docs" /> <recent name="$PROJECT_DIR$/docs" />
</key> </key>
</component> </component>
<component name="RunManager" selected="CMake Application.kge"> <component name="RunManager" selected="CMake Application.kge">
<configuration default="true" type="CLionExternalRunConfiguration" factoryName="Application" REDIRECT_INPUT="false" ELEVATE="false" USE_EXTERNAL_CONSOLE="false" EMULATE_TERMINAL="false" PASS_PARENT_ENVS_2="true"> <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"> <method v="2">
<option name="CLION.EXTERNAL.BUILD" enabled="true" /> <option name="com.jetbrains.cidr.execution.CidrBuildBeforeRunTaskProvider$BuildBeforeRunTask" enabled="true" />
</method> </method>
</configuration> </configuration>
<configuration name="imgui" 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="imgui" CONFIG_NAME="Debug"> <configuration name="imgui" 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="imgui" CONFIG_NAME="Debug">
@@ -138,7 +142,7 @@
<option name="com.jetbrains.cidr.execution.CidrBuildBeforeRunTaskProvider$BuildBeforeRunTask" enabled="true" /> <option name="com.jetbrains.cidr.execution.CidrBuildBeforeRunTaskProvider$BuildBeforeRunTask" enabled="true" />
</method> </method>
</configuration> </configuration>
<configuration name="kge" type="CMakeRunConfiguration" factoryName="Application" REDIRECT_INPUT="false" ELEVATE="false" USE_EXTERNAL_CONSOLE="false" EMULATE_TERMINAL="false" WORKING_DIR="file://$PROJECT_DIR$" PASS_PARENT_ENVS_2="true" PROJECT_NAME="kte" TARGET_NAME="kge" CONFIG_NAME="Debug" RUN_TARGET_PROJECT_NAME="kte" RUN_TARGET_NAME="kge"> <configuration name="kge" type="CMakeRunConfiguration" factoryName="Application" PROGRAM_PARAMS="$PROJECT_DIR$/cmake-build-debug/test.txt" REDIRECT_INPUT="false" ELEVATE="false" USE_EXTERNAL_CONSOLE="false" EMULATE_TERMINAL="false" WORKING_DIR="file://$PROJECT_DIR$" PASS_PARENT_ENVS_2="true" PROJECT_NAME="kte" TARGET_NAME="kge" CONFIG_NAME="Debug" RUN_TARGET_PROJECT_NAME="kte" RUN_TARGET_NAME="kge">
<method v="2"> <method v="2">
<option name="com.jetbrains.cidr.execution.CidrBuildBeforeRunTaskProvider$BuildBeforeRunTask" enabled="true" /> <option name="com.jetbrains.cidr.execution.CidrBuildBeforeRunTaskProvider$BuildBeforeRunTask" enabled="true" />
</method> </method>
@@ -169,7 +173,7 @@
<workItem from="1764539556448" duration="156000" /> <workItem from="1764539556448" duration="156000" />
<workItem from="1764539725338" duration="1075000" /> <workItem from="1764539725338" duration="1075000" />
<workItem from="1764542392763" duration="3512000" /> <workItem from="1764542392763" duration="3512000" />
<workItem from="1764548345516" duration="9962000" /> <workItem from="1764548345516" duration="39312000" />
</task> </task>
<task id="LOCAL-00001" summary="Add undo/redo infrastructure and buffer management additions."> <task id="LOCAL-00001" summary="Add undo/redo infrastructure and buffer management additions.">
<option name="closed" value="true" /> <option name="closed" value="true" />
@@ -283,7 +287,47 @@
<option name="project" value="LOCAL" /> <option name="project" value="LOCAL" />
<updated>1764557759844</updated> <updated>1764557759844</updated>
</task> </task>
<option name="localTasksCounter" value="15" /> <task id="LOCAL-00015" summary="Fix void crash in kge.">
<option name="closed" value="true" />
<created>1764568264996</created>
<option name="number" value="00015" />
<option name="presentableId" value="LOCAL-00015" />
<option name="project" value="LOCAL" />
<updated>1764568264996</updated>
</task>
<task id="LOCAL-00016" summary="add regex and search/replace functionality to editor">
<option name="closed" value="true" />
<created>1764574397967</created>
<option name="number" value="00016" />
<option name="presentableId" value="LOCAL-00016" />
<option name="project" value="LOCAL" />
<updated>1764574397967</updated>
</task>
<task id="LOCAL-00017" summary="Add regex search, search/replace, and buffer read-only mode functionality with help text">
<option name="closed" value="true" />
<created>1764586480092</created>
<option name="number" value="00017" />
<option name="presentableId" value="LOCAL-00017" />
<option name="project" value="LOCAL" />
<updated>1764586480092</updated>
</task>
<task id="LOCAL-00018" summary="Nord theme and undo system refinements&#10;&#10;- Improve text input/event batching&#10;- Enhance debugging with optional instrumentation&#10;- Begin implementation of non-linear undo tree structure.">
<option name="closed" value="true" />
<created>1764619193516</created>
<option name="number" value="00018" />
<option name="presentableId" value="LOCAL-00018" />
<option name="project" value="LOCAL" />
<updated>1764619193517</updated>
</task>
<task id="LOCAL-00019" summary="Add Nord theme for real">
<option name="closed" value="true" />
<created>1764619210817</created>
<option name="number" value="00019" />
<option name="presentableId" value="LOCAL-00019" />
<option name="project" value="LOCAL" />
<updated>1764619210817</updated>
</task>
<option name="localTasksCounter" value="20" />
<servers /> <servers />
</component> </component>
<component name="TypeScriptGeneratedFilesManager"> <component name="TypeScriptGeneratedFilesManager">
@@ -311,7 +355,12 @@
<MESSAGE value="Introduce file picker and GUI configuration with enhancements.&#10;&#10;- Add visual file picker for GUI with toggle support.&#10;- Introduce `GUIConfig` class for loading GUI settings from configuration file.&#10;- Refactor window initialization to support dynamic sizing based on configuration.&#10;- Add macOS-specific handling for fullscreen behavior.&#10;- Improve header inclusion order and minor code cleanup." /> <MESSAGE value="Introduce file picker and GUI configuration with enhancements.&#10;&#10;- Add visual file picker for GUI with toggle support.&#10;- Introduce `GUIConfig` class for loading GUI settings from configuration file.&#10;- Refactor window initialization to support dynamic sizing based on configuration.&#10;- Add macOS-specific handling for fullscreen behavior.&#10;- Improve header inclusion order and minor code cleanup." />
<MESSAGE value="Add buffer position display and documentation improvements.&#10;&#10;- Display buffer position prefix &quot;[x/N]&quot; in GUI and terminal renderers.&#10;- Improve `kte` and `kge` man pages with frontend usage details and project homepage.&#10;- Update README with GUI invocation instructions.&#10;- Bump version to 1.0.0." /> <MESSAGE value="Add buffer position display and documentation improvements.&#10;&#10;- Display buffer position prefix &quot;[x/N]&quot; in GUI and terminal renderers.&#10;- Improve `kte` and `kge` man pages with frontend usage details and project homepage.&#10;- Update README with GUI invocation instructions.&#10;- Bump version to 1.0.0." />
<MESSAGE value="Actually add the screenshot." /> <MESSAGE value="Actually add the screenshot." />
<option name="LAST_COMMIT_MESSAGE" value="Actually add the screenshot." /> <MESSAGE value="Fix void crash in kge." />
<MESSAGE value="add regex and search/replace functionality to editor" />
<MESSAGE value="Add regex search, search/replace, and buffer read-only mode functionality with help text" />
<MESSAGE value="Nord theme and undo system refinements&#10;&#10;- Improve text input/event batching&#10;- Enhance debugging with optional instrumentation&#10;- Begin implementation of non-linear undo tree structure." />
<MESSAGE value="Add Nord theme for real" />
<option name="LAST_COMMIT_MESSAGE" value="Add Nord theme for real" />
</component> </component>
<component name="XSLT-Support.FileAssociations.UIState"> <component name="XSLT-Support.FileAssociations.UIState">
<expand /> <expand />

View File

@@ -36,6 +36,7 @@ Buffer::Buffer(const Buffer &other)
filename_ = other.filename_; filename_ = other.filename_;
is_file_backed_ = other.is_file_backed_; is_file_backed_ = other.is_file_backed_;
dirty_ = other.dirty_; dirty_ = other.dirty_;
read_only_ = other.read_only_;
mark_set_ = other.mark_set_; mark_set_ = other.mark_set_;
mark_curx_ = other.mark_curx_; mark_curx_ = other.mark_curx_;
mark_cury_ = other.mark_cury_; mark_cury_ = other.mark_cury_;
@@ -60,6 +61,7 @@ Buffer::operator=(const Buffer &other)
filename_ = other.filename_; filename_ = other.filename_;
is_file_backed_ = other.is_file_backed_; is_file_backed_ = other.is_file_backed_;
dirty_ = other.dirty_; dirty_ = other.dirty_;
read_only_ = other.read_only_;
mark_set_ = other.mark_set_; mark_set_ = other.mark_set_;
mark_curx_ = other.mark_curx_; mark_curx_ = other.mark_curx_;
mark_cury_ = other.mark_cury_; mark_cury_ = other.mark_cury_;
@@ -82,6 +84,7 @@ Buffer::Buffer(Buffer &&other) noexcept
filename_(std::move(other.filename_)), filename_(std::move(other.filename_)),
is_file_backed_(other.is_file_backed_), is_file_backed_(other.is_file_backed_),
dirty_(other.dirty_), dirty_(other.dirty_),
read_only_(other.read_only_),
mark_set_(other.mark_set_), mark_set_(other.mark_set_),
mark_curx_(other.mark_curx_), mark_curx_(other.mark_curx_),
mark_cury_(other.mark_cury_), mark_cury_(other.mark_cury_),
@@ -112,6 +115,7 @@ Buffer::operator=(Buffer &&other) noexcept
filename_ = std::move(other.filename_); filename_ = std::move(other.filename_);
is_file_backed_ = other.is_file_backed_; is_file_backed_ = other.is_file_backed_;
dirty_ = other.dirty_; dirty_ = other.dirty_;
read_only_ = other.read_only_;
mark_set_ = other.mark_set_; mark_set_ = other.mark_set_;
mark_curx_ = other.mark_curx_; mark_curx_ = other.mark_curx_;
mark_cury_ = other.mark_cury_; mark_cury_ = other.mark_cury_;
@@ -364,9 +368,9 @@ Buffer::insert_text(int row, int col, std::string_view text)
rows_[y].insert(x, seg); rows_[y].insert(x, seg);
x += seg.size(); x += seg.size();
// Split line at x // Split line at x
std::string tail = rows_[y].substr(x); std::string tail = rows_[y].substr(x);
rows_[y].erase(x); rows_[y].erase(x);
rows_.insert(rows_.begin() + static_cast<std::ptrdiff_t>(y + 1), tail); rows_.insert(rows_.begin() + static_cast<std::ptrdiff_t>(y + 1), Line(tail));
y += 1; y += 1;
x = 0; x = 0;
remain.erase(0, pos + 1); remain.erase(0, pos + 1);
@@ -426,8 +430,8 @@ Buffer::split_line(int row, const int col)
const auto y = static_cast<std::size_t>(row); const auto y = static_cast<std::size_t>(row);
const auto x = std::min<std::size_t>(static_cast<std::size_t>(col), rows_[y].size()); const auto x = std::min<std::size_t>(static_cast<std::size_t>(col), rows_[y].size());
const auto tail = rows_[y].substr(x); const auto tail = rows_[y].substr(x);
rows_[y].erase(x); rows_[y].erase(x);
rows_.insert(rows_.begin() + static_cast<std::ptrdiff_t>(y + 1), tail); rows_.insert(rows_.begin() + static_cast<std::ptrdiff_t>(y + 1), Line(tail));
} }
@@ -455,7 +459,7 @@ Buffer::insert_row(int row, const std::string_view text)
row = 0; row = 0;
if (static_cast<std::size_t>(row) > rows_.size()) if (static_cast<std::size_t>(row) > rows_.size())
row = static_cast<int>(rows_.size()); row = static_cast<int>(rows_.size());
rows_.insert(rows_.begin() + row, std::string(text)); rows_.insert(rows_.begin() + row, Line(std::string(text)));
} }

View File

@@ -16,7 +16,7 @@
class Buffer { class Buffer {
public: public:
Buffer(); Buffer();
Buffer(const Buffer &other); Buffer(const Buffer &other);
@@ -77,13 +77,13 @@ public:
Line() = default; Line() = default;
Line(const char *s) explicit Line(const char *s)
{ {
assign_from(s ? std::string(s) : std::string()); assign_from(s ? std::string(s) : std::string());
} }
Line(const std::string &s) explicit Line(const std::string &s)
{ {
assign_from(s); assign_from(s);
} }
@@ -139,29 +139,38 @@ public:
// conversions // conversions
operator std::string() const explicit operator std::string() const
{ {
return std::string(buf_.Data() ? buf_.Data() : "", buf_.Size()); return {buf_.Data() ? buf_.Data() : "", buf_.Size()};
} }
// string-like API used by command/renderer layers (implemented via materialization for now) // string-like API used by command/renderer layers (implemented via materialization for now)
std::string substr(std::size_t pos) const [[nodiscard]] std::string substr(std::size_t pos) const
{ {
const std::size_t n = buf_.Size(); const std::size_t n = buf_.Size();
if (pos >= n) if (pos >= n)
return std::string(); return {};
return std::string(buf_.Data() + pos, n - pos); return {buf_.Data() + pos, n - pos};
} }
std::string substr(std::size_t pos, std::size_t len) const [[nodiscard]] std::string substr(std::size_t pos, std::size_t len) const
{ {
const std::size_t n = buf_.Size(); const std::size_t n = buf_.Size();
if (pos >= n) if (pos >= n)
return std::string(); return {};
const std::size_t take = (pos + len > n) ? (n - pos) : len; const std::size_t take = (pos + len > n) ? (n - pos) : len;
return std::string(buf_.Data() + pos, take); return {buf_.Data() + pos, take};
}
// minimal find() to support search within a line
[[nodiscard]] std::size_t find(const std::string &needle, const std::size_t pos = 0) const
{
// Materialize to std::string for now; Line is backed by AppendBuffer
const auto s = static_cast<std::string>(*this);
return s.find(needle, pos);
} }
@@ -253,6 +262,14 @@ public:
return filename_; return filename_;
} }
// Set a virtual (non file-backed) display name for this buffer, e.g. "+HELP+"
// This does not mark the buffer as file-backed.
void SetVirtualName(const std::string &name)
{
filename_ = name;
is_file_backed_ = false;
}
[[nodiscard]] bool IsFileBacked() const [[nodiscard]] bool IsFileBacked() const
{ {
@@ -260,26 +277,42 @@ public:
} }
[[nodiscard]] bool Dirty() const [[nodiscard]] bool Dirty() const
{ {
return dirty_; return dirty_;
} }
// Read-only flag
[[nodiscard]] bool IsReadOnly() const
{
return read_only_;
}
void SetReadOnly(bool ro)
{
read_only_ = ro;
}
void ToggleReadOnly()
{
read_only_ = !read_only_;
}
void SetCursor(std::size_t x, std::size_t y) void SetCursor(const std::size_t x, const std::size_t y)
{ {
curx_ = x; curx_ = x;
cury_ = y; cury_ = y;
} }
void SetRenderX(std::size_t rx) void SetRenderX(const std::size_t rx)
{ {
rx_ = rx; rx_ = rx;
} }
void SetOffsets(std::size_t row, std::size_t col) void SetOffsets(const std::size_t row, const std::size_t col)
{ {
rowoffs_ = row; rowoffs_ = row;
coloffs_ = col; coloffs_ = col;
@@ -299,7 +332,7 @@ public:
} }
void SetMark(std::size_t x, std::size_t y) void SetMark(const std::size_t x, const std::size_t y)
{ {
mark_set_ = true; mark_set_ = true;
mark_curx_ = x; mark_curx_ = x;
@@ -344,20 +377,21 @@ public:
// Undo system accessors (created per-buffer) // Undo system accessors (created per-buffer)
UndoSystem *Undo(); UndoSystem *Undo();
const UndoSystem *Undo() const; [[nodiscard]] const UndoSystem *Undo() const;
private: private:
// State mirroring original C struct (without undo_tree) // State mirroring original C struct (without undo_tree)
std::size_t curx_ = 0, cury_ = 0; // cursor position in characters std::size_t curx_ = 0, cury_ = 0; // cursor position in characters
std::size_t rx_ = 0; // render x (tabs expanded) std::size_t rx_ = 0; // render x (tabs expanded)
std::size_t nrows_ = 0; // number of rows std::size_t nrows_ = 0; // number of rows
std::size_t rowoffs_ = 0, coloffs_ = 0; // viewport offsets std::size_t rowoffs_ = 0, coloffs_ = 0; // viewport offsets
std::vector<Line> rows_; // buffer rows (without trailing newlines) std::vector<Line> rows_; // buffer rows (without trailing newlines)
std::string filename_; std::string filename_;
bool is_file_backed_ = false; bool is_file_backed_ = false;
bool dirty_ = false; bool dirty_ = false;
bool mark_set_ = false; bool read_only_ = false;
std::size_t mark_curx_ = 0, mark_cury_ = 0; bool mark_set_ = false;
std::size_t mark_curx_ = 0, mark_cury_ = 0;
// Per-buffer undo state // Per-buffer undo state
std::unique_ptr<struct UndoTree> undo_tree_; std::unique_ptr<struct UndoTree> undo_tree_;

View File

@@ -4,14 +4,15 @@ project(kte)
include(GNUInstallDirs) include(GNUInstallDirs)
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD 17)
set(KTE_VERSION "1.0.2") set(KTE_VERSION "1.1.0")
# Default to terminal-only build to avoid SDL/OpenGL dependency by default. # Default to terminal-only build to avoid SDL/OpenGL dependency by default.
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available. # Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
set(BUILD_GUI OFF CACHE BOOL "Enable building the graphical version.") set(BUILD_GUI ON CACHE BOOL "Enable building the graphical version.")
set(BUILD_TESTS OFF CACHE BOOL "Enable building test programs.") set(BUILD_TESTS OFF CACHE BOOL "Enable building test programs.")
option(KTE_USE_PIECE_TABLE "Use PieceTable instead of GapBuffer implementation" ON) option(KTE_USE_PIECE_TABLE "Use PieceTable instead of GapBuffer implementation" ON)
set(KTE_FONT_SIZE "18.0" CACHE STRING "Default font size for GUI") set(KTE_FONT_SIZE "18.0" CACHE STRING "Default font size for GUI")
option(KTE_UNDO_DEBUG "Enable undo instrumentation logs" OFF)
if (CMAKE_HOST_UNIX) if (CMAKE_HOST_UNIX)
message(STATUS "Build system is POSIX.") message(STATUS "Build system is POSIX.")
@@ -55,6 +56,7 @@ set(COMMON_SOURCES
Buffer.cc Buffer.cc
Editor.cc Editor.cc
Command.cc Command.cc
HelpText.cc
KKeymap.cc KKeymap.cc
TerminalInputHandler.cc TerminalInputHandler.cc
TerminalRenderer.cc TerminalRenderer.cc
@@ -74,6 +76,7 @@ set(COMMON_HEADERS
Editor.h Editor.h
AppendBuffer.h AppendBuffer.h
Command.h Command.h
HelpText.h
KKeymap.h KKeymap.h
InputHandler.h InputHandler.h
TerminalInputHandler.h TerminalInputHandler.h
@@ -99,6 +102,9 @@ add_executable(kte
if (KTE_USE_PIECE_TABLE) if (KTE_USE_PIECE_TABLE)
target_compile_definitions(kte PRIVATE KTE_USE_PIECE_TABLE=1) target_compile_definitions(kte PRIVATE KTE_USE_PIECE_TABLE=1)
endif () endif ()
if (KTE_UNDO_DEBUG)
target_compile_definitions(kte PRIVATE KTE_UNDO_DEBUG=1)
endif ()
target_link_libraries(kte ${CURSES_LIBRARIES}) target_link_libraries(kte ${CURSES_LIBRARIES})
@@ -121,6 +127,10 @@ if (BUILD_TESTS)
target_compile_definitions(test_undo PRIVATE KTE_USE_PIECE_TABLE=1) target_compile_definitions(test_undo PRIVATE KTE_USE_PIECE_TABLE=1)
endif () endif ()
if (KTE_UNDO_DEBUG)
target_compile_definitions(test_undo PRIVATE KTE_UNDO_DEBUG=1)
endif ()
target_link_libraries(test_undo ${CURSES_LIBRARIES}) target_link_libraries(test_undo ${CURSES_LIBRARIES})
endif () endif ()
@@ -153,6 +163,9 @@ if (${BUILD_GUI})
GUIFrontend.cc GUIFrontend.cc
GUIFrontend.h) GUIFrontend.h)
target_compile_definitions(kge PRIVATE KTE_BUILD_GUI=1 KTE_DEFAULT_GUI=1 KTE_FONT_SIZE=${KTE_FONT_SIZE}) target_compile_definitions(kge PRIVATE KTE_BUILD_GUI=1 KTE_DEFAULT_GUI=1 KTE_FONT_SIZE=${KTE_FONT_SIZE})
if (KTE_UNDO_DEBUG)
target_compile_definitions(kge PRIVATE KTE_UNDO_DEBUG=1)
endif ()
target_link_libraries(kge ${CURSES_LIBRARIES} imgui) target_link_libraries(kge ${CURSES_LIBRARIES} imgui)
# On macOS, build kge as a proper .app bundle # On macOS, build kge as a proper .app bundle
@@ -201,5 +214,5 @@ if (${BUILD_GUI})
endif () endif ()
# Install kge man page only when GUI is built # Install kge man page only when GUI is built
install(FILES docs/kge.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1) install(FILES docs/kge.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1)
install(FILES kge.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons) install(FILES kge.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons)
endif () endif ()

File diff suppressed because it is too large Load Diff

View File

@@ -23,8 +23,10 @@ enum class CommandId {
Refresh, // force redraw Refresh, // force redraw
KPrefix, // show "C-k _" prompt in status when entering k-command KPrefix, // show "C-k _" prompt in status when entering k-command
FindStart, // begin incremental search (placeholder) FindStart, // begin incremental search (placeholder)
RegexFindStart, // begin regex search (C-r)
RegexpReplace, // begin regex search & replace (C-t)
SearchReplace, // begin search & replace (two-step prompt)
OpenFileStart, // begin open-file prompt OpenFileStart, // begin open-file prompt
// GUI: visual file picker
VisualFilePickerToggle, VisualFilePickerToggle,
// Buffers // Buffers
BufferSwitchStart, // begin buffer switch prompt BufferSwitchStart, // begin buffer switch prompt
@@ -71,6 +73,8 @@ enum class CommandId {
IndentRegion, // indent region (C-k =) IndentRegion, // indent region (C-k =)
UnindentRegion, // unindent region (C-k -) UnindentRegion, // unindent region (C-k -)
ReflowParagraph, // reflow paragraph to column width (ESC q) ReflowParagraph, // reflow paragraph to column width (ESC q)
// Read-only buffers
ToggleReadOnly, // toggle current buffer read-only (C-k ')
// Buffer operations // Buffer operations
ReloadBuffer, // reload buffer from disk (C-k l) ReloadBuffer, // reload buffer from disk (C-k l)
MarkAllAndJumpEnd, // set mark at beginning, jump to end (C-k a) MarkAllAndJumpEnd, // set mark at beginning, jump to end (C-k a)
@@ -78,6 +82,8 @@ enum class CommandId {
JumpToLine, // prompt for line and jump (C-k g) JumpToLine, // prompt for line and jump (C-k g)
ShowWorkingDirectory, // Display the current working directory in the editor message. ShowWorkingDirectory, // Display the current working directory in the editor message.
ChangeWorkingDirectory, // Change the editor's current directory. ChangeWorkingDirectory, // Change the editor's current directory.
// Help
ShowHelp, // open +HELP+ buffer with manual text (C-k h)
// Meta // Meta
UnknownKCommand, // arg: single character that was not recognized after C-k UnknownKCommand, // arg: single character that was not recognized after C-k
}; };

View File

@@ -301,8 +301,22 @@ public:
} }
// --- Generic Prompt subsystem (for search, open-file, save-as, etc.) --- // --- Generic Prompt subsystem (for search, open-file, save-as, etc.) ---
enum class PromptKind { None = 0, Search, OpenFile, SaveAs, Confirm, BufferSwitch, GotoLine, Chdir }; enum class PromptKind {
None = 0,
Search,
RegexSearch,
RegexReplaceFind, // step 1 of Regex Search & Replace: find pattern
RegexReplaceWith, // step 2 of Regex Search & Replace: replacement text
OpenFile,
SaveAs,
Confirm,
BufferSwitch,
GotoLine,
Chdir,
ReplaceFind, // step 1 of Search & Replace: find what
ReplaceWith // step 2 of Search & Replace: replace with
};
void StartPrompt(PromptKind kind, const std::string &label, const std::string &initial) void StartPrompt(PromptKind kind, const std::string &label, const std::string &initial)
@@ -504,9 +518,20 @@ private:
std::string prompt_text_; std::string prompt_text_;
std::string pending_overwrite_path_; std::string pending_overwrite_path_;
// GUI-only state (safe no-op in terminal builds) // GUI-only state (safe no-op in terminal builds)
bool file_picker_visible_ = false; bool file_picker_visible_ = false;
std::string file_picker_dir_; std::string file_picker_dir_;
// Temporary state for Search & Replace flow
public:
void SetReplaceFindTmp(const std::string &s) { replace_find_tmp_ = s; }
void SetReplaceWithTmp(const std::string &s) { replace_with_tmp_ = s; }
[[nodiscard]] const std::string &ReplaceFindTmp() const { return replace_find_tmp_; }
[[nodiscard]] const std::string &ReplaceWithTmp() const { return replace_with_tmp_; }
private:
std::string replace_find_tmp_;
std::string replace_with_tmp_;
}; };
#endif // KTE_EDITOR_H #endif // KTE_EDITOR_H

View File

@@ -23,7 +23,7 @@ default_config_path()
{ {
const char *home = std::getenv("HOME"); const char *home = std::getenv("HOME");
if (!home || !*home) if (!home || !*home)
return std::string(); return {};
std::string path(home); std::string path(home);
path += "/.config/kte/kge.ini"; path += "/.config/kte/kge.ini";
return path; return path;
@@ -34,7 +34,8 @@ GUIConfig
GUIConfig::Load() GUIConfig::Load()
{ {
GUIConfig cfg; // defaults already set GUIConfig cfg; // defaults already set
std::string path = default_config_path(); const std::string path = default_config_path();
if (!path.empty()) { if (!path.empty()) {
cfg.LoadFromFile(path); cfg.LoadFromFile(path);
} }
@@ -98,8 +99,9 @@ GUIConfig::LoadFromFile(const std::string &path)
try { try {
v = std::stof(val); v = std::stof(val);
} catch (...) {} } catch (...) {}
if (v > 0.0f) if (v > 0.0f) {
font_size = v; font_size = v;
}
} }
} }

View File

@@ -15,6 +15,7 @@
#include "GUIFrontend.h" #include "GUIFrontend.h"
#include "Font.h" // embedded default font (DefaultFontRegular) #include "Font.h" // embedded default font (DefaultFontRegular)
#include "GUIConfig.h" #include "GUIConfig.h"
#include "GUITheme.h"
#ifndef KTE_FONT_SIZE #ifndef KTE_FONT_SIZE
@@ -74,7 +75,7 @@ GUIFrontend::Init(Editor &ed)
} }
window_ = SDL_CreateWindow( window_ = SDL_CreateWindow(
"kge - kyle's text editor " KTE_VERSION_STR, "kge - kyle's graphical editor " KTE_VERSION_STR,
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
width_, height_, width_, height_,
win_flags); win_flags);
@@ -104,6 +105,8 @@ GUIFrontend::Init(Editor &ed)
ImGuiIO &io = ImGui::GetIO(); ImGuiIO &io = ImGui::GetIO();
(void) io; (void) io;
ImGui::StyleColorsDark(); ImGui::StyleColorsDark();
// Apply a Nord-inspired theme
kte::ApplyNordImGuiTheme();
if (!ImGui_ImplSDL2_InitForOpenGL(window_, gl_ctx_)) if (!ImGui_ImplSDL2_InitForOpenGL(window_, gl_ctx_))
return false; return false;
@@ -264,8 +267,8 @@ GUIFrontend::LoadGuiFont_(const char * /*path*/, float size_px)
ImGuiIO &io = ImGui::GetIO(); ImGuiIO &io = ImGui::GetIO();
io.Fonts->Clear(); io.Fonts->Clear();
ImFont *font = io.Fonts->AddFontFromMemoryCompressedTTF( ImFont *font = io.Fonts->AddFontFromMemoryCompressedTTF(
(void *) DefaultFontRegularCompressedData, (void *) DefaultFontBoldCompressedData,
(int) DefaultFontRegularCompressedSize, (int) DefaultFontBoldCompressedSize,
size_px); size_px);
if (!font) { if (!font) {
font = io.Fonts->AddFontDefault(); font = io.Fonts->AddFontDefault();

View File

@@ -1,4 +1,5 @@
#include <cstdio> #include <cstdio>
#include <algorithm>
#include <ncurses.h> #include <ncurses.h>
#include <SDL.h> #include <SDL.h>
@@ -91,11 +92,9 @@ map_key(const SDL_Keycode key,
out = {true, CommandId::Backspace, "", 0}; out = {true, CommandId::Backspace, "", 0};
return true; return true;
case SDLK_TAB: case SDLK_TAB:
// Insert a literal tab character // Do not insert text on KEYDOWN; allow SDL_TEXTINPUT to deliver '\t'
out.hasCommand = true; // as printable input so that all printable characters flow via TEXTINPUT.
out.id = CommandId::InsertText; out.hasCommand = false;
out.arg = "\t";
out.count = 0;
return true; return true;
case SDLK_RETURN: case SDLK_RETURN:
case SDLK_KP_ENTER: case SDLK_KP_ENTER:
@@ -520,11 +519,21 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
break; break;
} }
if (!k_prefix_ && e.text.text[0] != '\0') { if (!k_prefix_ && e.text.text[0] != '\0') {
mi.hasCommand = true; // Ensure InsertText never carries a newline; those must originate from KEYDOWN
mi.id = CommandId::InsertText; std::string text(e.text.text);
mi.arg = std::string(e.text.text); // Strip any CR/LF that might slip through from certain platforms/IME behaviors
mi.count = 0; text.erase(std::remove(text.begin(), text.end(), '\n'), text.end());
produced = true; text.erase(std::remove(text.begin(), text.end(), '\r'), text.end());
if (!text.empty()) {
mi.hasCommand = true;
mi.id = CommandId::InsertText;
mi.arg = std::move(text);
mi.count = 0;
produced = true;
} else {
// Nothing to insert after filtering; consume the event
produced = true;
}
} else { } else {
produced = true; // consumed while k-prefix is active produced = true; // consumed while k-prefix is active
} }

View File

@@ -1,10 +1,13 @@
#include <algorithm>
#include <cmath> #include <cmath>
#include <cstdio> #include <cstdio>
#include <cstdlib>
#include <filesystem> #include <filesystem>
#include <limits> #include <limits>
#include <string> #include <string>
#include <imgui.h> #include <imgui.h>
#include <regex>
#include "GUIRenderer.h" #include "GUIRenderer.h"
#include "Buffer.h" #include "Buffer.h"
@@ -149,14 +152,14 @@ GUIRenderer::Draw(Editor &ed)
} }
} }
} }
// Handle mouse click before rendering to avoid dependent on drawn items // Handle mouse click before rendering to avoid dependent on drawn items
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
ImVec2 mp = ImGui::GetIO().MousePos; ImVec2 mp = ImGui::GetIO().MousePos;
// Compute viewport-relative row so (0) is top row of the visible area // Compute viewport-relative row so (0) is top row of the visible area
float vy_f = (mp.y - list_origin.y - scroll_y) / row_h; float vy_f = (mp.y - list_origin.y - scroll_y) / row_h;
long vy = static_cast<long>(vy_f); long vy = static_cast<long>(vy_f);
if (vy < 0) if (vy < 0)
vy = 0; vy = 0;
// Clamp vy within visible content height to avoid huge jumps // Clamp vy within visible content height to avoid huge jumps
ImVec2 cr_min = ImGui::GetWindowContentRegionMin(); ImVec2 cr_min = ImGui::GetWindowContentRegionMin();
@@ -168,97 +171,163 @@ GUIRenderer::Draw(Editor &ed)
if (vy >= vis_rows) if (vy >= vis_rows)
vy = vis_rows - 1; vy = vis_rows - 1;
// Translate viewport row to buffer row using Buffer::Rowoffs // Translate viewport row to buffer row using Buffer::Rowoffs
std::size_t by = buf->Rowoffs() + static_cast<std::size_t>(vy); std::size_t by = buf->Rowoffs() + static_cast<std::size_t>(vy);
if (by >= lines.size()) { if (by >= lines.size()) {
if (!lines.empty()) if (!lines.empty())
by = lines.size() - 1; by = lines.size() - 1;
else else
by = 0; by = 0;
} }
// Compute desired pixel X inside the viewport content (subtract horizontal scroll) // Compute desired pixel X inside the viewport content (subtract horizontal scroll)
float px = (mp.x - list_origin.x - scroll_x); float px = (mp.x - list_origin.x - scroll_x);
if (px < 0.0f) if (px < 0.0f)
px = 0.0f; px = 0.0f;
// Convert pixel X to a render-column target including horizontal col offset // Empty buffer guard: if there are no lines yet, just move to 0:0
// Use our own tab expansion of width 8 to match command layer logic. if (lines.empty()) {
const std::string &line_clicked = lines[by]; Execute(ed, CommandId::MoveCursorTo, std::string("0:0"));
const std::size_t tabw = 8; } else {
// We iterate source columns computing absolute rendered column (rx_abs) from 0, // Convert pixel X to a render-column target including horizontal col offset
// then translate to viewport-space by subtracting Coloffs. // Use our own tab expansion of width 8 to match command layer logic.
std::size_t coloffs = buf->Coloffs(); std::string line_clicked = static_cast<std::string>(lines[by]);
std::size_t rx_abs = 0; // absolute rendered column const std::size_t tabw = 8;
std::size_t i = 0; // source column iterator // We iterate source columns computing absolute rendered column (rx_abs) from 0,
// then translate to viewport-space by subtracting Coloffs.
std::size_t coloffs = buf->Coloffs();
std::size_t rx_abs = 0; // absolute rendered column
std::size_t i = 0; // source column iterator
// Fast-forward i until rx_abs >= coloffs to align with leftmost visible column // Fast-forward i until rx_abs >= coloffs to align with leftmost visible column
if (!line_clicked.empty() && coloffs > 0) { if (!line_clicked.empty() && coloffs > 0) {
while (i < line_clicked.size() && rx_abs < coloffs) { while (i < line_clicked.size() && rx_abs < coloffs) {
if (line_clicked[i] == '\t') { if (line_clicked[i] == '\t') {
rx_abs += (tabw - (rx_abs % tabw)); rx_abs += (tabw - (rx_abs % tabw));
} else { } else {
rx_abs += 1; rx_abs += 1;
} }
++i; ++i;
} }
} }
// Now search for closest source column to clicked px within/after viewport // Now search for closest source column to clicked px within/after viewport
std::size_t best_col = i; // default to first visible column std::size_t best_col = i; // default to first visible column
float best_dist = std::numeric_limits<float>::infinity(); float best_dist = std::numeric_limits<float>::infinity();
while (true) { while (true) {
// For i in [current..size], evaluate candidate including the implicit end position // For i in [current..size], evaluate candidate including the implicit end position
std::size_t rx_view = (rx_abs >= coloffs) ? (rx_abs - coloffs) : 0; std::size_t rx_view = (rx_abs >= coloffs) ? (rx_abs - coloffs) : 0;
float rx_px = static_cast<float>(rx_view) * space_w; float rx_px = static_cast<float>(rx_view) * space_w;
float dist = std::fabs(px - rx_px); float dist = std::fabs(px - rx_px);
if (dist <= best_dist) { if (dist <= best_dist) {
best_dist = dist; best_dist = dist;
best_col = i; best_col = i;
} }
if (i == line_clicked.size()) if (i == line_clicked.size())
break; break;
// advance to next source column // advance to next source column
if (line_clicked[i] == '\t') { if (line_clicked[i] == '\t') {
rx_abs += (tabw - (rx_abs % tabw)); rx_abs += (tabw - (rx_abs % tabw));
} else { } else {
rx_abs += 1; rx_abs += 1;
} }
++i; ++i;
} }
// Dispatch absolute buffer coordinates (row:col) // Dispatch absolute buffer coordinates (row:col)
char tmp[64]; char tmp[64];
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, best_col); std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, best_col);
Execute(ed, CommandId::MoveCursorTo, std::string(tmp)); Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
} }
}
// Cache current horizontal offset in rendered columns // Cache current horizontal offset in rendered columns
const std::size_t coloffs_now = buf->Coloffs(); const std::size_t coloffs_now = buf->Coloffs();
for (std::size_t i = rowoffs; i < lines.size(); ++i) { for (std::size_t i = rowoffs; i < lines.size(); ++i) {
// Capture the screen position before drawing the line // Capture the screen position before drawing the line
ImVec2 line_pos = ImGui::GetCursorScreenPos(); ImVec2 line_pos = ImGui::GetCursorScreenPos();
const std::string &line = lines[i]; std::string line = static_cast<std::string>(lines[i]);
// Expand tabs to spaces with width=8 and apply horizontal scroll offset // Expand tabs to spaces with width=8 and apply horizontal scroll offset
const std::size_t tabw = 8; const std::size_t tabw = 8;
std::string expanded; std::string expanded;
expanded.reserve(line.size() + 16); expanded.reserve(line.size() + 16);
std::size_t rx_abs_draw = 0; // rendered column for drawing std::size_t rx_abs_draw = 0; // rendered column for drawing
// Emit entire line (ImGui child scrolling will handle clipping) // Compute search highlight ranges for this line in source indices
for (std::size_t src = 0; src < line.size(); ++src) { bool search_mode = ed.SearchActive() && !ed.SearchQuery().empty();
char c = line[src]; std::vector<std::pair<std::size_t, std::size_t>> hl_src_ranges;
if (c == '\t') { if (search_mode) {
std::size_t adv = (tabw - (rx_abs_draw % tabw)); // If we're in RegexSearch or RegexReplaceFind mode, compute ranges using regex; otherwise plain substring
// Emit spaces for the tab if (ed.PromptActive() && (ed.CurrentPromptKind() == Editor::PromptKind::RegexSearch || ed.CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind)) {
expanded.append(adv, ' '); try {
rx_abs_draw += adv; std::regex rx(ed.SearchQuery());
} else { for (auto it = std::sregex_iterator(line.begin(), line.end(), rx);
expanded.push_back(c); it != std::sregex_iterator(); ++it) {
rx_abs_draw += 1; const auto &m = *it;
} std::size_t sx = static_cast<std::size_t>(m.position());
} std::size_t ex = sx + static_cast<std::size_t>(m.length());
hl_src_ranges.emplace_back(sx, ex);
}
} catch (const std::regex_error &) {
// ignore invalid patterns here; status line already shows the error
}
} else {
const std::string &q = ed.SearchQuery();
std::size_t pos = 0;
while (!q.empty() && (pos = line.find(q, pos)) != std::string::npos) {
hl_src_ranges.emplace_back(pos, pos + q.size());
pos += q.size();
}
}
}
auto src_to_rx = [&](std::size_t upto_src_exclusive) -> std::size_t {
std::size_t rx = 0;
std::size_t s = 0;
while (s < upto_src_exclusive && s < line.size()) {
if (line[s] == '\t')
rx += (tabw - (rx % tabw));
else
rx += 1;
++s;
}
return rx;
};
// Draw background highlights (under text)
if (search_mode && !hl_src_ranges.empty()) {
// Current match emphasis
bool has_current = ed.SearchMatchLen() > 0 && ed.SearchMatchY() == i;
std::size_t cur_x = has_current ? ed.SearchMatchX() : 0;
std::size_t cur_end = has_current ? (ed.SearchMatchX() + ed.SearchMatchLen()) : 0;
for (const auto &rg : hl_src_ranges) {
std::size_t sx = rg.first, ex = rg.second;
std::size_t rx_start = src_to_rx(sx);
std::size_t rx_end = src_to_rx(ex);
// Apply horizontal scroll offset
if (rx_end <= coloffs_now) continue; // fully left of view
std::size_t vx0 = (rx_start > coloffs_now) ? (rx_start - coloffs_now) : 0;
std::size_t vx1 = rx_end - coloffs_now;
ImVec2 p0 = ImVec2(line_pos.x + static_cast<float>(vx0) * space_w, line_pos.y);
ImVec2 p1 = ImVec2(line_pos.x + static_cast<float>(vx1) * space_w, line_pos.y + line_h);
// Choose color: current match stronger
bool is_current = has_current && sx == cur_x && ex == cur_end;
ImU32 col = is_current ? IM_COL32(255, 220, 120, 140) : IM_COL32(200, 200, 0, 90);
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
}
}
// Emit entire line (ImGui child scrolling will handle clipping)
for (std::size_t src = 0; src < line.size(); ++src) {
char c = line[src];
if (c == '\t') {
std::size_t adv = (tabw - (rx_abs_draw % tabw));
// Emit spaces for the tab
expanded.append(adv, ' ');
rx_abs_draw += adv;
} else {
expanded.push_back(c);
rx_abs_draw += 1;
}
}
ImGui::TextUnformatted(expanded.c_str()); ImGui::TextUnformatted(expanded.c_str());
// Draw a visible cursor indicator on the current line // Draw a visible cursor indicator on the current line
if (i == cy) { if (i == cy) {
@@ -280,28 +349,106 @@ GUIRenderer::Draw(Editor &ed)
} }
ImGui::EndChild(); ImGui::EndChild();
// Status bar spanning full width // Status bar spanning full width
ImGui::Separator(); ImGui::Separator();
// Build three segments: left (app/version/buffer/dirty), middle (message), right (cursor/mark) // Compute full content width and draw a filled background rectangle
// Compute full content width and draw a filled background rectangle ImVec2 win_pos = ImGui::GetWindowPos();
ImVec2 win_pos = ImGui::GetWindowPos(); ImVec2 cr_min = ImGui::GetWindowContentRegionMin();
ImVec2 cr_min = ImGui::GetWindowContentRegionMin(); ImVec2 cr_max = ImGui::GetWindowContentRegionMax();
ImVec2 cr_max = ImGui::GetWindowContentRegionMax(); float x0 = win_pos.x + cr_min.x;
float x0 = win_pos.x + cr_min.x; float x1 = win_pos.x + cr_max.x;
float x1 = win_pos.x + cr_max.x; ImVec2 cursor = ImGui::GetCursorScreenPos();
ImVec2 cursor = ImGui::GetCursorScreenPos(); float bar_h = ImGui::GetFrameHeight();
float bar_h = ImGui::GetFrameHeight(); ImVec2 p0(x0, cursor.y);
ImVec2 p0(x0, cursor.y); ImVec2 p1(x1, cursor.y + bar_h);
ImVec2 p1(x1, cursor.y + bar_h); ImU32 bg_col = ImGui::GetColorU32(ImGuiCol_HeaderActive);
ImU32 bg_col = ImGui::GetColorU32(ImGuiCol_HeaderActive); ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, bg_col);
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, bg_col); // If a prompt is active, replace the entire status bar with the prompt text
// Build left text if (ed.PromptActive()) {
std::string left; std::string label = ed.PromptLabel();
left.reserve(256); std::string ptext = ed.PromptText();
left += "kge"; // GUI app name auto kind = ed.CurrentPromptKind();
left += " "; if (kind == Editor::PromptKind::OpenFile || kind == Editor::PromptKind::SaveAs ||
left += KTE_VERSION_STR; kind == Editor::PromptKind::Chdir) {
const char *home_c = std::getenv("HOME");
if (home_c && *home_c) {
std::string home(home_c);
if (ptext.rfind(home, 0) == 0) {
std::string rest = ptext.substr(home.size());
if (rest.empty())
ptext = "~";
else if (!rest.empty() && (rest[0] == '/' || rest[0] == '\\'))
ptext = std::string("~") + rest;
}
}
}
float pad = 6.f;
float left_x = p0.x + pad;
float right_x = p1.x - pad;
float max_px = std::max(0.0f, right_x - left_x);
std::string prefix;
if (!label.empty()) prefix = label + ": ";
// Compose showing right-end of filename portion when too long for space
std::string final_msg;
ImVec2 prefix_sz = ImGui::CalcTextSize(prefix.c_str());
float avail_px = std::max(0.0f, max_px - prefix_sz.x);
if ((kind == Editor::PromptKind::OpenFile || kind == Editor::PromptKind::SaveAs || kind == Editor::PromptKind::Chdir) && avail_px > 0.0f) {
// Trim from left until it fits by pixel width
std::string tail = ptext;
ImVec2 tail_sz = ImGui::CalcTextSize(tail.c_str());
if (tail_sz.x > avail_px) {
// Remove leading chars until it fits
// Use a simple loop; text lengths are small here
size_t start = 0;
// To avoid O(n^2) worst-case, remove chunks
while (start < tail.size()) {
// Estimate how many chars to skip based on ratio
float ratio = tail_sz.x / avail_px;
size_t skip = ratio > 1.5f ? std::min(tail.size() - start, (size_t)std::max<size_t>(1, (size_t)(tail.size() / 4))) : 1;
start += skip;
std::string candidate = tail.substr(start);
ImVec2 cand_sz = ImGui::CalcTextSize(candidate.c_str());
if (cand_sz.x <= avail_px) {
tail = candidate;
tail_sz = cand_sz;
break;
}
}
if (ImGui::CalcTextSize(tail.c_str()).x > avail_px && !tail.empty()) {
// As a last resort, ensure fit by chopping exactly
// binary reduce
size_t lo = 0, hi = tail.size();
while (lo < hi) {
size_t mid = (lo + hi) / 2;
std::string cand = tail.substr(mid);
if (ImGui::CalcTextSize(cand.c_str()).x <= avail_px) hi = mid; else lo = mid + 1;
}
tail = tail.substr(lo);
}
}
final_msg = prefix + tail;
} else {
final_msg = prefix + ptext;
}
ImVec2 msg_sz = ImGui::CalcTextSize(final_msg.c_str());
ImGui::PushClipRect(ImVec2(p0.x, p0.y), ImVec2(p1.x, p1.y), true);
ImGui::SetCursorScreenPos(ImVec2(left_x, p0.y + (bar_h - msg_sz.y) * 0.5f));
ImGui::TextUnformatted(final_msg.c_str());
ImGui::PopClipRect();
// Advance cursor to after the bar to keep layout consistent
ImGui::Dummy(ImVec2(x1 - x0, bar_h));
} else {
// Build left text
std::string left;
left.reserve(256);
left += "kge"; // GUI app name
left += " ";
left += KTE_VERSION_STR;
std::string fname; std::string fname;
try { try {
fname = ed.DisplayNameFor(*buf); fname = ed.DisplayNameFor(*buf);
@@ -400,8 +547,9 @@ GUIRenderer::Draw(Editor &ed)
ImGui::PopClipRect(); ImGui::PopClipRect();
} }
} }
// Advance cursor to after the bar to keep layout consistent // Advance cursor to after the bar to keep layout consistent
ImGui::Dummy(ImVec2(x1 - x0, bar_h)); ImGui::Dummy(ImVec2(x1 - x0, bar_h));
}
} }
ImGui::End(); ImGui::End();

127
GUITheme.h Normal file
View File

@@ -0,0 +1,127 @@
// GUITheme.h - ImGui theme configuration for kte GUI
// Provides a Nord-inspired color palette and style settings.
#pragma once
#include <imgui.h>
namespace kte {
// Convert RGB hex (0xRRGGBB) to ImVec4 with optional alpha
static inline ImVec4
RGBA(unsigned int rgb, float a = 1.0f)
{
float r = ((rgb >> 16) & 0xFF) / 255.0f;
float g = ((rgb >> 8) & 0xFF) / 255.0f;
float b = ((rgb) & 0xFF) / 255.0f;
return ImVec4(r, g, b, a);
}
// Apply a Nord-inspired theme to the current ImGui style.
// Safe to call after ImGui::CreateContext().
static inline void
ApplyNordImGuiTheme()
{
// Nord palette
const ImVec4 nord0 = RGBA(0x2E3440); // darkest bg
const ImVec4 nord1 = RGBA(0x3B4252);
const ImVec4 nord2 = RGBA(0x434C5E);
const ImVec4 nord3 = RGBA(0x4C566A);
const ImVec4 nord4 = RGBA(0xD8DEE9);
const ImVec4 nord6 = RGBA(0xECEFF4); // lightest
const ImVec4 nord8 = RGBA(0x88C0D0); // cyan
const ImVec4 nord9 = RGBA(0x81A1C1); // blue
const ImVec4 nord10 = RGBA(0x5E81AC); // blue dark
const ImVec4 nord12 = RGBA(0xD08770); // orange
const ImVec4 nord13 = RGBA(0xEBCB8B); // yellow
ImGuiStyle &style = ImGui::GetStyle();
// Base style tweaks to suit Nord aesthetics
style.WindowPadding = ImVec2(8.0f, 8.0f);
style.FramePadding = ImVec2(6.0f, 4.0f);
style.CellPadding = ImVec2(6.0f, 4.0f);
style.ItemSpacing = ImVec2(6.0f, 6.0f);
style.ItemInnerSpacing = ImVec2(6.0f, 4.0f);
style.ScrollbarSize = 14.0f;
style.GrabMinSize = 10.0f;
style.WindowRounding = 4.0f;
style.FrameRounding = 3.0f;
style.PopupRounding = 4.0f;
style.GrabRounding = 3.0f;
style.TabRounding = 4.0f;
style.WindowBorderSize = 1.0f;
style.FrameBorderSize = 1.0f;
ImVec4 *colors = style.Colors;
colors[ImGuiCol_Text] = nord4; // primary text
colors[ImGuiCol_TextDisabled] = ImVec4(nord4.x, nord4.y, nord4.z, 0.55f);
colors[ImGuiCol_WindowBg] = nord10;
colors[ImGuiCol_ChildBg] = nord0;
colors[ImGuiCol_PopupBg] = RGBA(0x2E3440, 0.98f);
colors[ImGuiCol_Border] = nord1;
colors[ImGuiCol_BorderShadow] = RGBA(0x000000, 0.0f);
colors[ImGuiCol_FrameBg] = nord2;
colors[ImGuiCol_FrameBgHovered] = nord3;
colors[ImGuiCol_FrameBgActive] = nord10;
colors[ImGuiCol_TitleBg] = nord1;
colors[ImGuiCol_TitleBgActive] = nord3;
colors[ImGuiCol_TitleBgCollapsed] = nord1;
colors[ImGuiCol_MenuBarBg] = nord1;
colors[ImGuiCol_ScrollbarBg] = nord0;
colors[ImGuiCol_ScrollbarGrab] = nord3;
colors[ImGuiCol_ScrollbarGrabHovered] = nord10;
colors[ImGuiCol_ScrollbarGrabActive] = nord9;
colors[ImGuiCol_CheckMark] = nord8;
colors[ImGuiCol_SliderGrab] = nord8;
colors[ImGuiCol_SliderGrabActive] = nord9;
colors[ImGuiCol_Button] = nord3;
colors[ImGuiCol_ButtonHovered] = nord10;
colors[ImGuiCol_ButtonActive] = nord9;
colors[ImGuiCol_Header] = nord3;
colors[ImGuiCol_HeaderHovered] = nord10;
colors[ImGuiCol_HeaderActive] = nord10;
colors[ImGuiCol_Separator] = nord2;
colors[ImGuiCol_SeparatorHovered] = nord10;
colors[ImGuiCol_SeparatorActive] = nord9;
colors[ImGuiCol_ResizeGrip] = ImVec4(nord6.x, nord6.y, nord6.z, 0.12f);
colors[ImGuiCol_ResizeGripHovered] = ImVec4(nord8.x, nord8.y, nord8.z, 0.67f);
colors[ImGuiCol_ResizeGripActive] = nord9;
colors[ImGuiCol_Tab] = nord2;
colors[ImGuiCol_TabHovered] = nord10;
colors[ImGuiCol_TabActive] = nord3;
colors[ImGuiCol_TabUnfocused] = nord2;
colors[ImGuiCol_TabUnfocusedActive] = nord3;
// Docking colors are available only when docking branch is enabled; omit for compatibility
colors[ImGuiCol_TableHeaderBg] = nord2;
colors[ImGuiCol_TableBorderStrong] = nord1;
colors[ImGuiCol_TableBorderLight] = ImVec4(nord1.x, nord1.y, nord1.z, 0.6f);
colors[ImGuiCol_TableRowBg] = ImVec4(nord1.x, nord1.y, nord1.z, 0.2f);
colors[ImGuiCol_TableRowBgAlt] = ImVec4(nord1.x, nord1.y, nord1.z, 0.35f);
colors[ImGuiCol_TextSelectedBg] = ImVec4(nord8.x, nord8.y, nord8.z, 0.35f);
colors[ImGuiCol_DragDropTarget] = nord13;
colors[ImGuiCol_NavHighlight] = nord9;
colors[ImGuiCol_NavWindowingHighlight] = ImVec4(nord6.x, nord6.y, nord6.z, 0.7f);
colors[ImGuiCol_NavWindowingDimBg] = ImVec4(nord0.x, nord0.y, nord0.z, 0.6f);
colors[ImGuiCol_ModalWindowDimBg] = ImVec4(nord0.x, nord0.y, nord0.z, 0.6f);
// Plots
colors[ImGuiCol_PlotLines] = nord8;
colors[ImGuiCol_PlotLinesHovered] = nord9;
colors[ImGuiCol_PlotHistogram] = nord13;
colors[ImGuiCol_PlotHistogramHovered] = nord12;
}
} // namespace kte

55
HelpText.cc Normal file
View File

@@ -0,0 +1,55 @@
/*
* HelpText.cc - embedded/customizable help content
*/
#include "HelpText.h"
std::string
HelpText::Text()
{
// Customize the help text here. This string will be used by C-k h first.
// You can keep it empty to fall back to the manpage or built-in defaults.
// Note: keep newline characters as-is; the renderer splits lines on '\n'.
return std::string(
"KTE - Kyle's Text Editor\n\n"
"About:\n"
" kte is Kyle's Text Editor and is probably ill-suited to everyone else. It was\n"
" inspired by Antirez' kilo text editor by way of someone's writeup of the\n"
" process of writing a text editor from scratch. It has keybindings inspired by\n"
" VDE (and the Wordstar family) and emacs; its spiritual parent is mg(1).\n"
"\n"
"Core keybindings:\n"
" C-k ' Toggle read-only\n"
" C-k - Unindent region\n"
" C-k = Indent region\n"
" C-k C-d Kill entire line\n"
" C-k C-q Quit now (no confirm)\n"
" C-k a Mark all and jump to end\n"
" C-k b Switch buffer\n"
" C-k c Close current buffer\n"
" C-k d Kill to end of line\n"
" C-k e Open file (prompt)\n"
" C-k g Jump to line\n"
" C-k h Show this help\n"
" C-k l Reload buffer from disk\n"
" C-k n Previous buffer\n"
" C-k o Change working directory (prompt)\n"
" C-k p Next buffer\n"
" C-k q Quit (confirm if dirty)\n"
" C-k r Redo\n"
" C-k s Save buffer\n"
" C-k u Undo\n"
" C-k v Toggle visual file picker (GUI)\n"
" C-k w Show working directory\n"
" C-k x Save and quit\n"
"\n"
"ESC/Alt commands:\n"
" ESC q Reflow paragraph\n"
" ESC BACKSPACE Delete previous word\n"
" ESC d Delete next word\n"
" Alt-w Copy region to kill ring\n\n"
"Buffers:\n +HELP+ is read-only. Press C-k ' to toggle if you need to edit; C-k h restores it.\n"
);
}

17
HelpText.h Normal file
View File

@@ -0,0 +1,17 @@
/*
* HelpText.h - embedded/customizable help content
*/
#ifndef KTE_HELPTEXT_H
#define KTE_HELPTEXT_H
#include <string>
class HelpText {
public:
// Returns the embedded help text as a single string with newlines.
// Project maintainers can customize the returned string below
// (in HelpText.cc) without touching the help command logic.
static std::string Text();
};
#endif // KTE_HELPTEXT_H

View File

@@ -33,6 +33,10 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
out = CommandId::Redo; // C-k r (redo) out = CommandId::Redo; // C-k r (redo)
return true; return true;
} }
if (ascii_key == '\'') {
out = CommandId::ToggleReadOnly; // C-k ' (toggle read-only)
return true;
}
switch (k_lower) { switch (k_lower) {
case 'a': case 'a':
@@ -59,6 +63,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
case 'g': case 'g':
out = CommandId::JumpToLine; out = CommandId::JumpToLine;
return true; return true;
case 'h':
out = CommandId::ShowHelp;
return true;
case 'j': case 'j':
out = CommandId::JumpToMark; out = CommandId::JumpToMark;
return true; return true;
@@ -114,7 +121,7 @@ auto
KLookupCtrlCommand(const int ascii_key, CommandId &out) -> bool KLookupCtrlCommand(const int ascii_key, CommandId &out) -> bool
{ {
const int k = KLowerAscii(ascii_key); const int k = KLowerAscii(ascii_key);
switch (k) { switch (k) {
case 'w': case 'w':
out = CommandId::KillRegion; // C-w out = CommandId::KillRegion; // C-w
return true; return true;
@@ -145,6 +152,15 @@ KLookupCtrlCommand(const int ascii_key, CommandId &out) -> bool
case 's': case 's':
out = CommandId::FindStart; out = CommandId::FindStart;
return true; return true;
case 'r':
out = CommandId::RegexFindStart; // C-r regex search
return true;
case 't':
out = CommandId::RegexpReplace; // C-t regex search & replace
return true;
case 'h':
out = CommandId::SearchReplace; // C-h: search & replace
return true;
case 'l': case 'l':
out = CommandId::Refresh; out = CommandId::Refresh;
return true; return true;

View File

@@ -1,10 +1,10 @@
ROADMAP / TODO: ROADMAP / TODO:
- [ ] Search + Replace - [x] Search + Replace
- [ ] Regex search + replace - [x] Regex search + replace
- [ ] The undo system should actually work - [ ] The undo system should actually work
- [ ] Able to mark buffers as read-only - [x] Able to mark buffers as read-only
- [ ] Built-in help text - [x] Built-in help text
- [ ] Shorten paths in the homedir with ~ - [x] Shorten paths in the homedir with ~
- [ ] When the filename is longer than the message window, scoot left to - [x] When the filename is longer than the message window, scoot left to
to keep it in view keep it in view

View File

@@ -1,7 +1,9 @@
#include <algorithm> #include <algorithm>
#include <cstdio> #include <cstdio>
#include <filesystem> #include <filesystem>
#include <cstdlib>
#include <ncurses.h> #include <ncurses.h>
#include <regex>
#include <string> #include <string>
#include "TerminalRenderer.h" #include "TerminalRenderer.h"
@@ -39,100 +41,140 @@ TerminalRenderer::Draw(Editor &ed)
std::size_t coloffs = buf->Coloffs(); std::size_t coloffs = buf->Coloffs();
const int tabw = 8; const int tabw = 8;
for (int r = 0; r < content_rows; ++r) { for (int r = 0; r < content_rows; ++r) {
move(r, 0); move(r, 0);
std::size_t li = rowoffs + static_cast<std::size_t>(r); std::size_t li = rowoffs + static_cast<std::size_t>(r);
std::size_t render_col = 0; std::size_t render_col = 0;
std::size_t src_i = 0; std::size_t src_i = 0;
bool do_hl = ed.SearchActive() && li == ed.SearchMatchY() && ed.SearchMatchLen() > 0; // Compute matches for this line if search highlighting is active
std::size_t mx = do_hl ? ed.SearchMatchX() : 0; bool search_mode = ed.SearchActive() && !ed.SearchQuery().empty();
std::size_t mlen = do_hl ? ed.SearchMatchLen() : 0; std::vector<std::pair<std::size_t, std::size_t>> ranges; // [start, end)
bool hl_on = false; if (search_mode && li < lines.size()) {
int written = 0; std::string sline = static_cast<std::string>(lines[li]);
if (li < lines.size()) { // If regex search prompt is active (RegexSearch or RegexReplaceFind), use regex to compute highlight ranges
const std::string &line = lines[li]; if (ed.PromptActive() && (ed.CurrentPromptKind() == Editor::PromptKind::RegexSearch || ed.CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind)) {
src_i = 0; try {
render_col = 0; std::regex rx(ed.SearchQuery());
while (written < cols) { for (auto it = std::sregex_iterator(sline.begin(), sline.end(), rx);
char ch = ' '; it != std::sregex_iterator(); ++it) {
bool from_src = false; const auto &m = *it;
if (src_i < line.size()) { std::size_t sx = static_cast<std::size_t>(m.position());
unsigned char c = static_cast<unsigned char>(line[src_i]); std::size_t ex = sx + static_cast<std::size_t>(m.length());
if (c == '\t') { ranges.emplace_back(sx, ex);
std::size_t next_tab = tabw - (render_col % tabw); }
if (render_col + next_tab <= coloffs) { } catch (const std::regex_error &) {
render_col += next_tab; // ignore invalid patterns here; status shows error
++src_i; }
continue; } else {
} const std::string &q = ed.SearchQuery();
// Emit spaces for tab std::size_t pos = 0;
if (render_col < coloffs) { while (!q.empty() && (pos = sline.find(q, pos)) != std::string::npos) {
// skip to coloffs ranges.emplace_back(pos, pos + q.size());
std::size_t to_skip = std::min<std::size_t>( pos += q.size();
next_tab, coloffs - render_col); }
render_col += to_skip; }
next_tab -= to_skip; }
} auto is_src_in_hl = [&](std::size_t si) -> bool {
// Now render visible spaces if (ranges.empty()) return false;
while (next_tab > 0 && written < cols) { // ranges are non-overlapping and ordered by construction
bool in_hl = do_hl && src_i >= mx && src_i < mx + mlen; // linear scan is fine for now
// highlight by source index for (const auto &rg : ranges) {
if (in_hl && !hl_on) { if (si < rg.first) break;
attron(A_STANDOUT); if (si >= rg.first && si < rg.second) return true;
hl_on = true; }
} return false;
if (!in_hl && hl_on) { };
attroff(A_STANDOUT); // Track current-match to optionally emphasize
hl_on = false; const bool has_current = ed.SearchActive() && ed.SearchMatchLen() > 0;
} const std::size_t cur_mx = has_current ? ed.SearchMatchX() : 0;
addch(' '); const std::size_t cur_my = has_current ? ed.SearchMatchY() : 0;
++written; const std::size_t cur_mend = has_current ? (ed.SearchMatchX() + ed.SearchMatchLen()) : 0;
++render_col; bool hl_on = false;
--next_tab; bool cur_on = false;
} int written = 0;
++src_i; if (li < lines.size()) {
continue; std::string line = static_cast<std::string>(lines[li]);
} else { src_i = 0;
// normal char render_col = 0;
if (render_col < coloffs) { while (written < cols) {
++render_col; char ch = ' ';
++src_i; bool from_src = false;
continue; if (src_i < line.size()) {
} unsigned char c = static_cast<unsigned char>(line[src_i]);
ch = static_cast<char>(c); if (c == '\t') {
from_src = true; std::size_t next_tab = tabw - (render_col % tabw);
} if (render_col + next_tab <= coloffs) {
} else { render_col += next_tab;
// beyond EOL, fill spaces ++src_i;
ch = ' '; continue;
from_src = false; }
} // Emit spaces for tab
if (do_hl) { if (render_col < coloffs) {
bool in_hl = from_src && src_i >= mx && src_i < mx + mlen; // skip to coloffs
if (in_hl && !hl_on) { std::size_t to_skip = std::min<std::size_t>(
attron(A_STANDOUT); next_tab, coloffs - render_col);
hl_on = true; render_col += to_skip;
} next_tab -= to_skip;
if (!in_hl && hl_on) { }
attroff(A_STANDOUT); // Now render visible spaces
hl_on = false; while (next_tab > 0 && written < cols) {
} bool in_hl = search_mode && is_src_in_hl(src_i);
} bool in_cur = has_current && li == cur_my && src_i >= cur_mx && src_i < cur_mend;
addch(static_cast<unsigned char>(ch)); // Toggle highlight attributes
++written; int attr = 0;
++render_col; if (in_hl) attr |= A_STANDOUT;
if (from_src) if (in_cur) attr |= A_BOLD;
++src_i; if ((attr & A_STANDOUT) && !hl_on) { attron(A_STANDOUT); hl_on = true; }
if (src_i >= line.size() && written >= cols) if (!(attr & A_STANDOUT) && hl_on) { attroff(A_STANDOUT); hl_on = false; }
break; if ((attr & A_BOLD) && !cur_on) { attron(A_BOLD); cur_on = true; }
} if (!(attr & A_BOLD) && cur_on) { attroff(A_BOLD); cur_on = false; }
} addch(' ');
if (hl_on) { ++written;
attroff(A_STANDOUT); ++render_col;
hl_on = false; --next_tab;
} }
clrtoeol(); ++src_i;
} continue;
} else {
// normal char
if (render_col < coloffs) {
++render_col;
++src_i;
continue;
}
ch = static_cast<char>(c);
from_src = true;
}
} else {
// beyond EOL, fill spaces
ch = ' ';
from_src = false;
}
bool in_hl = search_mode && from_src && is_src_in_hl(src_i);
bool in_cur = has_current && li == cur_my && from_src && src_i >= cur_mx && src_i < cur_mend;
if (in_hl && !hl_on) { attron(A_STANDOUT); hl_on = true; }
if (!in_hl && hl_on) { attroff(A_STANDOUT); hl_on = false; }
if (in_cur && !cur_on) { attron(A_BOLD); cur_on = true; }
if (!in_cur && cur_on) { attroff(A_BOLD); cur_on = false; }
addch(static_cast<unsigned char>(ch));
++written;
++render_col;
if (from_src)
++src_i;
if (src_i >= line.size() && written >= cols)
break;
}
}
if (hl_on) {
attroff(A_STANDOUT);
hl_on = false;
}
if (cur_on) {
attroff(A_BOLD);
cur_on = false;
}
clrtoeol();
}
// Place terminal cursor at logical position accounting for tabs and coloffs // Place terminal cursor at logical position accounting for tabs and coloffs
std::size_t cy = buf->Cury(); std::size_t cy = buf->Cury();
@@ -149,13 +191,71 @@ TerminalRenderer::Draw(Editor &ed)
mvaddstr(0, 0, "[no buffer]"); mvaddstr(0, 0, "[no buffer]");
} }
// Status line (inverse) — left: app/version/buffer/dirty, middle: message, right: cursor/mark // Status line (inverse)
move(rows - 1, 0); move(rows - 1, 0);
attron(A_REVERSE); attron(A_REVERSE);
// Fill the status line with spaces first // Fill the status line with spaces first
for (int i = 0; i < cols; ++i) for (int i = 0; i < cols; ++i)
addch(' '); addch(' ');
// If a prompt is active, replace the status bar with the full prompt text
if (ed.PromptActive()) {
// Build prompt text: "Label: text" and shorten HOME path for file-related prompts
std::string label = ed.PromptLabel();
std::string ptext = ed.PromptText();
auto kind = ed.CurrentPromptKind();
if (kind == Editor::PromptKind::OpenFile || kind == Editor::PromptKind::SaveAs ||
kind == Editor::PromptKind::Chdir) {
const char *home_c = std::getenv("HOME");
if (home_c && *home_c) {
std::string home(home_c);
// Ensure we match only at the start
if (ptext.rfind(home, 0) == 0) {
std::string rest = ptext.substr(home.size());
if (rest.empty())
ptext = "~";
else if (rest[0] == '/' || rest[0] == '\\')
ptext = std::string("~") + rest;
}
}
}
// Prefer keeping the tail of the filename visible when it exceeds the window
std::string msg;
if (!label.empty()) {
msg = label + ": ";
}
// When dealing with file-related prompts, left-trim the filename text so the tail stays visible
if ((kind == Editor::PromptKind::OpenFile || kind == Editor::PromptKind::SaveAs || kind == Editor::PromptKind::Chdir) && cols > 0) {
int avail = cols - static_cast<int>(msg.size());
if (avail <= 0) {
// No room for label; fall back to showing the rightmost portion of the whole string
std::string whole = msg + ptext;
if ((int)whole.size() > cols)
whole = whole.substr(whole.size() - cols);
msg = whole;
} else {
if ((int)ptext.size() > avail) {
ptext = ptext.substr(ptext.size() - avail);
}
msg += ptext;
}
} else {
// Non-file prompts: simple concatenation and clip by terminal
msg += ptext;
}
// Draw left-aligned, clipped to width
if (!msg.empty())
mvaddnstr(rows - 1, 0, msg.c_str(), std::max(0, cols));
// End status rendering for prompt mode
attroff(A_REVERSE);
// Restore logical cursor position in content area
if (saved_cur_y >= 0 && saved_cur_x >= 0)
move(saved_cur_y, saved_cur_x);
return;
}
// Build left segment // Build left segment
std::string left; std::string left;
@@ -195,6 +295,9 @@ TerminalRenderer::Draw(Editor &ed)
left += fname; left += fname;
if (b && b->Dirty()) if (b && b->Dirty())
left += " *"; left += " *";
// Append read-only indicator
if (b && b->IsReadOnly())
left += " [RO]";
// Append total line count as "<n>L" // Append total line count as "<n>L"
if (b) { if (b) {
unsigned long lcount = static_cast<unsigned long>(b->Rows().size()); unsigned long lcount = static_cast<unsigned long>(b->Rows().size());
@@ -243,10 +346,10 @@ TerminalRenderer::Draw(Editor &ed)
if (llen > 0) if (llen > 0)
mvaddnstr(rows - 1, 0, left.c_str(), llen); mvaddnstr(rows - 1, 0, left.c_str(), llen);
// Draw right, flush to end // Draw right, flush to end
int rstart = std::max(0, cols - rlen); int rstart = std::max(0, cols - rlen);
if (rlen > 0) if (rlen > 0)
mvaddnstr(rows - 1, rstart, right.c_str(), rlen); mvaddnstr(rows - 1, rstart, right.c_str(), rlen);
// Middle message // Middle message
const std::string &msg = ed.Status(); const std::string &msg = ed.Status();
@@ -262,7 +365,7 @@ TerminalRenderer::Draw(Editor &ed)
} }
} }
attroff(A_REVERSE); attroff(A_REVERSE);
// Restore terminal cursor to the content position so a visible caret // Restore terminal cursor to the content position so a visible caret
// remains in the editing area (not on the status line). // remains in the editing area (not on the status line).

View File

@@ -1,83 +1,107 @@
#include "UndoSystem.h" #include "UndoSystem.h"
#include "Buffer.h" #include "Buffer.h"
#include <cassert>
#include <cstdio>
UndoSystem::UndoSystem(Buffer &owner, UndoTree &tree) UndoSystem::UndoSystem(Buffer &owner, UndoTree &tree)
: buf_(&owner), tree_(tree) {} : buf_(&owner), tree_(tree) {}
void void
UndoSystem::Begin(UndoType type) UndoSystem::Begin(UndoType type)
{ {
// Reuse pending if batching conditions are met #ifdef KTE_UNDO_DEBUG
const int row = static_cast<int>(buf_->Cury()); debug_log("Begin");
const int col = static_cast<int>(buf_->Curx()); #endif
if (tree_.pending && tree_.pending->type == type && tree_.pending->row == row) { // Reuse pending if batching conditions are met
if (type == UndoType::Delete) { const int row = static_cast<int>(buf_->Cury());
// Support batching both forward deletes (DeleteChar) and backspace (prepend case) const int col = static_cast<int>(buf_->Curx());
// Forward delete: cursor stays at anchor col; expected == col if (tree_.pending && tree_.pending->type == type && tree_.pending->row == row) {
const auto anchor = static_cast<std::size_t>(tree_.pending->col); if (type == UndoType::Delete) {
if (anchor + tree_.pending->text.size() == static_cast<std::size_t>(col)) { // Support batching both forward deletes (DeleteChar) and backspace (prepend case)
pending_prepend_ = false; // Forward delete: cursor stays at anchor col; keep batching when col == anchor
return; // keep batching forward delete const auto anchor = static_cast<std::size_t>(tree_.pending->col);
} if (anchor == static_cast<std::size_t>(col)) {
// Backspace: cursor moved left by 1; allow extend if col + text.size() == anchor pending_prepend_ = false;
if (static_cast<std::size_t>(col) + tree_.pending->text.size() == anchor) { return; // keep batching forward delete
// Move anchor one left to new cursor column; next Append should prepend }
tree_.pending->col = col; // Backspace: cursor moved left by exactly one position relative to current anchor.
pending_prepend_ = true; // Extend batch by shifting anchor left and prepending the deleted byte.
return; if (static_cast<std::size_t>(col) + 1 == anchor) {
} tree_.pending->col = col;
} else { pending_prepend_ = true;
std::size_t expected = static_cast<std::size_t>(tree_.pending->col) + tree_.pending->text. return;
size(); }
if (expected == static_cast<std::size_t>(col)) { } else {
pending_prepend_ = false; std::size_t expected = static_cast<std::size_t>(tree_.pending->col) + tree_.pending->text.
return; // keep batching size();
} if (expected == static_cast<std::size_t>(col)) {
} pending_prepend_ = false;
} return; // keep batching
// Otherwise commit any existing batch and start a new node }
commit(); }
auto *node = new UndoNode(); }
node->type = type; // Otherwise commit any existing batch and start a new node
node->row = row; commit();
node->col = col; auto *node = new UndoNode();
node->child = nullptr; node->type = type;
node->next = nullptr; node->row = row;
tree_.pending = node; node->col = col;
pending_prepend_ = false; node->child = nullptr;
node->next = nullptr;
tree_.pending = node;
pending_prepend_ = false;
#ifdef KTE_UNDO_DEBUG
debug_log("Begin:new");
#endif
// Assert pending is detached from the tree
assert(tree_.pending && "pending must exist after Begin");
assert(tree_.pending != tree_.root);
assert(tree_.pending != tree_.current);
assert(tree_.pending != tree_.saved);
assert(!is_descendant(tree_.root, tree_.pending));
} }
void void
UndoSystem::Append(char ch) UndoSystem::Append(char ch)
{ {
if (!tree_.pending) if (!tree_.pending)
return; return;
if (pending_prepend_ && tree_.pending->type == UndoType::Delete) { if (pending_prepend_ && tree_.pending->type == UndoType::Delete) {
// Prepend for backspace so that text is in increasing column order // Prepend for backspace so that text is in increasing column order
tree_.pending->text.insert(tree_.pending->text.begin(), ch); tree_.pending->text.insert(tree_.pending->text.begin(), ch);
} else { } else {
tree_.pending->text.push_back(ch); tree_.pending->text.push_back(ch);
} }
#ifdef KTE_UNDO_DEBUG
debug_log("Append:ch");
#endif
} }
void void
UndoSystem::Append(std::string_view text) UndoSystem::Append(std::string_view text)
{ {
if (!tree_.pending) if (!tree_.pending)
return; return;
tree_.pending->text.append(text.data(), text.size()); tree_.pending->text.append(text.data(), text.size());
#ifdef KTE_UNDO_DEBUG
debug_log("Append:sv");
#endif
} }
void void
UndoSystem::commit() UndoSystem::commit()
{ {
if (!tree_.pending) #ifdef KTE_UNDO_DEBUG
return; debug_log("commit:enter");
#endif
if (!tree_.pending)
return;
// If we have redo branches from current, discard them (non-linear behavior) // If we have redo branches from current, discard them (non-linear behavior)
if (tree_.current && tree_.current->child) { if (tree_.current && tree_.current->child) {
@@ -103,78 +127,98 @@ UndoSystem::commit()
tree_.current->child = tree_.pending; tree_.current->child = tree_.pending;
tree_.current = tree_.pending; tree_.current = tree_.pending;
} }
tree_.pending = nullptr; tree_.pending = nullptr;
update_dirty_flag(); update_dirty_flag();
#ifdef KTE_UNDO_DEBUG
debug_log("commit:done");
#endif
// post-conditions
assert(tree_.pending == nullptr && "pending must be cleared after commit");
} }
void void
UndoSystem::undo() UndoSystem::undo()
{ {
// Close any pending batch // Close any pending batch
commit(); commit();
if (!tree_.current) if (!tree_.current)
return; return;
UndoNode *parent = find_parent(tree_.root, tree_.current); UndoNode *parent = find_parent(tree_.root, tree_.current);
UndoNode *node = tree_.current; UndoNode *node = tree_.current;
// Apply inverse of current node // Apply inverse of current node
apply(node, -1); apply(node, -1);
tree_.current = parent; tree_.current = parent;
update_dirty_flag(); update_dirty_flag();
#ifdef KTE_UNDO_DEBUG
debug_log("undo");
#endif
} }
void void
UndoSystem::redo() UndoSystem::redo()
{ {
// Redo next child along current timeline // Redo next child along current timeline
if (tree_.pending) { if (tree_.pending) {
// If app added pending edits, finalize them before redo chain // If app added pending edits, finalize them before redo chain
commit(); commit();
} }
UndoNode *next = nullptr; UndoNode *next = nullptr;
if (!tree_.current) { if (!tree_.current) {
next = tree_.root; // if nothing yet, try applying first node next = tree_.root; // if nothing yet, try applying first node
} else { } else {
next = tree_.current->child; next = tree_.current->child;
} }
if (!next) if (!next)
return; return;
apply(next, +1); apply(next, +1);
tree_.current = next; tree_.current = next;
update_dirty_flag(); update_dirty_flag();
#ifdef KTE_UNDO_DEBUG
debug_log("redo");
#endif
} }
void void
UndoSystem::mark_saved() UndoSystem::mark_saved()
{ {
tree_.saved = tree_.current; tree_.saved = tree_.current;
update_dirty_flag(); update_dirty_flag();
#ifdef KTE_UNDO_DEBUG
debug_log("mark_saved");
#endif
} }
void void
UndoSystem::discard_pending() UndoSystem::discard_pending()
{ {
if (tree_.pending) { if (tree_.pending) {
delete tree_.pending; delete tree_.pending;
tree_.pending = nullptr; tree_.pending = nullptr;
} }
#ifdef KTE_UNDO_DEBUG
debug_log("discard_pending");
#endif
} }
void void
UndoSystem::clear() UndoSystem::clear()
{ {
if (tree_.root) { if (tree_.root) {
free_node(tree_.root); free_node(tree_.root);
} }
if (tree_.pending) { if (tree_.pending) {
delete tree_.pending; delete tree_.pending;
} }
tree_.root = tree_.current = tree_.saved = tree_.pending = nullptr; tree_.root = tree_.current = tree_.saved = tree_.pending = nullptr;
update_dirty_flag(); update_dirty_flag();
#ifdef KTE_UNDO_DEBUG
debug_log("clear");
#endif
} }
@@ -282,14 +326,62 @@ UndoSystem::find_parent(UndoNode *from, UndoNode *target)
void void
UndoSystem::update_dirty_flag() UndoSystem::update_dirty_flag()
{ {
// dirty if current != saved // dirty if current != saved
bool dirty = (tree_.current != tree_.saved); bool dirty = (tree_.current != tree_.saved);
buf_->SetDirty(dirty); buf_->SetDirty(dirty);
} }
void void
UndoSystem::UpdateBufferReference(Buffer &new_buf) UndoSystem::UpdateBufferReference(Buffer &new_buf)
{ {
buf_ = &new_buf; buf_ = &new_buf;
}
// ---- Debug helpers ----
const char *
UndoSystem::type_str(UndoType t)
{
switch (t) {
case UndoType::Insert: return "Insert";
case UndoType::Delete: return "Delete";
case UndoType::Paste: return "Paste";
case UndoType::Newline: return "Newline";
case UndoType::DeleteRow: return "DeleteRow";
}
return "?";
}
bool
UndoSystem::is_descendant(UndoNode *root, const UndoNode *target)
{
if (!root || !target) return false;
if (root == target) return true;
for (UndoNode *child = root->child; child != nullptr; child = child->next) {
if (is_descendant(child, target)) return true;
}
return false;
}
void
UndoSystem::debug_log(const char *op) const
{
#ifdef KTE_UNDO_DEBUG
int row = static_cast<int>(buf_->Cury());
int col = static_cast<int>(buf_->Curx());
const UndoNode *p = tree_.pending;
std::fprintf(stderr,
"[UNDO] %s cur=(%d,%d) pending=%p t=%s r=%d c=%d nlen=%zu current=%p saved=%p\n",
op,
row, col,
(const void*)p,
p ? type_str(p->type) : "-",
p ? p->row : -1,
p ? p->col : -1,
p ? p->text.size() : 0,
(void*)tree_.current,
(void*)tree_.saved);
#else
(void)op;
#endif
} }

View File

@@ -2,6 +2,8 @@
#define KTE_UNDOSYSTEM_H #define KTE_UNDOSYSTEM_H
#include <string_view> #include <string_view>
#include <cstddef>
#include <cstdint>
#include "UndoTree.h" #include "UndoTree.h"
@@ -10,7 +12,7 @@ class Buffer;
class UndoSystem { class UndoSystem {
public: public:
explicit UndoSystem(Buffer &owner, UndoTree &tree); explicit UndoSystem(Buffer &owner, UndoTree &tree);
void Begin(UndoType type); void Begin(UndoType type);
@@ -28,23 +30,28 @@ public:
void discard_pending(); void discard_pending();
void clear(); void clear();
void UpdateBufferReference(Buffer &new_buf); void UpdateBufferReference(Buffer &new_buf);
private: private:
void apply(const UndoNode *node, int direction); // +1 redo, -1 undo void apply(const UndoNode *node, int direction); // +1 redo, -1 undo
void free_node(UndoNode *node); void free_node(UndoNode *node);
void free_branch(UndoNode *node); // frees redo siblings only void free_branch(UndoNode *node); // frees redo siblings only
UndoNode *find_parent(UndoNode *from, UndoNode *target); UndoNode *find_parent(UndoNode *from, UndoNode *target);
void update_dirty_flag(); // Debug helpers (compiled only when KTE_UNDO_DEBUG is defined)
void debug_log(const char *op) const;
static const char *type_str(UndoType t);
static bool is_descendant(UndoNode *root, const UndoNode *target);
void update_dirty_flag();
Buffer *buf_; Buffer *buf_;
UndoTree &tree_; UndoTree &tree_;
// Internal hint for Delete batching: whether next Append() should prepend // Internal hint for Delete batching: whether next Append() should prepend
bool pending_prepend_ = false; bool pending_prepend_ = false;
}; };
#endif // KTE_UNDOSYSTEM_H #endif // KTE_UNDOSYSTEM_H

View File

@@ -1,51 +0,0 @@
{
lib,
stdenv,
cmake,
ncurses,
SDL2,
libGL,
xorg,
installShellFiles,
...
}:
let
cmakeContent = builtins.readFile ./CMakeLists.txt;
cmakeLines = lib.splitString "\n" cmakeContent;
versionLine = lib.findFirst (l: builtins.match ".*set\\(KTE_VERSION \".+\"\\).*" l != null) (throw "KTE_VERSION not found in CMakeLists.txt") cmakeLines;
version = builtins.head (builtins.match ".*set\\(KTE_VERSION \"(.+)\"\\).*" versionLine);
in
stdenv.mkDerivation {
pname = "kte";
inherit version;
src = lib.cleanSource ./.;
nativeBuildInputs = [
cmake
ncurses
SDL2
libGL
xorg.libX11
installShellFiles
];
cmakeFlags = [
"-DBUILD_GUI=ON"
"-DCMAKE_BUILD_TYPE=Debug"
];
installPhase = ''
runHook preInstall
mkdir -p $out/bin
cp kte $out/bin/
cp kge $out/bin/
installManPage ../docs/kte.1
installManPage ../docs/kge.1
runHook postInstall
'';
}

View File

@@ -1,42 +0,0 @@
{
lib,
stdenv,
cmake,
ncurses,
installShellFiles,
...
}:
let
cmakeContent = builtins.readFile ./CMakeLists.txt;
cmakeLines = lib.splitString "\n" cmakeContent;
versionLine = lib.findFirst (l: builtins.match ".*set\\(KTE_VERSION \".+\"\\).*" l != null) (throw "KTE_VERSION not found in CMakeLists.txt") cmakeLines;
version = builtins.head (builtins.match ".*set\\(KTE_VERSION \"(.+)\"\\).*" versionLine);
in
stdenv.mkDerivation {
pname = "kte";
inherit version;
src = lib.cleanSource ./.;
nativeBuildInputs = [
cmake
ncurses
installShellFiles
];
cmakeFlags = [
"-DBUILD_GUI=OFF"
"-DCMAKE_BUILD_TYPE=Debug"
];
installPhase = ''
runHook preInstall
mkdir -p $out/bin
cp kte $out/bin/
installManPage ../docs/kte.1
runHook postInstall
'';
}

View File

@@ -7,12 +7,16 @@
libGL, libGL,
xorg, xorg,
installShellFiles, installShellFiles,
graphical ? false,
... ...
}: }:
let let
cmakeContent = builtins.readFile ./CMakeLists.txt; cmakeContent = builtins.readFile ./CMakeLists.txt;
cmakeLines = lib.splitString "\n" cmakeContent; cmakeLines = lib.splitString "\n" cmakeContent;
versionLine = lib.findFirst (l: builtins.match ".*set\\(KTE_VERSION \".+\"\\).*" l != null) (throw "KTE_VERSION not found in CMakeLists.txt") cmakeLines; versionLine = lib.findFirst (
l: builtins.match ".*set\\(KTE_VERSION \".+\"\\).*" l != null
) (throw "KTE_VERSION not found in CMakeLists.txt") cmakeLines;
version = builtins.head (builtins.match ".*set\\(KTE_VERSION \"(.+)\"\\).*" versionLine); version = builtins.head (builtins.match ".*set\\(KTE_VERSION \"(.+)\"\\).*" versionLine);
in in
stdenv.mkDerivation { stdenv.mkDerivation {
@@ -24,14 +28,16 @@ stdenv.mkDerivation {
nativeBuildInputs = [ nativeBuildInputs = [
cmake cmake
ncurses ncurses
installShellFiles
]
++ lib.optionals graphical [
SDL2 SDL2
libGL libGL
xorg.libX11 xorg.libX11
installShellFiles
]; ];
cmakeFlags = [ cmakeFlags = [
"-DBUILD_GUI=ON" "-DBUILD_GUI=${if graphical then "ON" else "OFF"}"
"-DCMAKE_BUILD_TYPE=Debug" "-DCMAKE_BUILD_TYPE=Debug"
]; ];
@@ -40,11 +46,17 @@ stdenv.mkDerivation {
mkdir -p $out/bin mkdir -p $out/bin
cp kte $out/bin/ cp kte $out/bin/
cp kge $out/bin/
installManPage ../docs/kte.1 installManPage ../docs/kte.1
installManPage ../docs/kge.1
''
+ lib.optionalString graphical ''
cp kge $out/bin/
installManPage ../docs/kge.1
mkdir -p $out/share/icons
cp ../kge.png $out/share/icons/
''
+ ''
runHook postInstall runHook postInstall
''; '';
} }

279
docs/undo-roadmap.md Normal file
View File

@@ -0,0 +1,279 @@
Undo System Overhaul Roadmap (emacs-style undo-tree)
Context: macOS, C++17 project, ncurses terminal and SDL2/ImGui GUI frontends. Date: 2025-12-01.
Purpose
- Define a clear, incremental plan to implement a robust, non-linear undo system inspired by emacs' undo-tree.
- Align implementation with docs/undo.md and fix gaps observed in docs/undo-state.md.
- Provide test cases and acceptance criteria so a junior engineer or agentic coding system can execute the plan safely.
References
- Specification: docs/undo.md (API, invariants, batching rules, raw buffer ops)
- Current snapshot and recent fix: docs/undo-state.md (GUI mapping notes; Begin/Append ordering fix)
- Code: UndoSystem.{h,cc}, UndoTree.{h,cc}, UndoNode.{h,cc}, Buffer.{h,cc}, Command.{h,cc}, GUI/Terminal InputHandlers,
KKeymap.
Instrumentation (KTE_UNDO_DEBUG)
- How to enable
- Build with the CMake option `-DKTE_UNDO_DEBUG=ON` to enable concise instrumentation logs from `UndoSystem`.
- The following targets receive the `KTE_UNDO_DEBUG` compile definition when ON:
- `kte` (terminal), `kge` (GUI), and `test_undo` (tests).
- Examples:
```sh
# Terminal build with tests and instrumentation ON
cmake -S . -B cmake-build-term -DBUILD_TESTS=ON -DBUILD_GUI=OFF -DKTE_UNDO_DEBUG=ON
cmake --build cmake-build-term --target test_undo -j
./cmake-build-term/test_undo 2> undo.log
# GUI build (requires SDL2/OpenGL/Freetype toolchain) with instrumentation ON
cmake -S . -B cmake-build-gui -DBUILD_GUI=ON -DKTE_UNDO_DEBUG=ON
cmake --build cmake-build-gui --target kge -j
# Run kge and perform actions; logs go to stderr
```
- What it logs
- Each Begin/Append/commit/undo/redo operation prints a single `[UNDO]` line with:
- current cursor `(row,col)`, pointer to `pending`, its type/row/col/text-size, and pointers to `current`/`saved`.
- Example fields: `[UNDO] Begin cur=(0,0) pending=0x... t=Insert r=0 c=0 nlen=2 current=0x... saved=0x...`
- Example trace snippets
- Typing a contiguous word ("Hello") batches into a single Insert node; one commit occurs before the subsequent undo:
```text
[UNDO] Begin cur=(0,0) pending=0x0 t=- r=-1 c=-1 nlen=0 current=0x0 saved=0x0
[UNDO] commit:enter cur=(0,0) pending=0x0 t=- r=-1 c=-1 nlen=0 current=0x0 saved=0x0
[UNDO] Begin:new cur=(0,0) pending=0x... t=Insert r=0 c=0 nlen=0 current=0x0 saved=0x0
[UNDO] Append:sv cur=(0,0) pending=0x... t=Insert r=0 c=0 nlen=1 current=0x0 saved=0x0
... (more Append as characters are typed) ...
[UNDO] commit:enter cur=(0,5) pending=0x... t=Insert r=0 c=0 nlen=5 current=0x0 saved=0x0
[UNDO] commit:done cur=(0,5) pending=0x0 t=- r=-1 c=-1 nlen=0 current=0x... saved=0x0
```
- Undo then Redo across that batch:
```text
[UNDO] commit:enter cur=(0,5) pending=0x0 t=- r=-1 c=-1 nlen=0 current=0x... saved=0x0
[UNDO] undo cur=(0,5) pending=0x0 t=- r=-1 c=-1 nlen=0 current=0x0 saved=0x0
[UNDO] commit:enter cur=(0,5) pending=0x0 t=- r=-1 c=-1 nlen=0 current=0x0 saved=0x0
[UNDO] redo cur=(0,5) pending=0x0 t=- r=-1 c=-1 nlen=0 current=0x... saved=0x0
```
- Newline and backspace/delete traces follow the same pattern with `t=Newline` or `t=Delete` and immediate commit for newline.
Capture by running `kge`/`kte` with `KTE_UNDO_DEBUG=ON` and performing the actions; append representative 36 line snippets to docs.
Notes
- Pointer values and exact cursor positions in the logs depend on the runtime and actions; this is expected.
- Keep `KTE_UNDO_DEBUG` OFF by default in CI/release to avoid noisy logs and any performance impact.
̄1) Current State Summary (from docs/undo-state.md)
- Terminal (kte): Keybindings and UndoSystem integration have been stable.
- GUI (kge): Previously, C-k u/U mapping and SDL TEXTINPUT suppression had issues on macOS; these were debugged. The
core root cause of “status shows Undone but no change” was fixed by moving UndoSystem::Begin/Append/commit to occur
after buffer modifications/cursor updates so batching conditions see the correct cursor.
- Undo core exists with tree invariants, saved marker/dirty flag mirroring, batching for Insert/Delete, and Newline as a
single-step undo.
Gaps/Risks
- Event-path unification between KEYDOWN and TEXTINPUT across platforms (macOS specifics).
- Comprehensive tests for branching, GC/limits, multi-line operations, and UTF-8 text input.
- Advanced/compound command grouping and future region operations.
2) Design Goals (emacs-like undo-tree)
- Per-buffer, non-linear undo tree: new edits after undo create a branch; existing redo branches are discarded.
- Batching: insert/backspace/paste/newline grouped into sensible units to match user expectations.
- Silent apply during undo/redo (no re-recording), using raw Buffer methods only.
- Correct saved/dirty tracking and robust pending node lifecycle (detached until commit).
- Efficient memory behavior; optional pruning limits similar to emacs (undo-limit, undo-strong-limit).
- Deterministic behavior across terminal and GUI frontends.
3) Invariants and API (must align with docs/undo.md)
- UndoTree holds root/current/saved/pending; pending is detached and only linked on commit.
- Begin(type) reuses pending only if: same type, same row, and pending->col + pending->text.size() == current cursor
col (or prepend rules for backspace sequences); otherwise it commits and starts a new node.
- commit(): frees redo siblings from current, attaches pending as current->child, advances current, clears pending;
nullifies saved marker if diverged.
- undo()/redo(): move current and apply the node using low-level Buffer APIs that do not trigger undo recording.
- mark_saved(): updates saved pointer and dirty flag (dirty ⇔ current != saved).
- discard_pending()/clear(): lifecycle for buffer close/reset/new file.
4) Phased Roadmap
Phase 0 — Baseline & Instrumentation (1 day)
- Audit UndoSystem against docs/undo.md invariants; ensure apply() uses only raw Buffer ops.
- Verify Begin/Append ordering across all edit commands: insert, backspace, delete, newline, paste.
- Add a temporary debug toggle (compile-time or editor flag) to log Begin/Append/commit/undo/redo, cursor(row,col), node
sizes, and pending state. Include assertions for: pending detached, commit clears pending, redo branch freed on new
commit, and correct batching preconditions.
- Deliverables: Short log from typing/undo/redo scenarios; instrumentation behind a macro so it can be removed.
Phase 1 — Input Path Unification & Batching Rules (12 days)
- Ensure all printable text insertion (terminal and GUI) flows through CommandId::InsertText and reaches UndoSystem
Begin/Append. On SDL, handle KEYDOWN vs TEXTINPUT consistently; always suppress trailing TEXTINPUT after k-prefix
suffix commands.
- Commit boundaries: at k-prefix entry, before Undo/Redo, on cursor movement, on focus/file ops, and before any
non-editing command that should separate undo units.
- Batching heuristics:
- Insert: same row, contiguous columns; Append(std::string_view) handles multi-character text (pastes, IME).
- Backspace: prepend batching in increasing column order (store deleted text in forward order).
- Delete (forward): contiguous at same row/col.
- Newline: record as UndoType::Newline and immediately commit (single-step undo for line splits/joins).
- Deliverables: Manual tests pass for typing/backspace/delete/newline/paste; GUI C-k u/U work as expected on macOS.
Phase 2 — Tree Limits & GC (1 day)
- Add configurable memory/size limits for undo data (soft and strong limits like emacs). Implement pruning of oldest
ancestors or deep redo branches while preserving recent edits. Provide stats (node count, bytes in text storage).
- Deliverables: Config hooks, tests demonstrating pruning without violating apply/undo invariants.
Phase 3 — Compound Commands & Region Ops (23 days)
- Introduce an optional RAII-style UndoTransaction to group multi-step commands (indent region, kill region, rectangle
ops) into a single undo step. Internally this just sequences Begin/Append and ensures commit even on early returns.
- Support row operations (InsertRow/DeleteRow) with proper raw Buffer calls. Ensure join_lines/split_line are handled by
Newline nodes or dedicated types if necessary.
- Deliverables: Commands updated to use transactions when appropriate; tests for region delete/indent and multi-line
paste.
Phase 4 — Developer UX & Diagnostics (1 day)
- Add a dev command to dump the undo tree (preorder) with markers for current/saved and pending (detached). For GUI,
optionally expose a simple ImGui debug window (behind a compile flag) that visualizes the current branch.
- Editor status improvements: show short status codes for undo/redo and when a new branch was created or redo discarded.
- Deliverables: Tree dump command; example output in docs.
Phase 5 — Comprehensive Tests & Property Checks (23 days)
- Unit tests (extend test_undo.cc):
- Insert batching: type "Hello" then one undo removes all; redo restores.
- Backspace batching: type "Hello", backspace 3×, undo → restores the 3; redo → re-applies deletion.
- Delete batching (forward delete) with cursor not moving.
- Newline: split a line and undo to join; join a line (via backspace at col 0) and undo to split.
- Branching: type "abc", undo twice, type "X" → redo history discarded; ensure redo no longer restores 'b'/'c'.
- Saved/dirty: mark_saved after typing; ensure dirty flag toggles correctly after undo/redo; saved marker tracks the
node.
- discard_pending: create pending by typing, then move cursor or invoke commit boundary; ensure pending is attached;
also ensure discard on buffer close clears pending.
- clear(): resets state with no leaks; tree pointers null.
- UTF-8 input: insert multi-byte characters via InsertText with multi-char std::string; ensure counts/col tracking
behave (text stored as bytes; editor col policy consistent within kte).
- Integration tests (TestFrontend):
- Both TerminalFrontend and GUIFrontend: simulate text input and commands, including k-prefix C-k u/U.
- Paste scenarios: multi-character insertions batched as one.
- Property tests (optional but recommended):
- Generate random sequences of edits; record them; then apply undo until root and redo back to the end → buffer
contents match at each step; no crashes; dirty flag transitions consistent. Seed-based to reproduce failures.
- Redo-branch discard property: any new edit after undo must eliminate redo path; redoing should be impossible
afterward.
- Deliverables: Tests merged and passing on CI for both frontends; failures block changes to undo core.
Phase 6 — Performance & Stress (0.51 day)
- Stress test with large files and long edit sequences. Target: smooth typing at 10k+ ops/minute on commodity hardware;
memory growth bounded when GC limits enabled.
- Deliverables: Basic perf notes; optional lightweight benchmarks.
5) Acceptance Criteria
- Conformance to docs/undo.md invariants and API surface (including raw Buffer operations for apply()).
- Repro checklist passes:
- Type text; single-step undo/redo works and respects batching.
- Backspace/delete batching works.
- Newline split/join are single-step undo/redo.
- Branching works: undo, then type → redo path is discarded; no ghost redo.
- Saved/dirty flags accurate across undo/redo and diverge/rejoin paths.
- No pending nodes leaked on buffer close/reload; no re-recording during undo/redo.
- Behavior identical across terminal and GUI input paths.
- Tests added for all above; CI green.
6) Concrete Work Items by File
- UndoSystem.h/cc:
- Re-verify Begin/Append ordering; enforce batching invariants; prepend logic for backspace; immediate commit for
newline.
- Implement/verify apply() uses only Buffer raw methods: insert_text/delete_text/split_line/join_lines/row ops.
- Add limits (configurable) and stats; add discard_pending safety paths.
- Buffer.h/cc:
- Ensure raw methods exist and do not trigger undo; ensure UpdateBufferReference is correctly used when
replacing/renaming the underlying buffer.
- Call undo.commit() on cursor movement and non-editing commands (via Command layer integration).
- Command.cc:
- Ensure all edit commands drive UndoSystem correctly; commit at k-prefix entry and before Undo/Redo.
- Introduce UndoTransaction for compound commands when needed.
- GUIInputHandler.cc / TerminalInputHandler.cc / KKeymap.cc:
- Ensure unified InsertText path; suppress SDL_TEXTINPUT when a k-prefix suffix produced a command; preserve case
mapping.
- Tests: test_undo.cc (extend) + new tests (e.g., test_undo_branching.cc, test_undo_multiline.cc).
7) Example Test Cases (sketches)
- Branch discard after undo:
1) InsertText("abc"); Undo(); Undo(); InsertText("X"); Redo();
Expected: Redo is a no-op (or status indicates no redo), buffer is "aX".
- Newline split/join:
1) InsertText("ab"); Newline(); InsertText("c"); Undo();
Expected: single undo joins lines → buffer "abc" on one line at original join point; Redo() splits again.
- Backspace batching:
1) InsertText("hello"); Backspace×3; Undo();
Expected: restores "hello".
- UTF-8 insertion:
1) InsertText("😀汉"); Undo(); Redo();
Expected: content unchanged across cycles; no crashes.
- Saved/dirty transitions:
1) InsertText("hi"); mark_saved(); InsertText("!"); Undo(); Redo();
Expected: dirty false after mark_saved; dirty true after InsertText("!"); dirty returns to false after Undo();
true again after Redo().
8) Risks & Mitigations
- SDL/macOS event ordering (KEYDOWN vs TEXTINPUT, IME): Mitigate by suppressing TEXTINPUT on mapped k-prefix suffixes;
optionally temporarily disable SDL text input during k-prefix suffix mapping; add targeted diagnostics.
- UTF-8 width vs byte-length: Store bytes in UndoNode::text; keep column logic consistent with existing Buffer
semantics.
- Memory growth: Add GC/limits and provide a way to clear/reduce history for huge sessions.
- Re-entrancy during apply(): Prevent public edit paths from being called; use only raw operations.
9) Nice-to-Have (post-MVP)
- Visual undo-tree navigation (emacs-like time travel and branch selection), at least as a debug tool initially.
- Persistent undo across saves (opt-in; likely out-of-scope initially).
- Time-based batching threshold (e.g., break batches after >500ms pause in typing).
10) Execution Notes for a Junior Engineer/Agentic System
- Start from Phase 0; do not skip instrumentation—assertions will catch subtle batching bugs early.
- Change one surface at a time; when adjusting Begin/Append/commit positions, re-run unit tests immediately.
- Always ensure commit boundaries before invoking commands that move the cursor/state.
- When unsure about apply(), read docs/undo.md and mirror exactly: only raw Buffer methods, never the public editing
APIs.
- Keep diffs small and localized; add tests alongside behavior changes.
Appendix A — Minimal Developer Checklist
- [ ] Begin/Append occur after buffer mutation and cursor updates for all edit commands.
- [ ] Pending detached until commit; freed/cleared on commit/discard/clear.
- [ ] Redo branches freed on new commit after undo.
- [ ] mark_saved updates both saved pointer and dirty flag; dirty mirrors current != saved.
- [ ] apply() uses only raw Buffer methods; no recording during apply.
- [ ] Terminal and GUI both route printable input to InsertText; k-prefix mapping suppresses trailing TEXTINPUT.
- [ ] Unit and integration tests cover batching, branching, newline, saved/dirty, and UTF-8 cases.

View File

@@ -1,128 +1,139 @@
Undo/Redo + C-k GUI status (macOS) — current state snapshot ### Context recap
Context - The undo system is now treebased with batching rules and `KTE_UNDO_DEBUG` instrumentation hooks already present in
- Platform: macOS (Darwin) `UndoSystem.{h,cc}`.
- Target: GUI build (kge) using SDL2/ImGui path - GUI path uses SDL; printable input now flows exclusively via `SDL_TEXTINPUT` to `CommandId::InsertText`, while
- Date: 2025-11-30 00:30 local (from user) control/meta/movement (incl. Backspace/Delete/Newline and kprefix) come from `SDL_KEYDOWN`.
- Commit boundaries must be enforced at welldefined points (movement, nonediting commands, newline, undo/redo, etc.).
What works right now ### Status summary (20251201)
- Terminal (kte): C-k keymap and UndoSystem integration have been stable in recent builds.
- GUI: Most C-k mappings work: C-k d (KillToEOL), C-k x (Save+Quit), C-k q (Quit) — confirmed by user.
- UndoSystem core is implemented and integrated for InsertText/Newline/Delete/Backspace. Buffer owns an UndoSystem and raw edit APIs are used by apply().
What is broken (GUI, macOS) - Inputpath unification: Completed. `GUIInputHandler.cc` routes all printable characters through `SDL_TEXTINPUT → InsertText`.
- C-k u: Status shows "Undone" but buffer content does not change (no visible undo). Newlines originate only from `SDL_KEYDOWN → Newline`. CR/LF are filtered out of `SDL_TEXTINPUT` payloads. Suppression
- C-k U: Inserts a literal 'U' into the buffer; does not execute Redo. rules prevent stray `TEXTINPUT` after meta/prefix/universalargument flows. Terminal input path remains consistent.
- C-k C-u / C-k C-U: No effect (expected unmapped), but the k-prefix prompt can remain in some paths. - Tests: `test_undo.cc` expanded to cover branching behavior, UTF8 insertion, multiline newline/join, and typing batching.
All scenarios pass.
- Instrumentation: `KTE_UNDO_DEBUG` hooks exist in `UndoSystem.{h,cc}`; a CMake toggle has not yet been added.
- Commit boundaries: Undo/Redo commit boundaries are in place; newline path commits immediately by design. A final audit
pass across movement/nonediting commands is still pending.
- Docs: This status document updated. Further docs (instrumentation howto and example traces) remain pending in
`docs/undo.md` / `docs/undo-roadmap.md`.
Repro steps (GUI) ### Objectives
1) Type "Hello".
2) Press C-k then press u → status becomes "Undone", but text remains "Hello".
3) Press C-k then press Shift+U → a literal 'U' is inserted (becomes "HelloU").
4) Press C-k then hold Ctrl on the suffix and press u → status "Undone", still no change.
5) Press C-k then hold Ctrl on the suffix and press Shift+U → status shows the k-prefix prompt again ("C-k _").
Keymap and input-layer changes we attempted (and kept) - Use the existing instrumentation to capture short traces of typing/backspacing/deleting and undo/redo.
- KKeymap.cc: Case-sensitive 'U' mapping prioritized before the lowercase table. Added ctrl→non-ctrl fall-through so C-k u/U still map even if SDL reports Ctrl held on the suffix. - Unify input paths (SDL `KEYDOWN` vs `TEXTINPUT`) and lock down commit boundaries across commands.
- TerminalInputHandler: already preserved case and mapped correctly. - Extend tests to cover branching behavior, UTF8, and multiline operations.
- GUIInputHandler:
- Preserve case for k-prefix suffix letters (Shift → uppercase). Clear esc_meta before k-suffix mapping.
- Strengthened SDL_TEXTINPUT suppression after a k-prefix printable suffix to avoid inserting literal characters.
- Added fallback to map the k-prefix suffix in the SDL_TEXTINPUT path (to catch macOS cases where uppercase arrives in TEXTINPUT rather than KEYDOWN).
- Fixed malformed switch block introduced during iteration.
- Command layer: commit pending undo batch at k-prefix entry and just before Undo/Redo so prior typing can actually be undone/redone.
Diagnostics added ### Plan of action
- GUIInputHandler logs k-prefix u/U suffix attempts to stderr and (previously) /tmp/kge.log. The users macOS session showed only KEYDOWN logs for 'u':
- "[kge] k-prefix suffix: sym=117 mods=0x0 ascii=117 'u' ctrl2=0 pass_ctrl=0 mapped=1 id=38"
- "[kge] k-prefix suffix: sym=117 mods=0x80 ascii=117 'u' ctrl2=1 pass_ctrl=0 mapped=1 id=38"
- No logs were produced for 'U' (neither KEYDOWN nor TEXTINPUT). The /tmp log file was not created on the users host in the last run (stderr logs were visible earlier from KEYDOWN).
Hypotheses for current failures 1. Enable instrumentation and make it easy to toggle
1) Undo appears to be invoked (status "Undone"), but no state change: - Add a CMake option in `CMakeLists.txt` (root project):
- The most likely cause is that no committed node exists at the time of undo (i.e., typing "Hello" is not being recorded as an undo node), because our current typing path in Command.cc directly edits buffer rows without always driving UndoSystem Begin/Append/commit at the right times for every printable char on GUI. `option(KTE_UNDO_DEBUG "Enable undo instrumentation logs" OFF)`.
- Although we call u->Begin(Insert) and u->Append(text) in cmd_insert_text for CommandId::InsertText, the GUI printable input might be arriving through a different path or being short-circuited (e.g., via a prompt or suppression), resulting in actual text insertion but no corresponding UndoSystem pending node content, or pending but never committed. - When ON, add a compile definition `-DKTE_UNDO_DEBUG` to all targets that include the editor core (e.g., `kte`,
- We now commit at k-prefix entry and before undo; if there is still "nothing to undo", it implies the batch never had text appended (Append not called) or is detached from the real buffer edits. `kge`, and test binaries).
- Keep the default OFF so normal builds are quiet; ensure both modes compile in CI.
2) Redo via C-k U inserts a literal 'U': 2. Capture short traces to validate current behavior
- On macOS, uppercase letters often arrive as SDL_TEXTINPUT events. We added TEXTINPUT-based k-prefix mapping, but the user's run still showed a literal insertion and no diagnostic lines for TEXTINPUT, which suggests: - Build with `-DKTE_UNDO_DEBUG=ON` and run the GUI frontend:
a) The TEXTINPUT suppression didnt trigger for that platform/sequence, or - Scenario A: type a contiguous word, then move cursor (should show `Begin(Insert)` + multiple `Append`, single
b) The k-prefix flag was already cleared by the time TEXTINPUT arrived, so the TEXTINPUT path defaulted to InsertText, or `commit` at a movement boundary).
c) The GUI windows input focus or SDL event ordering differs from expectations (e.g., IME/text input settings), so our suppression/mapping didnt see the event. - Scenario B: hold backspace to delete a run, including backspace batching (prepend rule); verify
`Begin(Delete)` with prepended `Append` behavior, single `commit`.
- Scenario C: forward deletes at a fixed column (anchor batching); expected single `Begin(Delete)` with same
column.
- Scenario D: insert newline (`Newline` node) and immediately commit; type text on the next line; undo/redo
across the boundary.
- Scenario E: undo chain and redo chain; then type new text and confirm redo branch gets discarded in logs.
- Save representative trace snippets and add to `docs/undo.md` or `docs/undo-roadmap.md` for reference.
Relevant code pointers 3. Inputpath unification (SDL `KEYDOWN` vs `TEXTINPUT`) — Completed 20251201
- Key mapping tables: KKeymap.cc → KLookupKCommand() (C-k suffix), KLookupCtrlCommand(), KLookupEscCommand(). - In `GUIInputHandler.cc`:
- Terminal input: TerminalInputHandler.cc → map_key_to_command(). - Ensure printable characters are generated exclusively from `SDL_TEXTINPUT` and mapped to
- GUI input: GUIInputHandler.cc → map_key() and GUIInputHandler::ProcessSDLEvent() (KEYDOWN + TEXTINPUT handling, suppression, k_prefix_/esc_meta_ flags). `CommandId::InsertText`.
- Command dispatch: Command.cc → cmd_insert_text(), cmd_newline(), cmd_backspace(), cmd_delete_char(), cmd_undo(), cmd_redo(), cmd_kprefix(). - Keep `SDL_KEYDOWN` for control/meta/movement, backspace/delete, newline, and kprefix handling.
- Undo core: UndoSystem.{h,cc}, UndoNode.{h,cc}, UndoTree.{h,cc}. Buffer raw methods used by apply(). - Maintain suppression of stray `SDL_TEXTINPUT` immediately following meta/prefix or universalargument
collection so no accidental text is inserted.
- Confirm that `InsertText` path never carries `"\n"`; newline must only originate from `KEYDOWN`
`CommandId::Newline`.
- If the terminal input path exists, ensure parity: printable insertions go through `InsertText`, control via key
events, and the same commit boundaries apply.
- Status: Implemented. See `GUIInputHandler.cc` changes; tests confirm parity with terminal path.
Immediate next steps (when we return to this) 4. Enforce and verify commit boundaries in command execution — In progress
1) Verify that GUI printable insertion always flows through CommandId::InsertText so UndoSystem::Begin/Append gets called. If SDL_TEXTINPUT delivers multi-byte strings, ensure Append() is given the same text inserted into buffer. - Audit `Command.cc` and ensure `u->commit()` is called before executing any nonediting command that should end a
- Add a one-session debug hook in cmd_insert_text to assert/trace: pending node type/text length and current cursor col before/after. batch:
- If GUI sometimes sends CommandId::InsertTextEmpty or another path, unify. - Movement commands (left/right/up/down/home/end/page).
- Prompt accept/cancel transitions and mode switches (search prompts, replace prompts).
- Buffer/file operations (open/switch/save/close), and focus changes.
- Before running `Undo` or `Redo` (already present).
- Ensure immediate commit at the end of atomic edit operations:
- `Newline` insertion and line joins (`Delete` of newline when backspacing at column 0) should create
`UndoType::Newline` and commit immediately (parts are already implemented; verify all call sites).
- Pastes should be a single `Paste`/`Insert` batch per operation (depending on current design).
2) Ensure batching rules are satisfied so Begin() reuses pending correctly: 5. Extend automated tests (or add them if absent) — Phase 1 completed
- Begin(Insert) must see same row and col == pending->col + pending->text.size() for typing sequences. - Branching behavior ✓
- If GUI accumulates multiple characters per TEXTINPUT (e.g., pasted text), Append(std::string_view) is fine, but row/col expectations remain. - Insert `"abc"`, undo twice (back to `"a"`), insert `"X"`, assert redo list is discarded, and new timeline
continues with `aX`.
- Navigate undo/redo along the new branch to ensure correctness.
- UTF8 insertion and deletion ✓
- Insert `"é漢"` (multibyte characters) via `InsertText`; verify buffer content and that a single Insert batch
is created.
- Undo/redo restores/removes the full insertion batch.
- Backspace after typed UTF8 should remove the last inserted codepoint from the batch in a single undo step (
current semantics are byteoriented in buffer ops; test to current behavior and document).
- Multiline operations ✓
- Newline splits a line: verify an `UndoType::Newline` node is created and committed immediately; undo/redo
roundtrip.
- Backspace at column 0 joins with previous line: record as `Newline` deletion (via `UndoType::Newline`
inverse); undo/redo roundtrip.
- Typing and deletion batching ✓ (typing) / Pending (delete batching)
- Typing a contiguous word (no cursor moves) yields one `Insert` node with accumulated text.
- Forward delete at a fixed anchor column yields one `Delete` batch. (Pending test)
- Backspace batching uses the prepend rule when the cursor moves left. (Pending test)
- Place tests near existing test suite files (e.g., `tests/test_undo.cc`) or create them if not present. Prefer
using `Buffer` + `UndoSystem` directly for tight unit tests; add higherlevel integration tests as needed.
3) For C-k U uppercase mapping on macOS: 6. Documentation updates — In progress
- Add a temporary status dump when k-prefix suffix mapping falls back to TEXTINPUT path (we added stderr prints; also set Editor status with a short code like "K-TI U" during one session) to confirm path is used and suppression is working. - In `docs/undo.md` and `docs/undo-roadmap.md`:
- If TEXTINPUT never arrives, force suppression: when we detect k-prefix and KEYDOWN of a letter with Shift, preemptively handle via KEYDOWN-derived uppercase ASCII rather than deferring. - Describe how to enable instrumentation (`KTE_UNDO_DEBUG`) and an example of trace logs.
- List batching rules and commit boundaries clearly with examples.
- Document current UTF8 semantics (bytewise vs codepointwise) and any known limitations.
- Current status: this `undo-state.md` updated; instrumentation howto and example traces pending.
4) Consolidate k-prefix handling: 7. CI and build hygiene — Pending
- After mapping a k-prefix suffix to a command (Undo/Redo/etc.), always set suppress_text_input_once_ = true to avoid any trailing TEXTINPUT. - Default builds: `KTE_UNDO_DEBUG` OFF.
- Clear k_prefix_ reliably on both KEYDOWN and TEXTINPUT paths. - Add a CI job that builds and runs tests with `KTE_UNDO_DEBUG=ON` to ensure the instrumentation path remains
healthy.
- Ensure no performance regressions or excessive logging in release builds.
5) Once mapping is solid, remove all diagnostics and keep the minimal, deterministic logic. 8. Stretch goals (optional, timeboxed) — Pending
- IME composition: confirm that `SDL_TEXTINPUT` behavior during IME composition does not produce partial/broken
insertions; if needed, buffer composition updates into a single commit.
- Ensure paste operations (multiline/UTF8) remain atomic in undo history.
Open questions for future debugging ### How to run the tests
- Does SDL on this macOS setup deliver Shift+U as KEYDOWN+TEXTINPUT consistently, or only TEXTINPUT? We need a small on-screen debug to avoid relying on stderr.
- Are there any IME/TextInput SDL hints on macOS we should set for raw key handling during k-prefix?
- Should we temporarily disable SDL text input (SDL_StopTextInput) during k-prefix suffix processing to eliminate TEXTINPUT races on macOS?
Notes on UndoSystem correctness (unrelated to the GUI mapping bug) - Configure with `-DBUILD_TESTS=ON` and build the `test_undo` target. Run the produced binary (e.g., `./test_undo`).
- Undo tree invariants are implemented: pending is detached; commit attaches and clears redo branches; undo/redo apply low-level Buffer edits with no public editor paths; saved marker updated via mark_saved(). The test prints progress and uses assertions to validate behavior.
- Dirty flag mirrors (current != saved).
- Delete batching supports prepend for backspace sequences (stored text is in increasing column order from anchor).
- Newline joins/splits are recorded as UndoType::Newline and committed immediately for single-step undo of line joins.
Owner pointers & file locations ### Deliverables
- GUI mapping and suppression: GUIInputHandler.cc
- Command layer commit boundaries: Command.cc (cmd_kprefix, cmd_undo, cmd_redo)
- Undo batching entry points: Command.cc (cmd_insert_text, cmd_backspace, cmd_delete_char, cmd_newline)
End of snapshot — safe to resume from here. - CMake toggle for instrumentation and verified logs for core scenarios. (Pending)
- Updated `GUIInputHandler.cc` solidifying `KEYDOWN` vs `TEXTINPUT` separation and suppression rules. (Completed)
- Verified commit boundaries in `Command.cc` with comments where appropriate. (In progress)
- New tests for branching, UTF8, and multiline operations; all passing. (Completed for listed scenarios)
- Docs updated with howto and example traces. (Pending)
--- ### Acceptance criteria
RESOLUTION (2025-11-30) ### Current status (20251201) vs acceptance criteria
Root Cause Identified and Fixed - Short instrumentation traces match expected batching and commit behavior for typing, backspace/delete, newline, and
The undo system failure was caused by incorrect timing of UndoSystem::Begin() and Append() calls relative to buffer modifications in Command.cc. undo/redo. — Pending (instrumentation toggle + capture not done)
- Printable input comes exclusively from `SDL_TEXTINPUT`; no stray inserts after meta/prefix/universalargument flows.
Problem: — Satisfied (GUI path updated; terminal path consistent)
- 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. - Undo branching behaves correctly; redo is discarded upon new commits after undo. — Satisfied (tested)
- UndoSystem::Begin() checks the current cursor position to determine if it can batch with the pending node. - UTF8 and multiline scenarios roundtrip via undo/redo according to the documented semantics. — Satisfied (tested)
- For Insert type: Begin() checks if col == pending->col + pending->text.size() - Tests pass with `KTE_UNDO_DEBUG` both OFF and ON. — Pending (no CMake toggle yet; default OFF passes)
- 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

View File

@@ -1,55 +0,0 @@
# flake.nix
{
description = "kte ImGui/SDL2 text editor";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in {
packages.default = pkgs.stdenv.mkDerivation {
pname = "kte";
version = "0.1.0";
src = ./.;
nativeBuildInputs = [ pkgs.cmake pkgs.pkg-config ];
buildInputs = with pkgs; [
ncurses
SDL2
libGL
xorg.libX11
];
cmakeFlags = [
"-DBUILD_GUI=ON"
"-DCURSES_NEED_NCURSES=TRUE"
"-DCURSES_NEED_WIDE=TRUE"
];
# Alternative (even stronger): completely hide the broken module
preConfigure = ''
# If the project ships its own FindSDL2.cmake in cmake/, hide it
if [ -f cmake/FindSDL2.cmake ]; then
mv cmake/FindSDL2.cmake cmake/FindSDL2.cmake.disabled
echo "Disabled bundled FindSDL2.cmake"
fi
'';
meta = with pkgs.lib; {
description = "kte ImGui/SDL2 GUI editor";
mainProgram = "kte";
platforms = platforms.linux;
};
};
devShells.default = pkgs.mkShell {
inputsFrom = [ self.packages.${system}.default ];
packages = with pkgs; [ gdb clang-tools ];
};
});
}

10
flake.lock generated
View File

@@ -2,15 +2,15 @@
"nodes": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1764242076, "lastModified": 1764517877,
"narHash": "sha256-sKoIWfnijJ0+9e4wRvIgm/HgE27bzwQxcEmo2J/gNpI=", "narHash": "sha256-pp3uT4hHijIC8JUK5MEqeAWmParJrgBVzHLNfJDZxg4=",
"owner": "nixos", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "2fad6eac6077f03fe109c4d4eb171cf96791faa4", "rev": "2d293cbfa5a793b4c50d17c05ef9e385b90edf6c",
"type": "github" "type": "github"
}, },
"original": { "original": {
"owner": "nixos", "owner": "NixOS",
"ref": "nixos-unstable", "ref": "nixos-unstable",
"repo": "nixpkgs", "repo": "nixpkgs",
"type": "github" "type": "github"

View File

@@ -1,21 +1,20 @@
{ {
description = "Kyle's Text Editor"; description = "kyle's text editor";
inputs = { inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
};
outputs = outputs =
{ self, nixpkgs }: inputs@{ self, nixpkgs, ... }:
let let
pkgs = import nixpkgs { system = "x86_64-linux"; }; eachSystem = nixpkgs.lib.genAttrs nixpkgs.lib.systems.flakeExposed;
pkgsFor = system: import nixpkgs { inherit system; };
in in
{ {
packages.x86_64-linux = { packages = eachSystem (system: rec {
default = pkgs.callPackage ./default-nogui.nix { }; default = kte;
kge = pkgs.callPackage ./default-gui.nix { }; full = kge;
kte = pkgs.callPackage ./default-nogui.nix { }; kte = (pkgsFor system).callPackage ./default.nix { graphical = false; };
full = pkgs.callPackage ./default.nix { }; kge = (pkgsFor system).callPackage ./default.nix { graphical = true; };
}; });
}; };
} }

View File

@@ -47,14 +47,14 @@ main()
// Initialize cursor to (0,0) explicitly // Initialize cursor to (0,0) explicitly
buf->SetCursor(0, 0); buf->SetCursor(0, 0);
std::cout << "test_undo: Testing undo/redo system\n"; std::cout << "test_undo: Testing undo/redo system\n";
std::cout << "====================================\n\n"; std::cout << "====================================\n\n";
bool running = true; bool running = true;
// Test 1: Insert text and verify buffer contains expected text // Test 1: Insert text and verify buffer contains expected text
std::cout << "Test 1: Insert text 'Hello'\n"; std::cout << "Test 1: Insert text 'Hello'\n";
frontend.Input().QueueText("Hello"); frontend.Input().QueueText("Hello");
while (!frontend.Input().IsEmpty() && running) { while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running); frontend.Step(editor, running);
@@ -66,9 +66,9 @@ main()
std::cout << " Buffer content: '" << line_after_insert << "'\n"; std::cout << " Buffer content: '" << line_after_insert << "'\n";
std::cout << " ✓ Text insertion verified\n\n"; std::cout << " ✓ Text insertion verified\n\n";
// Test 2: Undo insertion - text should be removed // Test 2: Undo insertion - text should be removed
std::cout << "Test 2: Undo insertion\n"; std::cout << "Test 2: Undo insertion\n";
frontend.Input().QueueCommand(CommandId::Undo); frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) { while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running); frontend.Step(editor, running);
@@ -80,9 +80,9 @@ main()
std::cout << " Buffer content: '" << line_after_undo << "'\n"; std::cout << " Buffer content: '" << line_after_undo << "'\n";
std::cout << " ✓ Undo successful - text removed\n\n"; std::cout << " ✓ Undo successful - text removed\n\n";
// Test 3: Redo insertion - text should be restored // Test 3: Redo insertion - text should be restored
std::cout << "Test 3: Redo insertion\n"; std::cout << "Test 3: Redo insertion\n";
frontend.Input().QueueCommand(CommandId::Redo); frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) { while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running); frontend.Step(editor, running);
@@ -94,7 +94,242 @@ main()
std::cout << " Buffer content: '" << line_after_redo << "'\n"; std::cout << " Buffer content: '" << line_after_redo << "'\n";
std::cout << " ✓ Redo successful - text restored\n\n"; std::cout << " ✓ Redo successful - text restored\n\n";
frontend.Shutdown(); // Test 4: Branching behavior redo is discarded after new edits
std::cout << "Test 4: Branching behavior (redo discarded after new edits)\n";
// Reset to empty by undoing the last redo and the original insert, then reinsert 'abc'
// Ensure buffer is empty before starting this scenario
frontend.Input().QueueCommand(CommandId::Undo); // undo Hello
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "");
// Type a contiguous word 'abc' (single batch)
frontend.Input().QueueText("abc");
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "abc");
// Undo once should remove the whole batch and leave empty
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "");
// Now type new text 'X' this should create a new branch and discard old redo chain
frontend.Input().QueueText("X");
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "X");
// Attempt Redo should be a no-op (redo branch was discarded by new edit)
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "X");
// Undo and Redo along the new branch should still work
frontend.Input().QueueCommand(CommandId::Undo);
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "X");
std::cout << " ✓ Redo discarded after new edit; new branch undo/redo works\n\n";
// Clear buffer state for next tests: undo to empty if needed
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "");
// Test 5: UTF-8 insertion and undo/redo round-trip
std::cout << "Test 5: UTF-8 insertion 'é漢' and undo/redo\n";
const std::string utf8_text = "é漢"; // multi-byte UTF-8 (2 bytes + 3 bytes)
frontend.Input().QueueText(utf8_text);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == utf8_text);
// Undo should remove the entire contiguous insertion batch
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "");
// Redo restores it
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == utf8_text);
std::cout << " ✓ UTF-8 insert round-trips with undo/redo\n\n";
// Clear for next test
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "");
// Test 6: Multi-line operations (newline split and join via backspace at BOL)
std::cout << "Test 6: Newline split and join via backspace at BOL\n";
// Insert "ab" then newline then "cd" → expect two lines
frontend.Input().QueueText("ab");
frontend.Input().QueueCommand(CommandId::Newline);
frontend.Input().QueueText("cd");
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(buf->Rows().size() >= 2);
assert(std::string(buf->Rows()[0]) == "ab");
assert(std::string(buf->Rows()[1]) == "cd");
std::cout << " ✓ Split into two lines\n";
// Undo once should remove "cd" insertion leaving two lines ["ab", ""] or join depending on commit
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
// Current design batches typing on the second line; after undo, the second line should exist but be empty
assert(buf->Rows().size() >= 2);
assert(std::string(buf->Rows()[0]) == "ab");
assert(std::string(buf->Rows()[1]) == "");
// Undo the newline should rejoin to a single line "ab"
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(buf->Rows().size() >= 1);
assert(std::string(buf->Rows()[0]) == "ab");
// Redo twice to get back to ["ab","cd"]
frontend.Input().QueueCommand(CommandId::Redo);
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "ab");
assert(std::string(buf->Rows()[1]) == "cd");
std::cout << " ✓ Newline undo/redo round-trip\n";
// Now join via Backspace at beginning of second line
frontend.Input().QueueCommand(CommandId::MoveDown); // ensure we're on the second line
frontend.Input().QueueCommand(CommandId::MoveHome); // go to BOL on second line
frontend.Input().QueueCommand(CommandId::Backspace); // join with previous line
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(buf->Rows().size() >= 1);
assert(std::string(buf->Rows()[0]) == "abcd");
std::cout << " ✓ Backspace at BOL joins lines\n";
// Undo/Redo the join
frontend.Input().QueueCommand(CommandId::Undo);
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(buf->Rows().size() >= 1);
assert(std::string(buf->Rows()[0]) == "abcd");
std::cout << " ✓ Join undo/redo round-trip\n\n";
// Test 7: Typing batching a contiguous word undone in one step
std::cout << "Test 7: Typing batching (single undo removes whole word)\n";
// Clear current line first
frontend.Input().QueueCommand(CommandId::MoveHome);
frontend.Input().QueueCommand(CommandId::KillToEOL);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]).empty());
// Type a word and verify one undo clears it
frontend.Input().QueueText("hello");
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "hello");
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]).empty());
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "hello");
std::cout << " ✓ Contiguous typing batched into single undo step\n\n";
// Test 8: Forward delete batching at a fixed anchor column
std::cout << "Test 8: Forward delete batching at fixed anchor (DeleteChar)\n";
// Prepare line content
frontend.Input().QueueCommand(CommandId::MoveHome);
frontend.Input().QueueCommand(CommandId::KillToEOL);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
frontend.Input().QueueText("abcdef");
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
// Ensure cursor at anchor column 0
frontend.Input().QueueCommand(CommandId::MoveHome);
// Delete three chars at cursor; should batch into one Delete node
frontend.Input().QueueCommand(CommandId::DeleteChar, "", 3);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "def");
// Single undo should restore the entire deleted run
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "abcdef");
// Redo should remove the same run again
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "def");
std::cout << " ✓ Forward delete batched and undo/redo round-trips\n\n";
// Test 9: Backspace batching with prepend rule (cursor moves left)
std::cout << "Test 9: Backspace batching with prepend rule\n";
// Restore to full string then backspace a run
frontend.Input().QueueCommand(CommandId::Undo); // bring back to "abcdef"
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "abcdef");
// Move to end and backspace three characters; should batch into one Delete node
frontend.Input().QueueCommand(CommandId::MoveEnd);
frontend.Input().QueueCommand(CommandId::Backspace, "", 3);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "abc");
// Single undo restores the deleted run
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "abcdef");
// Redo removes it again
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "abc");
std::cout << " ✓ Backspace run batched and undo/redo round-trips\n\n";
frontend.Shutdown();
std::cout << "====================================\n"; std::cout << "====================================\n";
std::cout << "All tests passed!\n"; std::cout << "All tests passed!\n";