Refine help text, keybindings, GUI themes, and undo system.
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

- Expanded help text and command documentation with detailed keybinding descriptions.
- Added theme customization support to GUIConfig (Nord default, light/dark variants).
- Adjusted for consistent indentation and debug instrumentation in undo system.
- Enhanced test cases for multi-line, UTF-8, and branching scenarios.
This commit is contained in:
2025-12-01 15:21:52 -08:00
parent d98785e825
commit 655cc40162
24 changed files with 3234 additions and 1497 deletions

28
.idea/workspace.xml generated
View File

@@ -7,6 +7,7 @@
<option name="/Default/Environment/Hierarchy/GeneratedFilesCacheKey/Timestamp/@EntryValue" value="3" type="long" /> <option name="/Default/Environment/Hierarchy/GeneratedFilesCacheKey/Timestamp/@EntryValue" value="3" type="long" />
<option name="/Default/Housekeeping/FeatureSuggestion/FeatureSuggestionManager/DisabledSuggesters/=SwitchToGoToActionSuggester/@EntryIndexedValue" value="true" type="bool" /> <option name="/Default/Housekeeping/FeatureSuggestion/FeatureSuggestionManager/DisabledSuggesters/=SwitchToGoToActionSuggester/@EntryIndexedValue" value="true" type="bool" />
<option name="/Default/Housekeeping/GlobalSettingsUpgraded/IsUpgraded/@EntryValue" value="true" type="bool" /> <option name="/Default/Housekeeping/GlobalSettingsUpgraded/IsUpgraded/@EntryValue" value="true" type="bool" />
<option name="/Default/Housekeeping/LiveTemplatesHousekeeping/HotspotSessionHintIsShown/@EntryValue" value="true" type="bool" />
<option name="/Default/Housekeeping/OptionsDialog/SelectedPageId/@EntryValue" value="CppFormatterOtherPage" type="string" /> <option name="/Default/Housekeeping/OptionsDialog/SelectedPageId/@EntryValue" value="CppFormatterOtherPage" type="string" />
<option name="/Default/Housekeeping/RefactoringsMru/RenameRefactoring/DoSearchForTextInStrings/@EntryValue" value="true" type="bool" /> <option name="/Default/Housekeeping/RefactoringsMru/RenameRefactoring/DoSearchForTextInStrings/@EntryValue" value="true" type="bool" />
<option name="/Default/RiderDebugger/RiderRestoreDecompile/RestoreDecompileSetting/@EntryValue" value="false" type="bool" /> <option name="/Default/RiderDebugger/RiderRestoreDecompile/RestoreDecompileSetting/@EntryValue" value="false" type="bool" />
@@ -34,7 +35,29 @@
</component> </component>
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="e1fe3ab0-3650-4fca-8664-a247d5dfa457" name="Changes" comment="Add Nord theme for real"> <list default="true" id="e1fe3ab0-3650-4fca-8664-a247d5dfa457" name="Changes" comment="Add Nord theme for real">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Buffer.cc" beforeDir="false" afterPath="$PROJECT_DIR$/Buffer.cc" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Buffer.h" beforeDir="false" afterPath="$PROJECT_DIR$/Buffer.h" afterDir="false" />
<change beforePath="$PROJECT_DIR$/CMakeLists.txt" beforeDir="false" afterPath="$PROJECT_DIR$/CMakeLists.txt" afterDir="false" /> <change beforePath="$PROJECT_DIR$/CMakeLists.txt" beforeDir="false" afterPath="$PROJECT_DIR$/CMakeLists.txt" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Command.cc" beforeDir="false" afterPath="$PROJECT_DIR$/Command.cc" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Command.h" beforeDir="false" afterPath="$PROJECT_DIR$/Command.h" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Editor.h" beforeDir="false" afterPath="$PROJECT_DIR$/Editor.h" afterDir="false" />
<change beforePath="$PROJECT_DIR$/GUIConfig.cc" beforeDir="false" afterPath="$PROJECT_DIR$/GUIConfig.cc" afterDir="false" />
<change beforePath="$PROJECT_DIR$/GUIConfig.h" beforeDir="false" afterPath="$PROJECT_DIR$/GUIConfig.h" afterDir="false" />
<change beforePath="$PROJECT_DIR$/GUIFrontend.cc" beforeDir="false" afterPath="$PROJECT_DIR$/GUIFrontend.cc" afterDir="false" />
<change beforePath="$PROJECT_DIR$/GUIFrontend.h" beforeDir="false" afterPath="$PROJECT_DIR$/GUIFrontend.h" afterDir="false" />
<change beforePath="$PROJECT_DIR$/GUIInputHandler.cc" beforeDir="false" afterPath="$PROJECT_DIR$/GUIInputHandler.cc" afterDir="false" />
<change beforePath="$PROJECT_DIR$/GUIRenderer.cc" beforeDir="false" afterPath="$PROJECT_DIR$/GUIRenderer.cc" afterDir="false" />
<change beforePath="$PROJECT_DIR$/GUITheme.h" beforeDir="false" afterPath="$PROJECT_DIR$/GUITheme.h" afterDir="false" />
<change beforePath="$PROJECT_DIR$/HelpText.cc" beforeDir="false" afterPath="$PROJECT_DIR$/HelpText.cc" afterDir="false" />
<change beforePath="$PROJECT_DIR$/HelpText.h" beforeDir="false" afterPath="$PROJECT_DIR$/HelpText.h" afterDir="false" />
<change beforePath="$PROJECT_DIR$/KKeymap.cc" beforeDir="false" afterPath="$PROJECT_DIR$/KKeymap.cc" afterDir="false" />
<change beforePath="$PROJECT_DIR$/TerminalRenderer.cc" beforeDir="false" afterPath="$PROJECT_DIR$/TerminalRenderer.cc" afterDir="false" />
<change beforePath="$PROJECT_DIR$/UndoSystem.cc" beforeDir="false" afterPath="$PROJECT_DIR$/UndoSystem.cc" afterDir="false" />
<change beforePath="$PROJECT_DIR$/UndoSystem.h" beforeDir="false" afterPath="$PROJECT_DIR$/UndoSystem.h" afterDir="false" />
<change beforePath="$PROJECT_DIR$/docs/kge.1" beforeDir="false" afterPath="$PROJECT_DIR$/docs/kge.1" afterDir="false" />
<change beforePath="$PROJECT_DIR$/docs/kte.1" beforeDir="false" afterPath="$PROJECT_DIR$/docs/kte.1" afterDir="false" />
<change beforePath="$PROJECT_DIR$/test_undo.cc" beforeDir="false" afterPath="$PROJECT_DIR$/test_undo.cc" 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" />
@@ -63,12 +86,13 @@
<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" />
<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"> <component name="ProblemsViewState">
<option name="selectedTabId" value="AISelfReview" /> <option name="selectedTabId" value="CurrentFile" />
</component> </component>
<component name="ProjectApplicationVersion"> <component name="ProjectApplicationVersion">
<option name="ide" value="CLion" /> <option name="ide" value="CLion" />
@@ -173,7 +197,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="39312000" /> <workItem from="1764548345516" duration="50201000" />
</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" />

View File

@@ -368,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), Line(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);
@@ -430,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), Line(tail)); rows_.insert(rows_.begin() + static_cast<std::ptrdiff_t>(y + 1), Line(tail));
} }
@@ -459,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, Line(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);
@@ -262,11 +262,12 @@ public:
return filename_; return filename_;
} }
// Set a virtual (non file-backed) display name for this buffer, e.g. "+HELP+" // Set a virtual (non file-backed) display name for this buffer, e.g. "+HELP+"
// This does not mark the buffer as file-backed. // This does not mark the buffer as file-backed.
void SetVirtualName(const std::string &name) void SetVirtualName(const std::string &name)
{ {
filename_ = name; filename_ = name;
is_file_backed_ = false; is_file_backed_ = false;
} }
@@ -277,26 +278,29 @@ 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 flag
{ [[nodiscard]] bool IsReadOnly() const
read_only_ = ro; {
} return read_only_;
}
void ToggleReadOnly()
{ void SetReadOnly(bool ro)
read_only_ = !read_only_; {
} read_only_ = ro;
}
void ToggleReadOnly()
{
read_only_ = !read_only_;
}
void SetCursor(const std::size_t x, const std::size_t y) void SetCursor(const std::size_t x, const std::size_t y)
@@ -380,18 +384,18 @@ public:
[[nodiscard]] 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 read_only_ = false; bool read_only_ = false;
bool mark_set_ = false; bool mark_set_ = false;
std::size_t mark_curx_ = 0, mark_cury_ = 0; 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,7 +4,7 @@ project(kte)
include(GNUInstallDirs) include(GNUInstallDirs)
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD 17)
set(KTE_VERSION "1.1.0") set(KTE_VERSION "1.1.1")
# 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.

1149
Command.cc

File diff suppressed because it is too large Load Diff

View File

@@ -69,6 +69,9 @@ enum class CommandId {
Redo, Redo,
// UI/status helpers // UI/status helpers
UArgStatus, // update status line during universal-argument collection UArgStatus, // update status line during universal-argument collection
// Themes (GUI)
ThemeNext,
ThemePrev,
// Region formatting // Region formatting
IndentRegion, // indent region (C-k =) IndentRegion, // indent region (C-k =)
UnindentRegion, // unindent region (C-k -) UnindentRegion, // unindent region (C-k -)
@@ -86,6 +89,12 @@ enum class CommandId {
ShowHelp, // open +HELP+ buffer with manual text (C-k h) 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
// Generic command prompt
CommandPromptStart, // begin generic command prompt (C-k ;)
// Theme by name
ThemeSetByName,
// Background mode (GUI)
BackgroundSet,
}; };
@@ -109,6 +118,8 @@ struct Command {
std::string name; // stable, unique name (e.g., "save", "save-as") std::string name; // stable, unique name (e.g., "save", "save-as")
std::string help; // short help/description std::string help; // short help/description
CommandHandler handler; CommandHandler handler;
// Public commands are exposed in the ": " prompt (C-k ;)
bool isPublic = false;
}; };

View File

@@ -301,22 +301,23 @@ public:
} }
// --- Generic Prompt subsystem (for search, open-file, save-as, etc.) --- // --- Generic Prompt subsystem (for search, open-file, save-as, etc.) ---
enum class PromptKind { enum class PromptKind {
None = 0, None = 0,
Search, Search,
RegexSearch, RegexSearch,
RegexReplaceFind, // step 1 of Regex Search & Replace: find pattern RegexReplaceFind, // step 1 of Regex Search & Replace: find pattern
RegexReplaceWith, // step 2 of Regex Search & Replace: replacement text RegexReplaceWith, // step 2 of Regex Search & Replace: replacement text
OpenFile, OpenFile,
SaveAs, SaveAs,
Confirm, Confirm,
BufferSwitch, BufferSwitch,
GotoLine, GotoLine,
Chdir, Chdir,
ReplaceFind, // step 1 of Search & Replace: find what ReplaceFind, // step 1 of Search & Replace: find what
ReplaceWith // step 2 of Search & Replace: replace with ReplaceWith, // step 2 of Search & Replace: replace with
}; Command // generic command prompt (": ")
};
void StartPrompt(PromptKind kind, const std::string &label, const std::string &initial) void StartPrompt(PromptKind kind, const std::string &label, const std::string &initial)
@@ -518,20 +519,38 @@ 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 // Temporary state for Search & Replace flow
public: public:
void SetReplaceFindTmp(const std::string &s) { replace_find_tmp_ = s; } void SetReplaceFindTmp(const std::string &s)
void SetReplaceWithTmp(const std::string &s) { replace_with_tmp_ = s; } {
[[nodiscard]] const std::string &ReplaceFindTmp() const { return replace_find_tmp_; } replace_find_tmp_ = s;
[[nodiscard]] const std::string &ReplaceWithTmp() const { return replace_with_tmp_; } }
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: private:
std::string replace_find_tmp_; std::string replace_find_tmp_;
std::string replace_with_tmp_; std::string replace_with_tmp_;
}; };
#endif // KTE_EDITOR_H #endif // KTE_EDITOR_H

View File

@@ -102,6 +102,15 @@ GUIConfig::LoadFromFile(const std::string &path)
if (v > 0.0f) { if (v > 0.0f) {
font_size = v; font_size = v;
} }
} else if (key == "theme") {
theme = val;
} else if (key == "background" || key == "bg") {
std::string v = val;
std::transform(v.begin(), v.end(), v.begin(), [](unsigned char c) {
return (char) std::tolower(c);
});
if (v == "light" || v == "dark")
background = v;
} }
} }

View File

@@ -12,10 +12,14 @@
class GUIConfig { class GUIConfig {
public: public:
bool fullscreen = false; bool fullscreen = false;
int columns = 80; int columns = 80;
int rows = 42; int rows = 42;
float font_size = (float) KTE_FONT_SIZE; float font_size = (float) KTE_FONT_SIZE;
std::string theme = "nord";
// Background mode for themes that support light/dark variants
// Values: "dark" (default), "light"
std::string background = "dark";
// Load from default path: $HOME/.config/kte/kge.ini // Load from default path: $HOME/.config/kte/kge.ini
static GUIConfig Load(); static GUIConfig Load();

View File

@@ -32,8 +32,8 @@ GUIFrontend::Init(Editor &ed)
return false; return false;
} }
// Load GUI configuration (fullscreen, columns/rows, font size) // Load GUI configuration (fullscreen, columns/rows, font size, theme, background)
const auto [fullscreen, columns, rows, font_size] = GUIConfig::Load(); GUIConfig cfg = GUIConfig::Load();
// GL attributes for core profile // GL attributes for core profile
SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, 0); SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, 0);
@@ -47,7 +47,7 @@ GUIFrontend::Init(Editor &ed)
// Compute desired window size from config // Compute desired window size from config
Uint32 win_flags = SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI; Uint32 win_flags = SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI;
if (fullscreen) { if (cfg.fullscreen) {
// "Fullscreen": fill the usable bounds of the primary display. // "Fullscreen": fill the usable bounds of the primary display.
// On macOS, do NOT use true fullscreen so the menu/status bar remains visible. // On macOS, do NOT use true fullscreen so the menu/status bar remains visible.
SDL_Rect usable{}; SDL_Rect usable{};
@@ -61,8 +61,8 @@ GUIFrontend::Init(Editor &ed)
#endif #endif
} else { } else {
// Windowed: width = columns * font_size, height = (rows * 2) * font_size // Windowed: width = columns * font_size, height = (rows * 2) * font_size
int w = static_cast<int>(columns * font_size); int w = cfg.columns * static_cast<int>(cfg.font_size);
int h = static_cast<int>((rows * 2) * font_size); int h = cfg.rows * static_cast<int>(cfg.font_size * 1.2);
// As a safety, clamp to display usable bounds if retrievable // As a safety, clamp to display usable bounds if retrievable
SDL_Rect usable{}; SDL_Rect usable{};
@@ -86,7 +86,7 @@ GUIFrontend::Init(Editor &ed)
// macOS: when "fullscreen" is requested, position the window at the // macOS: when "fullscreen" is requested, position the window at the
// top-left of the usable display area to mimic fullscreen while keeping // top-left of the usable display area to mimic fullscreen while keeping
// the system menu bar visible. // the system menu bar visible.
if (fullscreen) { if (cfg.fullscreen) {
SDL_Rect usable{}; SDL_Rect usable{};
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) { if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
SDL_SetWindowPosition(window_, usable.x, usable.y); SDL_SetWindowPosition(window_, usable.x, usable.y);
@@ -105,8 +105,13 @@ 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(); // Apply background mode and selected theme (default: Nord). Can be changed at runtime via commands.
if (cfg.background == "light")
kte::SetBackgroundMode(kte::BackgroundMode::Light);
else
kte::SetBackgroundMode(kte::BackgroundMode::Dark);
kte::ApplyThemeByName(cfg.theme);
if (!ImGui_ImplSDL2_InitForOpenGL(window_, gl_ctx_)) if (!ImGui_ImplSDL2_InitForOpenGL(window_, gl_ctx_))
return false; return false;
@@ -135,7 +140,7 @@ GUIFrontend::Init(Editor &ed)
#endif #endif
// Initialize GUI font from embedded default (use configured size or compiled default) // Initialize GUI font from embedded default (use configured size or compiled default)
LoadGuiFont_(nullptr, (float) font_size); LoadGuiFont_(nullptr, (float) cfg.font_size);
return true; return true;
} }
@@ -214,7 +219,7 @@ GUIFrontend::Step(Editor &ed, bool &running)
float avail_h = std::max(0.0f, disp_h - 2.0f * pad_y - status_h); float avail_h = std::max(0.0f, disp_h - 2.0f * pad_y - status_h);
// Visible content rows inside the scroll child // Visible content rows inside the scroll child
std::size_t content_rows = static_cast<std::size_t>(std::floor(avail_h / line_h)); auto content_rows = static_cast<std::size_t>(std::floor(avail_h / line_h));
// Editor::Rows includes the status line; add 1 back for it. // Editor::Rows includes the status line; add 1 back for it.
std::size_t rows = std::max<std::size_t>(1, content_rows + 1); std::size_t rows = std::max<std::size_t>(1, content_rows + 1);
std::size_t cols = static_cast<std::size_t>(std::max(1.0f, std::floor(avail_w / ch_w))); std::size_t cols = static_cast<std::size_t>(std::max(1.0f, std::floor(avail_w / ch_w)));
@@ -264,11 +269,11 @@ GUIFrontend::Shutdown()
bool bool
GUIFrontend::LoadGuiFont_(const char * /*path*/, float size_px) GUIFrontend::LoadGuiFont_(const char * /*path*/, float size_px)
{ {
ImGuiIO &io = ImGui::GetIO(); const ImGuiIO &io = ImGui::GetIO();
io.Fonts->Clear(); io.Fonts->Clear();
ImFont *font = io.Fonts->AddFontFromMemoryCompressedTTF( const ImFont *font = io.Fonts->AddFontFromMemoryCompressedTTF(
(void *) DefaultFontBoldCompressedData, DefaultFontBoldCompressedData,
(int) DefaultFontBoldCompressedSize, DefaultFontBoldCompressedSize,
size_px); size_px);
if (!font) { if (!font) {
font = io.Fonts->AddFontDefault(); font = io.Fonts->AddFontDefault();

View File

@@ -25,7 +25,7 @@ public:
void Shutdown() override; void Shutdown() override;
private: private:
bool LoadGuiFont_(const char *path, float size_px); static bool LoadGuiFont_(const char *path, float size_px);
GUIInputHandler input_{}; GUIInputHandler input_{};
GUIRenderer renderer_{}; GUIRenderer renderer_{};

View File

@@ -92,10 +92,14 @@ 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:
// Do not insert text on KEYDOWN; allow SDL_TEXTINPUT to deliver '\t' // Insert a literal tab character when not interpreting a k-prefix suffix.
// as printable input so that all printable characters flow via TEXTINPUT. // If k-prefix is active, let the k-prefix handler below consume the key
out.hasCommand = false; // (so Tab doesn't leave k-prefix stuck).
return true; if (!k_prefix) {
out = {true, CommandId::InsertText, std::string("\t"), 0};
return true;
}
break; // fall through so k-prefix handler can process
case SDLK_RETURN: case SDLK_RETURN:
case SDLK_KP_ENTER: case SDLK_KP_ENTER:
out = {true, CommandId::Newline, "", 0}; out = {true, CommandId::Newline, "", 0};
@@ -347,6 +351,12 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
uarg_text_, uarg_text_,
mi); mi);
// If we inserted a TAB on KEYDOWN, suppress any subsequent SDL_TEXTINPUT
// for this keystroke to avoid double insertion on platforms that emit it.
if (produced && mi.hasCommand && mi.id == CommandId::InsertText && mi.arg == "\t") {
suppress_text_input_once_ = true;
}
// If we just consumed a universal-argument digit or '-' on KEYDOWN and emitted UArgStatus, // If we just consumed a universal-argument digit or '-' on KEYDOWN and emitted UArgStatus,
// suppress the subsequent SDL_TEXTINPUT for this keystroke to avoid duplicating the digit in status. // suppress the subsequent SDL_TEXTINPUT for this keystroke to avoid duplicating the digit in status.
if (produced && mi.hasCommand && mi.id == CommandId::UArgStatus) { if (produced && mi.hasCommand && mi.id == CommandId::UArgStatus) {

View File

@@ -152,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();
@@ -171,163 +171,169 @@ 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;
// Empty buffer guard: if there are no lines yet, just move to 0:0 // Empty buffer guard: if there are no lines yet, just move to 0:0
if (lines.empty()) { if (lines.empty()) {
Execute(ed, CommandId::MoveCursorTo, std::string("0:0")); Execute(ed, CommandId::MoveCursorTo, std::string("0:0"));
} else { } else {
// Convert pixel X to a render-column target including horizontal col offset // Convert pixel X to a render-column target including horizontal col offset
// Use our own tab expansion of width 8 to match command layer logic. // Use our own tab expansion of width 8 to match command layer logic.
std::string line_clicked = static_cast<std::string>(lines[by]); std::string line_clicked = static_cast<std::string>(lines[by]);
const std::size_t tabw = 8; const std::size_t tabw = 8;
// We iterate source columns computing absolute rendered column (rx_abs) from 0, // We iterate source columns computing absolute rendered column (rx_abs) from 0,
// then translate to viewport-space by subtracting Coloffs. // then translate to viewport-space by subtracting Coloffs.
std::size_t coloffs = buf->Coloffs(); std::size_t coloffs = buf->Coloffs();
std::size_t rx_abs = 0; // absolute rendered column std::size_t rx_abs = 0; // absolute rendered column
std::size_t i = 0; // source column iterator 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();
std::string line = static_cast<std::string>(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
// Compute search highlight ranges for this line in source indices // Compute search highlight ranges for this line in source indices
bool search_mode = ed.SearchActive() && !ed.SearchQuery().empty(); bool search_mode = ed.SearchActive() && !ed.SearchQuery().empty();
std::vector<std::pair<std::size_t, std::size_t>> hl_src_ranges; std::vector<std::pair<std::size_t, std::size_t> > hl_src_ranges;
if (search_mode) { if (search_mode) {
// If we're in RegexSearch or RegexReplaceFind mode, compute ranges using regex; otherwise plain substring // If we're in RegexSearch or RegexReplaceFind mode, compute ranges using regex; otherwise plain substring
if (ed.PromptActive() && (ed.CurrentPromptKind() == Editor::PromptKind::RegexSearch || ed.CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind)) { if (ed.PromptActive() && (
try { ed.CurrentPromptKind() == Editor::PromptKind::RegexSearch || ed.
std::regex rx(ed.SearchQuery()); CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind)) {
for (auto it = std::sregex_iterator(line.begin(), line.end(), rx); try {
it != std::sregex_iterator(); ++it) { std::regex rx(ed.SearchQuery());
const auto &m = *it; for (auto it = std::sregex_iterator(line.begin(), line.end(), rx);
std::size_t sx = static_cast<std::size_t>(m.position()); it != std::sregex_iterator(); ++it) {
std::size_t ex = sx + static_cast<std::size_t>(m.length()); const auto &m = *it;
hl_src_ranges.emplace_back(sx, ex); std::size_t sx = static_cast<std::size_t>(m.position());
} std::size_t ex = sx + static_cast<std::size_t>(m.length());
} catch (const std::regex_error &) { hl_src_ranges.emplace_back(sx, ex);
// ignore invalid patterns here; status line already shows the error }
} } catch (const std::regex_error &) {
} else { // ignore invalid patterns here; status line already shows the error
const std::string &q = ed.SearchQuery(); }
std::size_t pos = 0; } else {
while (!q.empty() && (pos = line.find(q, pos)) != std::string::npos) { const std::string &q = ed.SearchQuery();
hl_src_ranges.emplace_back(pos, pos + q.size()); std::size_t pos = 0;
pos += q.size(); 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; auto src_to_rx = [&](std::size_t upto_src_exclusive) -> std::size_t {
while (s < upto_src_exclusive && s < line.size()) { std::size_t rx = 0;
if (line[s] == '\t') std::size_t s = 0;
rx += (tabw - (rx % tabw)); while (s < upto_src_exclusive && s < line.size()) {
else if (line[s] == '\t')
rx += 1; rx += (tabw - (rx % tabw));
++s; else
} rx += 1;
return rx; ++s;
}; }
// Draw background highlights (under text) return rx;
if (search_mode && !hl_src_ranges.empty()) { };
// Current match emphasis // Draw background highlights (under text)
bool has_current = ed.SearchMatchLen() > 0 && ed.SearchMatchY() == i; if (search_mode && !hl_src_ranges.empty()) {
std::size_t cur_x = has_current ? ed.SearchMatchX() : 0; // Current match emphasis
std::size_t cur_end = has_current ? (ed.SearchMatchX() + ed.SearchMatchLen()) : 0; bool has_current = ed.SearchMatchLen() > 0 && ed.SearchMatchY() == i;
for (const auto &rg : hl_src_ranges) { std::size_t cur_x = has_current ? ed.SearchMatchX() : 0;
std::size_t sx = rg.first, ex = rg.second; std::size_t cur_end = has_current ? (ed.SearchMatchX() + ed.SearchMatchLen()) : 0;
std::size_t rx_start = src_to_rx(sx); for (const auto &rg: hl_src_ranges) {
std::size_t rx_end = src_to_rx(ex); std::size_t sx = rg.first, ex = rg.second;
// Apply horizontal scroll offset std::size_t rx_start = src_to_rx(sx);
if (rx_end <= coloffs_now) continue; // fully left of view std::size_t rx_end = src_to_rx(ex);
std::size_t vx0 = (rx_start > coloffs_now) ? (rx_start - coloffs_now) : 0; // Apply horizontal scroll offset
std::size_t vx1 = rx_end - coloffs_now; if (rx_end <= coloffs_now)
ImVec2 p0 = ImVec2(line_pos.x + static_cast<float>(vx0) * space_w, line_pos.y); continue; // fully left of view
ImVec2 p1 = ImVec2(line_pos.x + static_cast<float>(vx1) * space_w, line_pos.y + line_h); std::size_t vx0 = (rx_start > coloffs_now) ? (rx_start - coloffs_now) : 0;
// Choose color: current match stronger std::size_t vx1 = rx_end - coloffs_now;
bool is_current = has_current && sx == cur_x && ex == cur_end; ImVec2 p0 = ImVec2(line_pos.x + static_cast<float>(vx0) * space_w, line_pos.y);
ImU32 col = is_current ? IM_COL32(255, 220, 120, 140) : IM_COL32(200, 200, 0, 90); ImVec2 p1 = ImVec2(line_pos.x + static_cast<float>(vx1) * space_w,
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col); line_pos.y + line_h);
} // Choose color: current match stronger
} bool is_current = has_current && sx == cur_x && ex == cur_end;
// Emit entire line (ImGui child scrolling will handle clipping) ImU32 col = is_current
for (std::size_t src = 0; src < line.size(); ++src) { ? IM_COL32(255, 220, 120, 140)
char c = line[src]; : IM_COL32(200, 200, 0, 90);
if (c == '\t') { ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
std::size_t adv = (tabw - (rx_abs_draw % tabw)); }
// Emit spaces for the tab }
expanded.append(adv, ' '); // Emit entire line (ImGui child scrolling will handle clipping)
rx_abs_draw += adv; for (std::size_t src = 0; src < line.size(); ++src) {
} else { char c = line[src];
expanded.push_back(c); if (c == '\t') {
rx_abs_draw += 1; 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) {
@@ -349,207 +355,220 @@ GUIRenderer::Draw(Editor &ed)
} }
ImGui::EndChild(); ImGui::EndChild();
// Status bar spanning full width // Status bar spanning full width
ImGui::Separator(); ImGui::Separator();
// 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 // If a prompt is active, replace the entire status bar with the prompt text
if (ed.PromptActive()) {
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);
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;
try {
fname = ed.DisplayNameFor(*buf);
} catch (...) {
fname = buf->Filename();
try {
fname = std::filesystem::path(fname).filename().string();
} catch (...) {}
}
left += " ";
// Insert buffer position prefix "[x/N] " before filename
{
std::size_t total = ed.BufferCount();
if (total > 0) {
std::size_t idx1 = ed.CurrentBufferIndex() + 1; // 1-based for display
left += "[";
left += std::to_string(static_cast<unsigned long long>(idx1));
left += "/";
left += std::to_string(static_cast<unsigned long long>(total));
left += "] ";
}
}
left += fname;
if (buf->Dirty())
left += " *";
// Append total line count as "<n>L"
{
unsigned long lcount = static_cast<unsigned long>(buf->Rows().size());
left += " ";
left += std::to_string(lcount);
left += "L";
}
// Build right text (cursor/mark)
int row1 = static_cast<int>(buf->Cury()) + 1;
int col1 = static_cast<int>(buf->Curx()) + 1;
bool have_mark = buf->MarkSet();
int mrow1 = have_mark ? static_cast<int>(buf->MarkCury()) + 1 : 0;
int mcol1 = have_mark ? static_cast<int>(buf->MarkCurx()) + 1 : 0;
char rbuf[128];
if (have_mark)
std::snprintf(rbuf, sizeof(rbuf), "%d,%d | M: %d,%d", row1, col1, mrow1, mcol1);
else
std::snprintf(rbuf, sizeof(rbuf), "%d,%d | M: not set", row1, col1);
std::string right = rbuf;
// Middle message: if a prompt is active, show "Label: text"; otherwise show status
std::string msg;
if (ed.PromptActive()) { if (ed.PromptActive()) {
msg = ed.PromptLabel(); std::string label = ed.PromptLabel();
if (!msg.empty()) std::string ptext = ed.PromptText();
msg += ": "; auto kind = ed.CurrentPromptKind();
msg += ed.PromptText(); if (kind == Editor::PromptKind::OpenFile || kind == Editor::PromptKind::SaveAs ||
} else { kind == Editor::PromptKind::Chdir) {
msg = ed.Status(); 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;
}
}
}
// Measurements float pad = 6.f;
ImVec2 left_sz = ImGui::CalcTextSize(left.c_str()); float left_x = p0.x + pad;
ImVec2 right_sz = ImGui::CalcTextSize(right.c_str()); float right_x = p1.x - pad;
float pad = 6.f; float max_px = std::max(0.0f, right_x - left_x);
float left_x = p0.x + pad;
float right_x = p1.x - pad - right_sz.x; std::string prefix;
if (right_x < left_x + left_sz.x + pad) { if (kind == Editor::PromptKind::Command) {
// Not enough room; clip left to fit prefix = ": ";
float max_left = std::max(0.0f, right_x - left_x - pad); } else if (!label.empty()) {
if (max_left < left_sz.x && max_left > 10.0f) { prefix = label + ": ";
// Render a clipped left using a child region }
// 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;
try {
fname = ed.DisplayNameFor(*buf);
} catch (...) {
fname = buf->Filename();
try {
fname = std::filesystem::path(fname).filename().string();
} catch (...) {}
}
left += " ";
// Insert buffer position prefix "[x/N] " before filename
{
std::size_t total = ed.BufferCount();
if (total > 0) {
std::size_t idx1 = ed.CurrentBufferIndex() + 1; // 1-based for display
left += "[";
left += std::to_string(static_cast<unsigned long long>(idx1));
left += "/";
left += std::to_string(static_cast<unsigned long long>(total));
left += "] ";
}
}
left += fname;
if (buf->Dirty())
left += " *";
// Append total line count as "<n>L"
{
unsigned long lcount = static_cast<unsigned long>(buf->Rows().size());
left += " ";
left += std::to_string(lcount);
left += "L";
}
// Build right text (cursor/mark)
int row1 = static_cast<int>(buf->Cury()) + 1;
int col1 = static_cast<int>(buf->Curx()) + 1;
bool have_mark = buf->MarkSet();
int mrow1 = have_mark ? static_cast<int>(buf->MarkCury()) + 1 : 0;
int mcol1 = have_mark ? static_cast<int>(buf->MarkCurx()) + 1 : 0;
char rbuf[128];
if (have_mark)
std::snprintf(rbuf, sizeof(rbuf), "%d,%d | M: %d,%d", row1, col1, mrow1, mcol1);
else
std::snprintf(rbuf, sizeof(rbuf), "%d,%d | M: not set", row1, col1);
std::string right = rbuf;
// Middle message: if a prompt is active, show "Label: text"; otherwise show status
std::string msg;
if (ed.PromptActive()) {
msg = ed.PromptLabel();
if (!msg.empty())
msg += ": ";
msg += ed.PromptText();
} else {
msg = ed.Status();
}
// Measurements
ImVec2 left_sz = ImGui::CalcTextSize(left.c_str());
ImVec2 right_sz = ImGui::CalcTextSize(right.c_str());
float pad = 6.f;
float left_x = p0.x + pad;
float right_x = p1.x - pad - right_sz.x;
if (right_x < left_x + left_sz.x + pad) {
// Not enough room; clip left to fit
float max_left = std::max(0.0f, right_x - left_x - pad);
if (max_left < left_sz.x && max_left > 10.0f) {
// Render a clipped left using a child region
ImGui::SetCursorScreenPos(ImVec2(left_x, p0.y + (bar_h - left_sz.y) * 0.5f));
ImGui::PushClipRect(ImVec2(left_x, p0.y), ImVec2(right_x - pad, p1.y), true);
ImGui::TextUnformatted(left.c_str());
ImGui::PopClipRect();
}
} else {
// Draw left normally
ImGui::SetCursorScreenPos(ImVec2(left_x, p0.y + (bar_h - left_sz.y) * 0.5f)); ImGui::SetCursorScreenPos(ImVec2(left_x, p0.y + (bar_h - left_sz.y) * 0.5f));
ImGui::PushClipRect(ImVec2(left_x, p0.y), ImVec2(right_x - pad, p1.y), true);
ImGui::TextUnformatted(left.c_str()); ImGui::TextUnformatted(left.c_str());
ImGui::PopClipRect();
} }
} else {
// Draw left normally
ImGui::SetCursorScreenPos(ImVec2(left_x, p0.y + (bar_h - left_sz.y) * 0.5f));
ImGui::TextUnformatted(left.c_str());
}
// Draw right // Draw right
ImGui::SetCursorScreenPos(ImVec2(std::max(right_x, left_x), p0.y + (bar_h - right_sz.y) * 0.5f)); ImGui::SetCursorScreenPos(ImVec2(std::max(right_x, left_x),
ImGui::TextUnformatted(right.c_str()); p0.y + (bar_h - right_sz.y) * 0.5f));
ImGui::TextUnformatted(right.c_str());
// Draw middle message centered in remaining space // Draw middle message centered in remaining space
if (!msg.empty()) { if (!msg.empty()) {
float mid_left = left_x + left_sz.x + pad; float mid_left = left_x + left_sz.x + pad;
float mid_right = std::max(right_x - pad, mid_left); float mid_right = std::max(right_x - pad, mid_left);
float mid_w = std::max(0.0f, mid_right - mid_left); float mid_w = std::max(0.0f, mid_right - mid_left);
if (mid_w > 1.0f) { if (mid_w > 1.0f) {
ImVec2 msg_sz = ImGui::CalcTextSize(msg.c_str()); ImVec2 msg_sz = ImGui::CalcTextSize(msg.c_str());
float msg_x = mid_left + std::max(0.0f, (mid_w - msg_sz.x) * 0.5f); float msg_x = mid_left + std::max(0.0f, (mid_w - msg_sz.x) * 0.5f);
// Clip to middle region // Clip to middle region
ImGui::PushClipRect(ImVec2(mid_left, p0.y), ImVec2(mid_right, p1.y), true); ImGui::PushClipRect(ImVec2(mid_left, p0.y), ImVec2(mid_right, p1.y), true);
ImGui::SetCursorScreenPos(ImVec2(msg_x, p0.y + (bar_h - msg_sz.y) * 0.5f)); ImGui::SetCursorScreenPos(ImVec2(msg_x, p0.y + (bar_h - msg_sz.y) * 0.5f));
ImGui::TextUnformatted(msg.c_str()); ImGui::TextUnformatted(msg.c_str());
ImGui::PopClipRect(); ImGui::PopClipRect();
}
} }
// Advance cursor to after the bar to keep layout consistent
ImGui::Dummy(ImVec2(x1 - x0, bar_h));
} }
// Advance cursor to after the bar to keep layout consistent
ImGui::Dummy(ImVec2(x1 - x0, bar_h));
}
} }
ImGui::End(); ImGui::End();

1021
GUITheme.h

File diff suppressed because it is too large Load Diff

View File

@@ -15,24 +15,26 @@ HelpText::Text()
return std::string( return std::string(
"KTE - Kyle's Text Editor\n\n" "KTE - Kyle's Text Editor\n\n"
"About:\n" "About:\n"
" kte is Kyle's Text Editor and is probably ill-suited to everyone else. It was\n" " kte is Kyle's Text Editor. It keeps a small, fast core and uses a\n"
" inspired by Antirez' kilo text editor by way of someone's writeup of the\n" " WordStar/VDE-style command model with some emacs influences.\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" "\n"
"Core keybindings:\n" "K-commands (prefix C-k):\n"
" C-k ' Toggle read-only\n" " C-k ' Toggle read-only\n"
" C-k - Unindent region\n" " C-k - Unindent region (mark required)\n"
" C-k = Indent region\n" " C-k = Indent region (mark required)\n"
" C-k ; Command prompt (:\\ )\n"
" C-k C-d Kill entire line\n" " C-k C-d Kill entire line\n"
" C-k C-q Quit now (no confirm)\n" " C-k C-q Quit now (no confirm)\n"
" C-k a Mark all and jump to end\n" " C-k C-x Save and quit\n"
" C-k a Mark start of file, jump to end\n"
" C-k b Switch buffer\n" " C-k b Switch buffer\n"
" C-k c Close current buffer\n" " C-k c Close current buffer\n"
" C-k d Kill to end of line\n" " C-k d Kill to end of line\n"
" C-k e Open file (prompt)\n" " C-k e Open file (prompt)\n"
" C-k f Flush kill ring\n"
" C-k g Jump to line\n" " C-k g Jump to line\n"
" C-k h Show this help\n" " C-k h Show this help\n"
" C-k j Jump to mark\n"
" C-k l Reload buffer from disk\n" " C-k l Reload buffer from disk\n"
" C-k n Previous buffer\n" " C-k n Previous buffer\n"
" C-k o Change working directory (prompt)\n" " C-k o Change working directory (prompt)\n"
@@ -44,12 +46,36 @@ HelpText::Text()
" C-k v Toggle visual file picker (GUI)\n" " C-k v Toggle visual file picker (GUI)\n"
" C-k w Show working directory\n" " C-k w Show working directory\n"
" C-k x Save and quit\n" " C-k x Save and quit\n"
" C-k y Yank\n"
"\n" "\n"
"ESC/Alt commands:\n" "ESC/Alt commands:\n"
" ESC < Go to beginning of file\n"
" ESC > Go to end of file\n"
" ESC m Toggle mark\n"
" ESC w Copy region to kill ring (Alt-w)\n"
" ESC b Previous word\n"
" ESC f Next word\n"
" ESC d Delete next word (Alt-d)\n"
" ESC BACKSPACE Delete previous word (Alt-Backspace)\n"
" ESC q Reflow paragraph\n" " ESC q Reflow paragraph\n"
" ESC BACKSPACE Delete previous word\n" "\n"
" ESC d Delete next word\n" "Control keys:\n"
" Alt-w Copy region to kill ring\n\n" " C-a C-e Line start / end\n"
"Buffers:\n +HELP+ is read-only. Press C-k ' to toggle if you need to edit; C-k h restores it.\n" " C-b C-f Move left / right\n"
" C-n C-p Move down / up\n"
" C-d Delete char\n"
" C-w / C-y Kill region / Yank\n"
" C-s Incremental find\n"
" C-r Regex search\n"
" C-t Regex search & replace\n"
" C-h Search & replace\n"
" C-l / C-g Refresh / Cancel\n"
" C-u [digits] Universal argument (repeat count)\n"
"\n"
"Buffers:\n +HELP+ is read-only. Press C-k ' to toggle; C-k h restores it.\n"
"\n"
"GUI appearance (command prompt):\n"
" : theme NAME Set GUI theme (eink, gruvbox, nord, plan9, solarized)\n"
" : background MODE Set background: light | dark (affects eink, gruvbox, solarized)\n"
); );
} }

View File

@@ -8,10 +8,10 @@
class HelpText { class HelpText {
public: public:
// Returns the embedded help text as a single string with newlines. // Returns the embedded help text as a single string with newlines.
// Project maintainers can customize the returned string below // Project maintainers can customize the returned string below
// (in HelpText.cc) without touching the help command logic. // (in HelpText.cc) without touching the help command logic.
static std::string Text(); static std::string Text();
}; };
#endif // KTE_HELPTEXT_H #endif // KTE_HELPTEXT_H

View File

@@ -33,10 +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 == '\'') { if (ascii_key == '\'') {
out = CommandId::ToggleReadOnly; // C-k ' (toggle read-only) out = CommandId::ToggleReadOnly; // C-k ' (toggle read-only)
return true; return true;
} }
switch (k_lower) { switch (k_lower) {
case 'a': case 'a':
@@ -108,6 +108,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
case '=': case '=':
out = CommandId::IndentRegion; out = CommandId::IndentRegion;
return true; return true;
case ';':
out = CommandId::CommandPromptStart; // C-k ; : generic command prompt
return true;
default: default:
break; break;
} }
@@ -121,7 +124,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;
@@ -152,12 +155,12 @@ KLookupCtrlCommand(const int ascii_key, CommandId &out) -> bool
case 's': case 's':
out = CommandId::FindStart; out = CommandId::FindStart;
return true; return true;
case 'r': case 'r':
out = CommandId::RegexFindStart; // C-r regex search out = CommandId::RegexFindStart; // C-r regex search
return true; return true;
case 't': case 't':
out = CommandId::RegexpReplace; // C-t regex search & replace out = CommandId::RegexpReplace; // C-t regex search & replace
return true; return true;
case 'h': case 'h':
out = CommandId::SearchReplace; // C-h: search & replace out = CommandId::SearchReplace; // C-h: search & replace
return true; return true;

View File

@@ -41,140 +41,175 @@ 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;
// Compute matches for this line if search highlighting is active // Compute matches for this line if search highlighting is active
bool search_mode = ed.SearchActive() && !ed.SearchQuery().empty(); bool search_mode = ed.SearchActive() && !ed.SearchQuery().empty();
std::vector<std::pair<std::size_t, std::size_t>> ranges; // [start, end) std::vector<std::pair<std::size_t, std::size_t> > ranges; // [start, end)
if (search_mode && li < lines.size()) { if (search_mode && li < lines.size()) {
std::string sline = static_cast<std::string>(lines[li]); std::string sline = static_cast<std::string>(lines[li]);
// If regex search prompt is active (RegexSearch or RegexReplaceFind), use regex to compute highlight ranges // If regex search prompt is active (RegexSearch or RegexReplaceFind), use regex to compute highlight ranges
if (ed.PromptActive() && (ed.CurrentPromptKind() == Editor::PromptKind::RegexSearch || ed.CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind)) { if (ed.PromptActive() && (
try { ed.CurrentPromptKind() == Editor::PromptKind::RegexSearch || ed.
std::regex rx(ed.SearchQuery()); CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind)) {
for (auto it = std::sregex_iterator(sline.begin(), sline.end(), rx); try {
it != std::sregex_iterator(); ++it) { std::regex rx(ed.SearchQuery());
const auto &m = *it; for (auto it = std::sregex_iterator(sline.begin(), sline.end(), rx);
std::size_t sx = static_cast<std::size_t>(m.position()); it != std::sregex_iterator(); ++it) {
std::size_t ex = sx + static_cast<std::size_t>(m.length()); const auto &m = *it;
ranges.emplace_back(sx, ex); std::size_t sx = static_cast<std::size_t>(m.position());
} std::size_t ex = sx + static_cast<std::size_t>(m.length());
} catch (const std::regex_error &) { ranges.emplace_back(sx, ex);
// ignore invalid patterns here; status shows error }
} } catch (const std::regex_error &) {
} else { // ignore invalid patterns here; status shows error
const std::string &q = ed.SearchQuery(); }
std::size_t pos = 0; } else {
while (!q.empty() && (pos = sline.find(q, pos)) != std::string::npos) { const std::string &q = ed.SearchQuery();
ranges.emplace_back(pos, pos + q.size()); std::size_t pos = 0;
pos += q.size(); while (!q.empty() && (pos = sline.find(q, pos)) != std::string::npos) {
} ranges.emplace_back(pos, pos + q.size());
} pos += q.size();
} }
auto is_src_in_hl = [&](std::size_t si) -> bool { }
if (ranges.empty()) return false; }
// ranges are non-overlapping and ordered by construction auto is_src_in_hl = [&](std::size_t si) -> bool {
// linear scan is fine for now if (ranges.empty())
for (const auto &rg : ranges) { return false;
if (si < rg.first) break; // ranges are non-overlapping and ordered by construction
if (si >= rg.first && si < rg.second) return true; // linear scan is fine for now
} for (const auto &rg: ranges) {
return false; if (si < rg.first)
}; break;
// Track current-match to optionally emphasize if (si >= rg.first && si < rg.second)
const bool has_current = ed.SearchActive() && ed.SearchMatchLen() > 0; return true;
const std::size_t cur_mx = has_current ? ed.SearchMatchX() : 0; }
const std::size_t cur_my = has_current ? ed.SearchMatchY() : 0; return false;
const std::size_t cur_mend = has_current ? (ed.SearchMatchX() + ed.SearchMatchLen()) : 0; };
bool hl_on = false; // Track current-match to optionally emphasize
bool cur_on = false; const bool has_current = ed.SearchActive() && ed.SearchMatchLen() > 0;
int written = 0; const std::size_t cur_mx = has_current ? ed.SearchMatchX() : 0;
if (li < lines.size()) { const std::size_t cur_my = has_current ? ed.SearchMatchY() : 0;
std::string line = static_cast<std::string>(lines[li]); const std::size_t cur_mend = has_current ? (ed.SearchMatchX() + ed.SearchMatchLen()) : 0;
src_i = 0; bool hl_on = false;
render_col = 0; bool cur_on = false;
while (written < cols) { int written = 0;
char ch = ' '; if (li < lines.size()) {
bool from_src = false; std::string line = static_cast<std::string>(lines[li]);
if (src_i < line.size()) { src_i = 0;
unsigned char c = static_cast<unsigned char>(line[src_i]); render_col = 0;
if (c == '\t') { while (written < cols) {
std::size_t next_tab = tabw - (render_col % tabw); char ch = ' ';
if (render_col + next_tab <= coloffs) { bool from_src = false;
render_col += next_tab; if (src_i < line.size()) {
++src_i; unsigned char c = static_cast<unsigned char>(line[src_i]);
continue; if (c == '\t') {
} std::size_t next_tab = tabw - (render_col % tabw);
// Emit spaces for tab if (render_col + next_tab <= coloffs) {
if (render_col < coloffs) { render_col += next_tab;
// skip to coloffs ++src_i;
std::size_t to_skip = std::min<std::size_t>( continue;
next_tab, coloffs - render_col); }
render_col += to_skip; // Emit spaces for tab
next_tab -= to_skip; if (render_col < coloffs) {
} // skip to coloffs
// Now render visible spaces std::size_t to_skip = std::min<std::size_t>(
while (next_tab > 0 && written < cols) { next_tab, coloffs - render_col);
bool in_hl = search_mode && is_src_in_hl(src_i); render_col += to_skip;
bool in_cur = has_current && li == cur_my && src_i >= cur_mx && src_i < cur_mend; next_tab -= to_skip;
// Toggle highlight attributes }
int attr = 0; // Now render visible spaces
if (in_hl) attr |= A_STANDOUT; while (next_tab > 0 && written < cols) {
if (in_cur) attr |= A_BOLD; bool in_hl = search_mode && is_src_in_hl(src_i);
if ((attr & A_STANDOUT) && !hl_on) { attron(A_STANDOUT); hl_on = true; } bool in_cur =
if (!(attr & A_STANDOUT) && hl_on) { attroff(A_STANDOUT); hl_on = false; } has_current && li == cur_my && src_i >= cur_mx
if ((attr & A_BOLD) && !cur_on) { attron(A_BOLD); cur_on = true; } && src_i < cur_mend;
if (!(attr & A_BOLD) && cur_on) { attroff(A_BOLD); cur_on = false; } // Toggle highlight attributes
addch(' '); int attr = 0;
++written; if (in_hl)
++render_col; attr |= A_STANDOUT;
--next_tab; if (in_cur)
} attr |= A_BOLD;
++src_i; if ((attr & A_STANDOUT) && !hl_on) {
continue; attron(A_STANDOUT);
} else { hl_on = true;
// normal char }
if (render_col < coloffs) { if (!(attr & A_STANDOUT) && hl_on) {
++render_col; attroff(A_STANDOUT);
++src_i; hl_on = false;
continue; }
} if ((attr & A_BOLD) && !cur_on) {
ch = static_cast<char>(c); attron(A_BOLD);
from_src = true; cur_on = true;
} }
} else { if (!(attr & A_BOLD) && cur_on) {
// beyond EOL, fill spaces attroff(A_BOLD);
ch = ' '; cur_on = false;
from_src = false; }
} addch(' ');
bool in_hl = search_mode && from_src && is_src_in_hl(src_i); ++written;
bool in_cur = has_current && li == cur_my && from_src && src_i >= cur_mx && src_i < cur_mend; ++render_col;
if (in_hl && !hl_on) { attron(A_STANDOUT); hl_on = true; } --next_tab;
if (!in_hl && hl_on) { attroff(A_STANDOUT); hl_on = false; } }
if (in_cur && !cur_on) { attron(A_BOLD); cur_on = true; } ++src_i;
if (!in_cur && cur_on) { attroff(A_BOLD); cur_on = false; } continue;
addch(static_cast<unsigned char>(ch)); } else {
++written; // normal char
++render_col; if (render_col < coloffs) {
if (from_src) ++render_col;
++src_i; ++src_i;
if (src_i >= line.size() && written >= cols) continue;
break; }
} ch = static_cast<char>(c);
} from_src = true;
if (hl_on) { }
attroff(A_STANDOUT); } else {
hl_on = false; // beyond EOL, fill spaces
} ch = ' ';
if (cur_on) { from_src = false;
attroff(A_BOLD); }
cur_on = false; bool in_hl = search_mode && from_src && is_src_in_hl(src_i);
} bool in_cur =
clrtoeol(); 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();
@@ -191,71 +226,74 @@ TerminalRenderer::Draw(Editor &ed)
mvaddstr(0, 0, "[no buffer]"); mvaddstr(0, 0, "[no buffer]");
} }
// Status line (inverse) // 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 a prompt is active, replace the status bar with the full prompt text
if (ed.PromptActive()) { if (ed.PromptActive()) {
// Build prompt text: "Label: text" and shorten HOME path for file-related prompts // Build prompt text: "Label: text" and shorten HOME path for file-related prompts
std::string label = ed.PromptLabel(); std::string label = ed.PromptLabel();
std::string ptext = ed.PromptText(); std::string ptext = ed.PromptText();
auto kind = ed.CurrentPromptKind(); auto kind = ed.CurrentPromptKind();
if (kind == Editor::PromptKind::OpenFile || kind == Editor::PromptKind::SaveAs || if (kind == Editor::PromptKind::OpenFile || kind == Editor::PromptKind::SaveAs ||
kind == Editor::PromptKind::Chdir) { kind == Editor::PromptKind::Chdir) {
const char *home_c = std::getenv("HOME"); const char *home_c = std::getenv("HOME");
if (home_c && *home_c) { if (home_c && *home_c) {
std::string home(home_c); std::string home(home_c);
// Ensure we match only at the start // Ensure we match only at the start
if (ptext.rfind(home, 0) == 0) { if (ptext.rfind(home, 0) == 0) {
std::string rest = ptext.substr(home.size()); std::string rest = ptext.substr(home.size());
if (rest.empty()) if (rest.empty())
ptext = "~"; ptext = "~";
else if (rest[0] == '/' || rest[0] == '\\') else if (rest[0] == '/' || rest[0] == '\\')
ptext = std::string("~") + rest; ptext = std::string("~") + rest;
} }
} }
} }
// Prefer keeping the tail of the filename visible when it exceeds the window // Prefer keeping the tail of the filename visible when it exceeds the window
std::string msg; std::string msg;
if (!label.empty()) { if (kind == Editor::PromptKind::Command) {
msg = label + ": "; msg = ": ";
} } else if (!label.empty()) {
// When dealing with file-related prompts, left-trim the filename text so the tail stays visible msg = label + ": ";
if ((kind == Editor::PromptKind::OpenFile || kind == Editor::PromptKind::SaveAs || kind == Editor::PromptKind::Chdir) && cols > 0) { }
int avail = cols - static_cast<int>(msg.size()); // When dealing with file-related prompts, left-trim the filename text so the tail stays visible
if (avail <= 0) { if ((kind == Editor::PromptKind::OpenFile || kind == Editor::PromptKind::SaveAs || kind ==
// No room for label; fall back to showing the rightmost portion of the whole string Editor::PromptKind::Chdir) && cols > 0) {
std::string whole = msg + ptext; int avail = cols - static_cast<int>(msg.size());
if ((int)whole.size() > cols) if (avail <= 0) {
whole = whole.substr(whole.size() - cols); // No room for label; fall back to showing the rightmost portion of the whole string
msg = whole; std::string whole = msg + ptext;
} else { if ((int) whole.size() > cols)
if ((int)ptext.size() > avail) { whole = whole.substr(whole.size() - cols);
ptext = ptext.substr(ptext.size() - avail); msg = whole;
} } else {
msg += ptext; if ((int) ptext.size() > avail) {
} ptext = ptext.substr(ptext.size() - avail);
} else { }
// Non-file prompts: simple concatenation and clip by terminal msg += ptext;
msg += ptext; }
} } else {
// Non-file prompts: simple concatenation and clip by terminal
msg += ptext;
}
// Draw left-aligned, clipped to width // Draw left-aligned, clipped to width
if (!msg.empty()) if (!msg.empty())
mvaddnstr(rows - 1, 0, msg.c_str(), std::max(0, cols)); mvaddnstr(rows - 1, 0, msg.c_str(), std::max(0, cols));
// End status rendering for prompt mode // End status rendering for prompt mode
attroff(A_REVERSE); attroff(A_REVERSE);
// Restore logical cursor position in content area // Restore logical cursor position in content area
if (saved_cur_y >= 0 && saved_cur_x >= 0) if (saved_cur_y >= 0 && saved_cur_x >= 0)
move(saved_cur_y, saved_cur_x); move(saved_cur_y, saved_cur_x);
return; return;
} }
// Build left segment // Build left segment
std::string left; std::string left;
@@ -346,10 +384,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();
@@ -365,7 +403,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

@@ -5,79 +5,79 @@
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)
{ {
#ifdef KTE_UNDO_DEBUG #ifdef KTE_UNDO_DEBUG
debug_log("Begin"); debug_log("Begin");
#endif #endif
// Reuse pending if batching conditions are met // Reuse pending if batching conditions are met
const int row = static_cast<int>(buf_->Cury()); const int row = static_cast<int>(buf_->Cury());
const int col = static_cast<int>(buf_->Curx()); const int col = static_cast<int>(buf_->Curx());
if (tree_.pending && tree_.pending->type == type && tree_.pending->row == row) { if (tree_.pending && tree_.pending->type == type && tree_.pending->row == row) {
if (type == UndoType::Delete) { if (type == UndoType::Delete) {
// Support batching both forward deletes (DeleteChar) and backspace (prepend case) // Support batching both forward deletes (DeleteChar) and backspace (prepend case)
// Forward delete: cursor stays at anchor col; keep batching when col == anchor // Forward delete: cursor stays at anchor col; keep batching when col == anchor
const auto anchor = static_cast<std::size_t>(tree_.pending->col); const auto anchor = static_cast<std::size_t>(tree_.pending->col);
if (anchor == static_cast<std::size_t>(col)) { if (anchor == static_cast<std::size_t>(col)) {
pending_prepend_ = false; pending_prepend_ = false;
return; // keep batching forward delete return; // keep batching forward delete
} }
// Backspace: cursor moved left by exactly one position relative to current anchor. // Backspace: cursor moved left by exactly one position relative to current anchor.
// Extend batch by shifting anchor left and prepending the deleted byte. // Extend batch by shifting anchor left and prepending the deleted byte.
if (static_cast<std::size_t>(col) + 1 == anchor) { if (static_cast<std::size_t>(col) + 1 == anchor) {
tree_.pending->col = col; tree_.pending->col = col;
pending_prepend_ = true; pending_prepend_ = true;
return; return;
} }
} else { } else {
std::size_t expected = static_cast<std::size_t>(tree_.pending->col) + tree_.pending->text. std::size_t expected = static_cast<std::size_t>(tree_.pending->col) + tree_.pending->text.
size(); size();
if (expected == static_cast<std::size_t>(col)) { if (expected == static_cast<std::size_t>(col)) {
pending_prepend_ = false; pending_prepend_ = false;
return; // keep batching return; // keep batching
} }
} }
} }
// Otherwise commit any existing batch and start a new node // Otherwise commit any existing batch and start a new node
commit(); commit();
auto *node = new UndoNode(); auto *node = new UndoNode();
node->type = type; node->type = type;
node->row = row; node->row = row;
node->col = col; node->col = col;
node->child = nullptr; node->child = nullptr;
node->next = nullptr; node->next = nullptr;
tree_.pending = node; tree_.pending = node;
pending_prepend_ = false; pending_prepend_ = false;
#ifdef KTE_UNDO_DEBUG #ifdef KTE_UNDO_DEBUG
debug_log("Begin:new"); debug_log("Begin:new");
#endif #endif
// Assert pending is detached from the tree // Assert pending is detached from the tree
assert(tree_.pending && "pending must exist after Begin"); assert(tree_.pending && "pending must exist after Begin");
assert(tree_.pending != tree_.root); assert(tree_.pending != tree_.root);
assert(tree_.pending != tree_.current); assert(tree_.pending != tree_.current);
assert(tree_.pending != tree_.saved); assert(tree_.pending != tree_.saved);
assert(!is_descendant(tree_.root, tree_.pending)); 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 #ifdef KTE_UNDO_DEBUG
debug_log("Append:ch"); debug_log("Append:ch");
#endif #endif
} }
@@ -85,11 +85,11 @@ UndoSystem::Append(char ch)
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 #ifdef KTE_UNDO_DEBUG
debug_log("Append:sv"); debug_log("Append:sv");
#endif #endif
} }
@@ -98,10 +98,10 @@ void
UndoSystem::commit() UndoSystem::commit()
{ {
#ifdef KTE_UNDO_DEBUG #ifdef KTE_UNDO_DEBUG
debug_log("commit:enter"); debug_log("commit:enter");
#endif #endif
if (!tree_.pending) if (!tree_.pending)
return; 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) {
@@ -127,31 +127,31 @@ 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 #ifdef KTE_UNDO_DEBUG
debug_log("commit:done"); debug_log("commit:done");
#endif #endif
// post-conditions // post-conditions
assert(tree_.pending == nullptr && "pending must be cleared after commit"); 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 #ifdef KTE_UNDO_DEBUG
debug_log("undo"); debug_log("undo");
#endif #endif
} }
@@ -159,24 +159,24 @@ UndoSystem::undo()
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 #ifdef KTE_UNDO_DEBUG
debug_log("redo"); debug_log("redo");
#endif #endif
} }
@@ -184,10 +184,10 @@ UndoSystem::redo()
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 #ifdef KTE_UNDO_DEBUG
debug_log("mark_saved"); debug_log("mark_saved");
#endif #endif
} }
@@ -195,12 +195,12 @@ UndoSystem::mark_saved()
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 #ifdef KTE_UNDO_DEBUG
debug_log("discard_pending"); debug_log("discard_pending");
#endif #endif
} }
@@ -208,16 +208,16 @@ UndoSystem::discard_pending()
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 #ifdef KTE_UNDO_DEBUG
debug_log("clear"); debug_log("clear");
#endif #endif
} }
@@ -326,62 +326,73 @@ 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 ---- // ---- Debug helpers ----
const char * const char *
UndoSystem::type_str(UndoType t) UndoSystem::type_str(UndoType t)
{ {
switch (t) { switch (t) {
case UndoType::Insert: return "Insert"; case UndoType::Insert:
case UndoType::Delete: return "Delete"; return "Insert";
case UndoType::Paste: return "Paste"; case UndoType::Delete:
case UndoType::Newline: return "Newline"; return "Delete";
case UndoType::DeleteRow: return "DeleteRow"; case UndoType::Paste:
} return "Paste";
return "?"; case UndoType::Newline:
return "Newline";
case UndoType::DeleteRow:
return "DeleteRow";
}
return "?";
} }
bool bool
UndoSystem::is_descendant(UndoNode *root, const UndoNode *target) UndoSystem::is_descendant(UndoNode *root, const UndoNode *target)
{ {
if (!root || !target) return false; if (!root || !target)
if (root == target) return true; return false;
for (UndoNode *child = root->child; child != nullptr; child = child->next) { if (root == target)
if (is_descendant(child, target)) return true; return true;
} for (UndoNode *child = root->child; child != nullptr; child = child->next) {
return false; if (is_descendant(child, target))
return true;
}
return false;
} }
void void
UndoSystem::debug_log(const char *op) const UndoSystem::debug_log(const char *op) const
{ {
#ifdef KTE_UNDO_DEBUG #ifdef KTE_UNDO_DEBUG
int row = static_cast<int>(buf_->Cury()); int row = static_cast<int>(buf_->Cury());
int col = static_cast<int>(buf_->Curx()); int col = static_cast<int>(buf_->Curx());
const UndoNode *p = tree_.pending; const UndoNode *p = tree_.pending;
std::fprintf(stderr, std::fprintf(stderr,
"[UNDO] %s cur=(%d,%d) pending=%p t=%s r=%d c=%d nlen=%zu current=%p saved=%p\n", "[UNDO] %s cur=(%d,%d) pending=%p t=%s r=%d c=%d nlen=%zu current=%p saved=%p\n",
op, op,
row, col, row, col,
(const void*)p, (const void *) p,
p ? type_str(p->type) : "-", p ? type_str(p->type) : "-",
p ? p->row : -1, p ? p->row : -1,
p ? p->col : -1, p ? p->col : -1,
p ? p->text.size() : 0, p ? p->text.size() : 0,
(void*)tree_.current, (void *) tree_.current,
(void*)tree_.saved); (void *) tree_.saved);
#else #else
(void)op; (void) op;
#endif #endif
} }

View File

@@ -12,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);
@@ -30,28 +30,30 @@ 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);
// Debug helpers (compiled only when KTE_UNDO_DEBUG is defined) // Debug helpers (compiled only when KTE_UNDO_DEBUG is defined)
void debug_log(const char *op) const; 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(); 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,7 +1,7 @@
.\" kge(1) — Kyle's Graphical Editor (GUI-first) .\" kge(1) — Kyle's Graphical Editor (GUI-first)
.\" .\"
.\" Project homepage: https://github.com/wntrmute/kte .\" Project homepage: https://github.com/wntrmute/kte
.TH KGE 1 "2025-11-30" "kte 0.1.0" "User Commands" .TH KGE 1 "2025-12-01" "kte 0.1.0" "User Commands"
.SH NAME .SH NAME
kge \- Kyle's Graphical Editor (GUI-first) kge \- Kyle's Graphical Editor (GUI-first)
.SH SYNOPSIS .SH SYNOPSIS
@@ -52,11 +52,8 @@ tree for the canonical reference and notes:
.PP .PP
Enter K-command mode with Ctrl-K. Exit K-command mode with ESC or Ctrl-G. Enter K-command mode with Ctrl-K. Exit K-command mode with ESC or Ctrl-G.
.TP .TP
.B C-k BACKSPACE .B C-k '
Delete from the cursor to the beginning of the line. Toggle read-only for the current buffer.
.TP
.B C-k SPACE
Toggle the mark.
.TP .TP
.B C-k - .B C-k -
If the mark is set, unindent the region. If the mark is set, unindent the region.
@@ -64,6 +61,9 @@ If the mark is set, unindent the region.
.B C-k = .B C-k =
If the mark is set, indent the region. If the mark is set, indent the region.
.TP .TP
.B C-k ;
Open the generic command prompt (": ").
.TP
.B C-k a .B C-k a
Set the mark at the beginning of the file, then jump to the end of the file. Set the mark at the beginning of the file, then jump to the end of the file.
.TP .TP
@@ -80,7 +80,7 @@ Delete from the cursor to the end of the line.
Delete the entire line. Delete the entire line.
.TP .TP
.B C-k e .B C-k e
Edit a new file. Edit (open) a new file.
.TP .TP
.B C-k f .B C-k f
Flush the kill ring. Flush the kill ring.
@@ -88,14 +88,20 @@ Flush the kill ring.
.B C-k g .B C-k g
Go to a specific line. Go to a specific line.
.TP .TP
.B C-k h
Show the built-in help (+HELP+ buffer).
.TP
.B C-k j .B C-k j
Jump to the mark. Jump to the mark.
.TP .TP
.B C-k l .B C-k l
Reload the current buffer from disk. Reload the current buffer from disk.
.TP .TP
.B C-k m .B C-k n
Run make(1), reporting success or failure. Switch to the previous buffer.
.TP
.B C-k o
Change working directory (prompt).
.TP .TP
.B C-k p .B C-k p
Switch to the next buffer. Switch to the next buffer.
@@ -106,14 +112,20 @@ Exit the editor. If the file has unsaved changes, a warning will be printed; a s
.B C-k C-q .B C-k C-q
Immediately exit the editor. Immediately exit the editor.
.TP .TP
.B C-k r
Redo changes.
.TP
.B C-k s .B C-k s
Save the file, prompting for a filename if needed. Save the file, prompting for a filename if needed.
.TP .TP
.B C-k u .B C-k u
Undo. Undo.
.TP .TP
.B C-k r .B C-k v
Redo changes. Toggle visual file picker (GUI).
.TP
.B C-k w
Show the current working directory.
.TP .TP
.B C-k x .B C-k x
Save the file and exit. Also C-k C-x. Save the file and exit. Also C-k C-x.
@@ -121,23 +133,50 @@ Save the file and exit. Also C-k C-x.
.B C-k y .B C-k y
Yank the kill ring. Yank the kill ring.
.TP .TP
.B C-k \e .B C-k C-x
Dump core. Save the file and exit.
.SS Other keybindings .SS Other keybindings
.TP .TP
.B C-g .B C-g
Cancel the current operation. Cancel the current operation.
.TP .TP
.B C-a
Move to the beginning of the line.
.TP
.B C-e
Move to the end of the line.
.TP
.B C-b
Move left.
.TP
.B C-f
Move right.
.TP
.B C-n
Move down.
.TP
.B C-p
Move up.
.TP
.B C-l .B C-l
Refresh the display. Refresh the display.
.TP .TP
.B C-d
Delete the character at the cursor.
.TP
.B C-r .B C-r
Regex search. Regex search.
.TP .TP
.B C-s .B C-s
Incremental find. Incremental find.
.TP .TP
.B C-t
Regex search and replace.
.TP
.B C-h
Search and replace.
.TP
.B C-u .B C-u
Universal argument. C-u followed by numbers will repeat an operation n times. Universal argument. C-u followed by numbers will repeat an operation n times.
.TP .TP
@@ -147,6 +186,15 @@ Kill the region if the mark is set.
.B C-y .B C-y
Yank the kill ring. Yank the kill ring.
.TP .TP
.B ESC <
Move to the beginning of the file.
.TP
.B ESC >
Move to the end of the file.
.TP
.B ESC m
Toggle the mark.
.TP
.B ESC BACKSPACE .B ESC BACKSPACE
Delete the previous word. Delete the previous word.
.TP .TP

View File

@@ -1,7 +1,7 @@
.\" kte(1) — Kyle's Text Editor (terminal-first) .\" kte(1) — Kyle's Text Editor (terminal-first)
.\" .\"
.\" Project homepage: https://github.com/wntrmute/kte .\" Project homepage: https://github.com/wntrmute/kte
.TH KTE 1 "2025-11-30" "kte 0.1.0" "User Commands" .TH KTE 1 "2025-12-01" "kte 0.1.0" "User Commands"
.SH NAME .SH NAME
kte \- Kyle's Text Editor (terminal-first) kte \- Kyle's Text Editor (terminal-first)
.SH SYNOPSIS .SH SYNOPSIS
@@ -57,11 +57,8 @@ in the source tree for the canonical reference and notes.
.PP .PP
Enter K-command mode with Ctrl-K. Exit K-command mode with ESC or Ctrl-G. Enter K-command mode with Ctrl-K. Exit K-command mode with ESC or Ctrl-G.
.TP .TP
.B C-k BACKSPACE .B C-k '
Delete from the cursor to the beginning of the line. Toggle read-only for the current buffer.
.TP
.B C-k SPACE
Toggle the mark.
.TP .TP
.B C-k - .B C-k -
If the mark is set, unindent the region. If the mark is set, unindent the region.
@@ -69,6 +66,9 @@ If the mark is set, unindent the region.
.B C-k = .B C-k =
If the mark is set, indent the region. If the mark is set, indent the region.
.TP .TP
.B C-k ;
Open the generic command prompt (": ").
.TP
.B C-k a .B C-k a
Set the mark at the beginning of the file, then jump to the end of the file. Set the mark at the beginning of the file, then jump to the end of the file.
.TP .TP
@@ -85,7 +85,7 @@ Delete from the cursor to the end of the line.
Delete the entire line. Delete the entire line.
.TP .TP
.B C-k e .B C-k e
Edit a new file. Edit (open) a new file.
.TP .TP
.B C-k f .B C-k f
Flush the kill ring. Flush the kill ring.
@@ -93,14 +93,20 @@ Flush the kill ring.
.B C-k g .B C-k g
Go to a specific line. Go to a specific line.
.TP .TP
.B C-k h
Show the built-in help (+HELP+ buffer).
.TP
.B C-k j .B C-k j
Jump to the mark. Jump to the mark.
.TP .TP
.B C-k l .B C-k l
Reload the current buffer from disk. Reload the current buffer from disk.
.TP .TP
.B C-k m .B C-k n
Run make(1), reporting success or failure. Switch to the previous buffer.
.TP
.B C-k o
Change working directory (prompt).
.TP .TP
.B C-k p .B C-k p
Switch to the next buffer. Switch to the next buffer.
@@ -111,14 +117,20 @@ Exit the editor. If the file has unsaved changes, a warning will be printed; a s
.B C-k C-q .B C-k C-q
Immediately exit the editor. Immediately exit the editor.
.TP .TP
.B C-k r
Redo changes.
.TP
.B C-k s .B C-k s
Save the file, prompting for a filename if needed. Save the file, prompting for a filename if needed.
.TP .TP
.B C-k u .B C-k u
Undo. Undo.
.TP .TP
.B C-k r .B C-k v
Redo changes. Toggle visual file picker (GUI).
.TP
.B C-k w
Show the current working directory.
.TP .TP
.B C-k x .B C-k x
Save the file and exit. Also C-k C-x. Save the file and exit. Also C-k C-x.
@@ -126,23 +138,76 @@ Save the file and exit. Also C-k C-x.
.B C-k y .B C-k y
Yank the kill ring. Yank the kill ring.
.TP .TP
.B C-k \e .B C-k C-x
Dump core. Save the file and exit.
.SH GUI APPEARANCE
When running the GUI frontend, you can control appearance via the generic
command prompt (type "C-k ;" then enter commands):
.TP
.B : theme NAME
Set the GUI theme. Available names: "nord", "gruvbox", "plan9", "solarized", "eink".
Compatibility aliases are also accepted: "gruvbox-dark", "gruvbox-light",
"solarized-dark", "solarized-light", "eink-dark", "eink-light".
.TP
.B : background MODE
Set background mode for supported themes. MODE is either "light" or "dark".
Themes that respond to background: eink, gruvbox, solarized. The
"nord" and "plan9" themes do not vary with background.
.SH CONFIGURATION
The GUI reads a simple configuration file at
~/.config/kte/kge.ini. Recognized keys include:
.IP "fullscreen=on|off"
.IP "columns=NUM"
.IP "rows=NUM"
.IP "font_size=NUM"
.IP "theme=NAME"
.IP "background=light|dark"
The theme name accepts the values listed above. The background key controls
light/dark variants when the selected theme supports it.
.SS Other keybindings .SS Other keybindings
.TP .TP
.B C-g .B C-g
Cancel the current operation. Cancel the current operation.
.TP .TP
.B C-a
Move to the beginning of the line.
.TP
.B C-e
Move to the end of the line.
.TP
.B C-b
Move left.
.TP
.B C-f
Move right.
.TP
.B C-n
Move down.
.TP
.B C-p
Move up.
.TP
.B C-l .B C-l
Refresh the display. Refresh the display.
.TP .TP
.B C-d
Delete the character at the cursor.
.TP
.B C-r .B C-r
Regex search. Regex search.
.TP .TP
.B C-s .B C-s
Incremental find. Incremental find.
.TP .TP
.B C-t
Regex search and replace.
.TP
.B C-h
Search and replace.
.TP
.B C-u .B C-u
Universal argument. C-u followed by numbers will repeat an operation n times. Universal argument. C-u followed by numbers will repeat an operation n times.
.TP .TP
@@ -152,6 +217,15 @@ Kill the region if the mark is set.
.B C-y .B C-y
Yank the kill ring. Yank the kill ring.
.TP .TP
.B ESC <
Move to the beginning of the file.
.TP
.B ESC >
Move to the end of the file.
.TP
.B ESC m
Toggle the mark.
.TP
.B ESC BACKSPACE .B ESC BACKSPACE
Delete the previous word. Delete the previous word.
.TP .TP

102
docs/syntax on.md Normal file
View File

@@ -0,0 +1,102 @@
### Objective
Introduce fast, minimaldependency syntax highlighting to kte, consistent with current architecture (Editor/Buffer + GUI/Terminal renderers), preserving ke UX and performance.
### Guiding principles
- Keep core small and fast; no heavy deps (C++17 only).
- Start simple (stateless line regex), evolve incrementally (stateful, caching).
- Work in both Terminal (ncurses) and GUI (ImGui) with consistent token classes and theme mapping.
- Integrate without disrupting existing search highlight, selection, or cursor rendering.
### Scope of v1
- Languages: plain text (off), C/C++ minimal set (keywords, types, strings, chars, comments, numbers, preprocessor).
- Stateless perline highlighting; handle singleline comments and strings; defer multiline state to v2.
- Toggle: `:syntax on|off` and perbuffer filetype selection.
### Architecture
1. Core types (new):
- `enum class TokenKind { Default, Keyword, Type, String, Char, Comment, Number, Preproc, Constant, Function, Operator, Punctuation, Identifier, Whitespace, Error };`
- `struct HighlightSpan { int col_start; int col_end; TokenKind kind; };` // 0based columns in buffer indices per rendered line
- `struct LineHighlight { std::vector<HighlightSpan> spans; uint64_t version; };`
2. Interfaces (new):
- `class LanguageHighlighter { public: virtual ~LanguageHighlighter() = default; virtual void HighlightLine(const Buffer& buf, int row, std::vector<HighlightSpan>& out) const = 0; virtual bool Stateful() const { return false; } };`
- `class HighlighterEngine { public: void SetHighlighter(std::unique_ptr<LanguageHighlighter>); const LineHighlight& GetLine(const Buffer&, int row, uint64_t buf_version); void InvalidateFrom(int row); };`
- `class HighlighterRegistry { public: static const LanguageHighlighter& ForFiletype(std::string_view ft); static std::string DetectForPath(std::string_view path, std::string_view first_line); };`
3. Editor/Buffer integration:
- PerBuffer settings: `bool syntax_enabled; std::string filetype; std::unique_ptr<HighlighterEngine> highlighter;`
- Buffer emits a monotonically increasing `version` on edit; renderers request line highlights by `(row, version)`.
- Invalidate cache minimally on edits (v1: current line only; v2: from current line down when stateful constructs present).
### Rendering integration
- TerminalRenderer/GUIRenderer changes:
- During line rendering, query `Editor.CurrentBuffer()->highlighter->GetLine(buf, row, buf_version)` to obtain spans.
- Apply token styles while drawing glyph runs.
- Zorder and blending:
1) Backgrounds (e.g., selection, search highlight rectangles)
2) Text with syntax colors
3) Cursor/IME decorations
- Search highlights must remain visible over syntax colors:
- Terminal: combine color/attr with reverse/bold for search; if color conflicts, prefer search.
- GUI: draw semitransparent rects behind text (already present); keep syntax color for text.
### Theme and color mapping
- Extend `GUITheme.h` with a `SyntaxPalette` mapping `TokenKind -> ImVec4 ink` (and optional background tint for comments/strings disabled by default). Provide default Light/Dark palettes.
- Terminal: map `TokenKind` to ncurses color pairs where available; degrade gracefully on 8/16color terminals (e.g., comments=dim, keywords=bold, strings=yellow/green if available).
### Language detection
- v1: by file extension; allow manual `:set filetype=<lang>`.
- v2: add shebang detection for scripts, simple modelines (optional).
### Commands/UX
- `:syntax on|off` — global default; buffer inherits on open.
- `:set filetype=<lang>` — perbuffer override.
- `:syntax reload` — rebuild patterns/themes.
- Status line shows filetype and syntax state when changed.
### Implementation plan (phased)
1. Phase 1 — Minimal regex highlighter for C/C++
- Implement `CppRegexHighlighter : LanguageHighlighter` with precompiled `std::regex` (or handrolled simple scanners to avoid regex backtracking). Classes: line comment `//…`, block comment start `/*` (no state), string `"…"`, char `'…'` (no multiline), numbers, keywords/types, preprocessor `^\s*#\w+`.
- Add `HighlighterEngine` with a simple perrow cache keyed by `(row, buf_version)`; no background worker.
- Integrate into both renderers; add palette to `GUITheme.h`; add terminal color selection.
- Add commands.
2. Phase 2 — Stateful constructs and more languages
- Add state machine for multiline comments `/*…*/` and multiline strings (C++11 raw strings), with invalidation from edit line downward until state stabilizes.
- Add simple highlighters: JSON (strings, numbers, booleans, null, punctuation), Markdown (headers/emphasis/code fences), Shell (comments, strings, keywords), Go (types, constants, keywords), Python (strings, comments, keywords), Rust (strings, comments, keywords), Lisp (comments, strings, keywords),.
- Filetype detection by extension + shebang.
3. Phase 3 — Performance and caching
- Viewportfirst highlighting: compute only visible rows each frame; background task warms cache around viewport.
- Reuse span buffers, avoid allocations; smallvector optimization if needed.
- Bench with large files; ensure O(n_visible) cost per frame.
4. Phase 4 — Extensibility
- Public registration API for external highlighters.
- Optional Treesitter adapter behind a compile flag (off by default) to keep dependencies minimal.
### Data flow (per frame)
- Renderer asks Editor for Buffer and viewport rows.
- For each row: `engine.GetLine(buf, row, buf.version)` → spans.
- Renderer emits runs with style from `SyntaxPalette[kind]`.
- Search highlights are applied as separate background rectangles (GUI) or attribute toggles (Terminal), not overriding text color.
### Testing
- Unit tests for tokenization per language: golden inputs → spans.
- Fuzz/edge cases: escaped quotes, numeric literals, preprocessor lines.
- Renderer tests with `TestRenderer` asserting the sequence of style changes for a line.
- Performance tests: highlight 1k visible lines repeatedly; assert time under threshold.
### Risks and mitigations
- Regex backtracking/perf: prefer linear scans; precompute keyword tables; avoid nested regex.
- Terminal color limitations: featuredetect colors; provide bold/dim fallbacks.
- Stateful correctness: invalidate conservatively (from edit line downward) and cap work per frame.
### Deliverables
- New files: `Highlight.h/.cc`, `HighlighterEngine.h/.cc`, `LanguageHighlighter.h`, `CppHighlighter.h/.cc`, optional `HighlighterRegistry.h/.cc`.
- Renderer updates: `GUIRenderer.cc`, `TerminalRenderer.cc` to consume spans.
- Theming: `GUITheme.h` additions for syntax colors.
- Editor/Buffer: perbuffer syntax settings and highlighter handle.
- Commands in `Command.cc` and help text updates.
- Docs: README/ROADMAP update and a brief `docs/syntax.md`.
- Tests: unit and renderer golden tests.

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