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/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/LiveTemplatesHousekeeping/HotspotSessionHintIsShown/@EntryValue" value="true" type="bool" />
<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/RiderDebugger/RiderRestoreDecompile/RestoreDecompileSetting/@EntryValue" value="false" type="bool" />
@@ -34,7 +35,29 @@
</component>
<component name="ChangeListManager">
<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$/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>
<option name="SHOW_DIALOG" value="false" />
<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" />
</component>
<component name="OptimizeOnSaveOptions">
<option name="myRunOnSave" value="true" />
</component>
<component name="ProblemsViewState">
<option name="selectedTabId" value="AISelfReview" />
<option name="selectedTabId" value="CurrentFile" />
</component>
<component name="ProjectApplicationVersion">
<option name="ide" value="CLion" />
@@ -173,7 +197,7 @@
<workItem from="1764539556448" duration="156000" />
<workItem from="1764539725338" duration="1075000" />
<workItem from="1764542392763" duration="3512000" />
<workItem from="1764548345516" duration="39312000" />
<workItem from="1764548345516" duration="50201000" />
</task>
<task id="LOCAL-00001" summary="Add undo/redo infrastructure and buffer management additions.">
<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);
x += seg.size();
// Split line at x
std::string tail = rows_[y].substr(x);
rows_[y].erase(x);
rows_.insert(rows_.begin() + static_cast<std::ptrdiff_t>(y + 1), Line(tail));
std::string tail = rows_[y].substr(x);
rows_[y].erase(x);
rows_.insert(rows_.begin() + static_cast<std::ptrdiff_t>(y + 1), Line(tail));
y += 1;
x = 0;
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 x = std::min<std::size_t>(static_cast<std::size_t>(col), rows_[y].size());
const auto tail = rows_[y].substr(x);
rows_[y].erase(x);
rows_.insert(rows_.begin() + static_cast<std::ptrdiff_t>(y + 1), Line(tail));
rows_[y].erase(x);
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;
if (static_cast<std::size_t>(row) > 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 {
public:
Buffer();
Buffer();
Buffer(const Buffer &other);
@@ -262,11 +262,12 @@ public:
return filename_;
}
// Set a virtual (non file-backed) display name for this buffer, e.g. "+HELP+"
// This does not mark the buffer as file-backed.
void SetVirtualName(const std::string &name)
{
filename_ = name;
filename_ = name;
is_file_backed_ = false;
}
@@ -277,26 +278,29 @@ public:
}
[[nodiscard]] bool Dirty() const
{
return dirty_;
}
[[nodiscard]] bool Dirty() const
{
return dirty_;
}
// Read-only flag
[[nodiscard]] bool IsReadOnly() const
{
return read_only_;
}
void SetReadOnly(bool ro)
{
read_only_ = ro;
}
// Read-only flag
[[nodiscard]] bool IsReadOnly() const
{
return read_only_;
}
void ToggleReadOnly()
{
read_only_ = !read_only_;
}
void SetReadOnly(bool ro)
{
read_only_ = ro;
}
void ToggleReadOnly()
{
read_only_ = !read_only_;
}
void SetCursor(const std::size_t x, const std::size_t y)
@@ -380,18 +384,18 @@ public:
[[nodiscard]] const UndoSystem *Undo() const;
private:
// State mirroring original C struct (without undo_tree)
std::size_t curx_ = 0, cury_ = 0; // cursor position in characters
std::size_t rx_ = 0; // render x (tabs expanded)
std::size_t nrows_ = 0; // number of rows
// State mirroring original C struct (without undo_tree)
std::size_t curx_ = 0, cury_ = 0; // cursor position in characters
std::size_t rx_ = 0; // render x (tabs expanded)
std::size_t nrows_ = 0; // number of rows
std::size_t rowoffs_ = 0, coloffs_ = 0; // viewport offsets
std::vector<Line> rows_; // buffer rows (without trailing newlines)
std::string filename_;
bool is_file_backed_ = false;
bool dirty_ = false;
bool read_only_ = false;
bool mark_set_ = false;
std::size_t mark_curx_ = 0, mark_cury_ = 0;
bool is_file_backed_ = false;
bool dirty_ = false;
bool read_only_ = false;
bool mark_set_ = false;
std::size_t mark_curx_ = 0, mark_cury_ = 0;
// Per-buffer undo state
std::unique_ptr<struct UndoTree> undo_tree_;

View File

@@ -4,7 +4,7 @@ project(kte)
include(GNUInstallDirs)
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.
# 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,
// UI/status helpers
UArgStatus, // update status line during universal-argument collection
// Themes (GUI)
ThemeNext,
ThemePrev,
// Region formatting
IndentRegion, // indent 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)
// Meta
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 help; // short help/description
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.) ---
enum class PromptKind {
None = 0,
Search,
RegexSearch,
RegexReplaceFind, // step 1 of Regex Search & Replace: find pattern
RegexReplaceWith, // step 2 of Regex Search & Replace: replacement text
OpenFile,
SaveAs,
Confirm,
BufferSwitch,
GotoLine,
Chdir,
ReplaceFind, // step 1 of Search & Replace: find what
ReplaceWith // step 2 of Search & Replace: replace with
};
// --- Generic Prompt subsystem (for search, open-file, save-as, etc.) ---
enum class PromptKind {
None = 0,
Search,
RegexSearch,
RegexReplaceFind, // step 1 of Regex Search & Replace: find pattern
RegexReplaceWith, // step 2 of Regex Search & Replace: replacement text
OpenFile,
SaveAs,
Confirm,
BufferSwitch,
GotoLine,
Chdir,
ReplaceFind, // step 1 of Search & Replace: find what
ReplaceWith, // step 2 of Search & Replace: replace with
Command // generic command prompt (": ")
};
void StartPrompt(PromptKind kind, const std::string &label, const std::string &initial)
@@ -518,20 +519,38 @@ private:
std::string prompt_text_;
std::string pending_overwrite_path_;
// GUI-only state (safe no-op in terminal builds)
bool file_picker_visible_ = false;
std::string file_picker_dir_;
// GUI-only state (safe no-op in terminal builds)
bool file_picker_visible_ = false;
std::string file_picker_dir_;
// Temporary state for Search & Replace flow
// Temporary state for Search & Replace flow
public:
void SetReplaceFindTmp(const std::string &s) { replace_find_tmp_ = s; }
void SetReplaceWithTmp(const std::string &s) { replace_with_tmp_ = s; }
[[nodiscard]] const std::string &ReplaceFindTmp() const { return replace_find_tmp_; }
[[nodiscard]] const std::string &ReplaceWithTmp() const { return replace_with_tmp_; }
void SetReplaceFindTmp(const std::string &s)
{
replace_find_tmp_ = s;
}
void SetReplaceWithTmp(const std::string &s)
{
replace_with_tmp_ = s;
}
[[nodiscard]] const std::string &ReplaceFindTmp() const
{
return replace_find_tmp_;
}
[[nodiscard]] const std::string &ReplaceWithTmp() const
{
return replace_with_tmp_;
}
private:
std::string replace_find_tmp_;
std::string replace_with_tmp_;
std::string replace_find_tmp_;
std::string replace_with_tmp_;
};
#endif // KTE_EDITOR_H

View File

@@ -102,6 +102,15 @@ GUIConfig::LoadFromFile(const std::string &path)
if (v > 0.0f) {
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 {
public:
bool fullscreen = false;
int columns = 80;
int rows = 42;
float font_size = (float) KTE_FONT_SIZE;
bool fullscreen = false;
int columns = 80;
int rows = 42;
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
static GUIConfig Load();

View File

@@ -32,8 +32,8 @@ GUIFrontend::Init(Editor &ed)
return false;
}
// Load GUI configuration (fullscreen, columns/rows, font size)
const auto [fullscreen, columns, rows, font_size] = GUIConfig::Load();
// Load GUI configuration (fullscreen, columns/rows, font size, theme, background)
GUIConfig cfg = GUIConfig::Load();
// GL attributes for core profile
SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, 0);
@@ -47,7 +47,7 @@ GUIFrontend::Init(Editor &ed)
// Compute desired window size from config
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.
// On macOS, do NOT use true fullscreen so the menu/status bar remains visible.
SDL_Rect usable{};
@@ -61,8 +61,8 @@ GUIFrontend::Init(Editor &ed)
#endif
} else {
// Windowed: width = columns * font_size, height = (rows * 2) * font_size
int w = static_cast<int>(columns * font_size);
int h = static_cast<int>((rows * 2) * font_size);
int w = cfg.columns * static_cast<int>(cfg.font_size);
int h = cfg.rows * static_cast<int>(cfg.font_size * 1.2);
// As a safety, clamp to display usable bounds if retrievable
SDL_Rect usable{};
@@ -86,7 +86,7 @@ GUIFrontend::Init(Editor &ed)
// macOS: when "fullscreen" is requested, position the window at the
// top-left of the usable display area to mimic fullscreen while keeping
// the system menu bar visible.
if (fullscreen) {
if (cfg.fullscreen) {
SDL_Rect usable{};
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
SDL_SetWindowPosition(window_, usable.x, usable.y);
@@ -105,8 +105,13 @@ GUIFrontend::Init(Editor &ed)
ImGuiIO &io = ImGui::GetIO();
(void) io;
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_))
return false;
@@ -135,7 +140,7 @@ GUIFrontend::Init(Editor &ed)
#endif
// 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;
}
@@ -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);
// 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.
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)));
@@ -264,11 +269,11 @@ GUIFrontend::Shutdown()
bool
GUIFrontend::LoadGuiFont_(const char * /*path*/, float size_px)
{
ImGuiIO &io = ImGui::GetIO();
const ImGuiIO &io = ImGui::GetIO();
io.Fonts->Clear();
ImFont *font = io.Fonts->AddFontFromMemoryCompressedTTF(
(void *) DefaultFontBoldCompressedData,
(int) DefaultFontBoldCompressedSize,
const ImFont *font = io.Fonts->AddFontFromMemoryCompressedTTF(
DefaultFontBoldCompressedData,
DefaultFontBoldCompressedSize,
size_px);
if (!font) {
font = io.Fonts->AddFontDefault();

View File

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

View File

@@ -92,10 +92,14 @@ map_key(const SDL_Keycode key,
out = {true, CommandId::Backspace, "", 0};
return true;
case SDLK_TAB:
// Do not insert text on KEYDOWN; allow SDL_TEXTINPUT to deliver '\t'
// as printable input so that all printable characters flow via TEXTINPUT.
out.hasCommand = false;
return true;
// Insert a literal tab character when not interpreting a k-prefix suffix.
// If k-prefix is active, let the k-prefix handler below consume the key
// (so Tab doesn't leave k-prefix stuck).
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_KP_ENTER:
out = {true, CommandId::Newline, "", 0};
@@ -347,6 +351,12 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
uarg_text_,
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,
// suppress the subsequent SDL_TEXTINPUT for this keystroke to avoid duplicating the digit in status.
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
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
ImVec2 mp = ImGui::GetIO().MousePos;
// 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;
long vy = static_cast<long>(vy_f);
if (vy < 0)
vy = 0;
// Handle mouse click before rendering to avoid dependent on drawn items
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
ImVec2 mp = ImGui::GetIO().MousePos;
// 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;
long vy = static_cast<long>(vy_f);
if (vy < 0)
vy = 0;
// Clamp vy within visible content height to avoid huge jumps
ImVec2 cr_min = ImGui::GetWindowContentRegionMin();
@@ -171,163 +171,169 @@ GUIRenderer::Draw(Editor &ed)
if (vy >= vis_rows)
vy = vis_rows - 1;
// Translate viewport row to buffer row using Buffer::Rowoffs
std::size_t by = buf->Rowoffs() + static_cast<std::size_t>(vy);
if (by >= lines.size()) {
if (!lines.empty())
by = lines.size() - 1;
else
by = 0;
}
// Translate viewport row to buffer row using Buffer::Rowoffs
std::size_t by = buf->Rowoffs() + static_cast<std::size_t>(vy);
if (by >= lines.size()) {
if (!lines.empty())
by = lines.size() - 1;
else
by = 0;
}
// Compute desired pixel X inside the viewport content (subtract horizontal scroll)
float px = (mp.x - list_origin.x - scroll_x);
if (px < 0.0f)
px = 0.0f;
// Compute desired pixel X inside the viewport content (subtract horizontal scroll)
float px = (mp.x - list_origin.x - scroll_x);
if (px < 0.0f)
px = 0.0f;
// Empty buffer guard: if there are no lines yet, just move to 0:0
if (lines.empty()) {
Execute(ed, CommandId::MoveCursorTo, std::string("0:0"));
} else {
// 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.
std::string line_clicked = static_cast<std::string>(lines[by]);
const std::size_t tabw = 8;
// We iterate source columns computing absolute rendered column (rx_abs) from 0,
// then translate to viewport-space by subtracting Coloffs.
std::size_t coloffs = buf->Coloffs();
std::size_t rx_abs = 0; // absolute rendered column
std::size_t i = 0; // source column iterator
// Empty buffer guard: if there are no lines yet, just move to 0:0
if (lines.empty()) {
Execute(ed, CommandId::MoveCursorTo, std::string("0:0"));
} else {
// 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.
std::string line_clicked = static_cast<std::string>(lines[by]);
const std::size_t tabw = 8;
// We iterate source columns computing absolute rendered column (rx_abs) from 0,
// then translate to viewport-space by subtracting Coloffs.
std::size_t coloffs = buf->Coloffs();
std::size_t rx_abs = 0; // absolute rendered column
std::size_t i = 0; // source column iterator
// Fast-forward i until rx_abs >= coloffs to align with leftmost visible column
if (!line_clicked.empty() && coloffs > 0) {
while (i < line_clicked.size() && rx_abs < coloffs) {
if (line_clicked[i] == '\t') {
rx_abs += (tabw - (rx_abs % tabw));
} else {
rx_abs += 1;
}
++i;
}
}
// Fast-forward i until rx_abs >= coloffs to align with leftmost visible column
if (!line_clicked.empty() && coloffs > 0) {
while (i < line_clicked.size() && rx_abs < coloffs) {
if (line_clicked[i] == '\t') {
rx_abs += (tabw - (rx_abs % tabw));
} else {
rx_abs += 1;
}
++i;
}
}
// Now search for closest source column to clicked px within/after viewport
std::size_t best_col = i; // default to first visible column
float best_dist = std::numeric_limits<float>::infinity();
while (true) {
// For i in [current..size], evaluate candidate including the implicit end position
std::size_t rx_view = (rx_abs >= coloffs) ? (rx_abs - coloffs) : 0;
float rx_px = static_cast<float>(rx_view) * space_w;
float dist = std::fabs(px - rx_px);
if (dist <= best_dist) {
best_dist = dist;
best_col = i;
}
if (i == line_clicked.size())
break;
// advance to next source column
if (line_clicked[i] == '\t') {
rx_abs += (tabw - (rx_abs % tabw));
} else {
rx_abs += 1;
}
++i;
}
// Now search for closest source column to clicked px within/after viewport
std::size_t best_col = i; // default to first visible column
float best_dist = std::numeric_limits<float>::infinity();
while (true) {
// For i in [current..size], evaluate candidate including the implicit end position
std::size_t rx_view = (rx_abs >= coloffs) ? (rx_abs - coloffs) : 0;
float rx_px = static_cast<float>(rx_view) * space_w;
float dist = std::fabs(px - rx_px);
if (dist <= best_dist) {
best_dist = dist;
best_col = i;
}
if (i == line_clicked.size())
break;
// advance to next source column
if (line_clicked[i] == '\t') {
rx_abs += (tabw - (rx_abs % tabw));
} else {
rx_abs += 1;
}
++i;
}
// Dispatch absolute buffer coordinates (row:col)
char tmp[64];
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, best_col);
Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
}
}
// Dispatch absolute buffer coordinates (row:col)
char tmp[64];
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, best_col);
Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
}
}
// Cache current horizontal offset in rendered columns
const std::size_t coloffs_now = buf->Coloffs();
for (std::size_t i = rowoffs; i < lines.size(); ++i) {
// Capture the screen position before drawing the line
ImVec2 line_pos = ImGui::GetCursorScreenPos();
std::string line = static_cast<std::string>(lines[i]);
for (std::size_t i = rowoffs; i < lines.size(); ++i) {
// Capture the screen position before drawing the line
ImVec2 line_pos = ImGui::GetCursorScreenPos();
std::string line = static_cast<std::string>(lines[i]);
// Expand tabs to spaces with width=8 and apply horizontal scroll offset
const std::size_t tabw = 8;
std::string expanded;
expanded.reserve(line.size() + 16);
std::size_t rx_abs_draw = 0; // rendered column for drawing
// Compute search highlight ranges for this line in source indices
bool search_mode = ed.SearchActive() && !ed.SearchQuery().empty();
std::vector<std::pair<std::size_t, std::size_t>> hl_src_ranges;
if (search_mode) {
// 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)) {
try {
std::regex rx(ed.SearchQuery());
for (auto it = std::sregex_iterator(line.begin(), line.end(), rx);
it != std::sregex_iterator(); ++it) {
const auto &m = *it;
std::size_t sx = static_cast<std::size_t>(m.position());
std::size_t ex = sx + static_cast<std::size_t>(m.length());
hl_src_ranges.emplace_back(sx, ex);
}
} catch (const std::regex_error &) {
// ignore invalid patterns here; status line already shows the error
}
} else {
const std::string &q = ed.SearchQuery();
std::size_t pos = 0;
while (!q.empty() && (pos = line.find(q, pos)) != std::string::npos) {
hl_src_ranges.emplace_back(pos, pos + q.size());
pos += q.size();
}
}
}
auto src_to_rx = [&](std::size_t upto_src_exclusive) -> std::size_t {
std::size_t rx = 0;
std::size_t s = 0;
while (s < upto_src_exclusive && s < line.size()) {
if (line[s] == '\t')
rx += (tabw - (rx % tabw));
else
rx += 1;
++s;
}
return rx;
};
// Draw background highlights (under text)
if (search_mode && !hl_src_ranges.empty()) {
// Current match emphasis
bool has_current = ed.SearchMatchLen() > 0 && ed.SearchMatchY() == i;
std::size_t cur_x = has_current ? ed.SearchMatchX() : 0;
std::size_t cur_end = has_current ? (ed.SearchMatchX() + ed.SearchMatchLen()) : 0;
for (const auto &rg : hl_src_ranges) {
std::size_t sx = rg.first, ex = rg.second;
std::size_t rx_start = src_to_rx(sx);
std::size_t rx_end = src_to_rx(ex);
// Apply horizontal scroll offset
if (rx_end <= coloffs_now) continue; // fully left of view
std::size_t vx0 = (rx_start > coloffs_now) ? (rx_start - coloffs_now) : 0;
std::size_t vx1 = rx_end - coloffs_now;
ImVec2 p0 = ImVec2(line_pos.x + static_cast<float>(vx0) * space_w, line_pos.y);
ImVec2 p1 = ImVec2(line_pos.x + static_cast<float>(vx1) * space_w, line_pos.y + line_h);
// Choose color: current match stronger
bool is_current = has_current && sx == cur_x && ex == cur_end;
ImU32 col = is_current ? IM_COL32(255, 220, 120, 140) : IM_COL32(200, 200, 0, 90);
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
}
}
// Emit entire line (ImGui child scrolling will handle clipping)
for (std::size_t src = 0; src < line.size(); ++src) {
char c = line[src];
if (c == '\t') {
std::size_t adv = (tabw - (rx_abs_draw % tabw));
// Emit spaces for the tab
expanded.append(adv, ' ');
rx_abs_draw += adv;
} else {
expanded.push_back(c);
rx_abs_draw += 1;
}
}
// Expand tabs to spaces with width=8 and apply horizontal scroll offset
const std::size_t tabw = 8;
std::string expanded;
expanded.reserve(line.size() + 16);
std::size_t rx_abs_draw = 0; // rendered column for drawing
// Compute search highlight ranges for this line in source indices
bool search_mode = ed.SearchActive() && !ed.SearchQuery().empty();
std::vector<std::pair<std::size_t, std::size_t> > hl_src_ranges;
if (search_mode) {
// 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)) {
try {
std::regex rx(ed.SearchQuery());
for (auto it = std::sregex_iterator(line.begin(), line.end(), rx);
it != std::sregex_iterator(); ++it) {
const auto &m = *it;
std::size_t sx = static_cast<std::size_t>(m.position());
std::size_t ex = sx + static_cast<std::size_t>(m.length());
hl_src_ranges.emplace_back(sx, ex);
}
} catch (const std::regex_error &) {
// ignore invalid patterns here; status line already shows the error
}
} else {
const std::string &q = ed.SearchQuery();
std::size_t pos = 0;
while (!q.empty() && (pos = line.find(q, pos)) != std::string::npos) {
hl_src_ranges.emplace_back(pos, pos + q.size());
pos += q.size();
}
}
}
auto src_to_rx = [&](std::size_t upto_src_exclusive) -> std::size_t {
std::size_t rx = 0;
std::size_t s = 0;
while (s < upto_src_exclusive && s < line.size()) {
if (line[s] == '\t')
rx += (tabw - (rx % tabw));
else
rx += 1;
++s;
}
return rx;
};
// Draw background highlights (under text)
if (search_mode && !hl_src_ranges.empty()) {
// Current match emphasis
bool has_current = ed.SearchMatchLen() > 0 && ed.SearchMatchY() == i;
std::size_t cur_x = has_current ? ed.SearchMatchX() : 0;
std::size_t cur_end = has_current ? (ed.SearchMatchX() + ed.SearchMatchLen()) : 0;
for (const auto &rg: hl_src_ranges) {
std::size_t sx = rg.first, ex = rg.second;
std::size_t rx_start = src_to_rx(sx);
std::size_t rx_end = src_to_rx(ex);
// Apply horizontal scroll offset
if (rx_end <= coloffs_now)
continue; // fully left of view
std::size_t vx0 = (rx_start > coloffs_now) ? (rx_start - coloffs_now) : 0;
std::size_t vx1 = rx_end - coloffs_now;
ImVec2 p0 = ImVec2(line_pos.x + static_cast<float>(vx0) * space_w, line_pos.y);
ImVec2 p1 = ImVec2(line_pos.x + static_cast<float>(vx1) * space_w,
line_pos.y + line_h);
// Choose color: current match stronger
bool is_current = has_current && sx == cur_x && ex == cur_end;
ImU32 col = is_current
? IM_COL32(255, 220, 120, 140)
: IM_COL32(200, 200, 0, 90);
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
}
}
// Emit entire line (ImGui child scrolling will handle clipping)
for (std::size_t src = 0; src < line.size(); ++src) {
char c = line[src];
if (c == '\t') {
std::size_t adv = (tabw - (rx_abs_draw % tabw));
// Emit spaces for the tab
expanded.append(adv, ' ');
rx_abs_draw += adv;
} else {
expanded.push_back(c);
rx_abs_draw += 1;
}
}
ImGui::TextUnformatted(expanded.c_str());
ImGui::TextUnformatted(expanded.c_str());
// Draw a visible cursor indicator on the current line
if (i == cy) {
@@ -349,207 +355,220 @@ GUIRenderer::Draw(Editor &ed)
}
ImGui::EndChild();
// Status bar spanning full width
ImGui::Separator();
// Status bar spanning full width
ImGui::Separator();
// Compute full content width and draw a filled background rectangle
ImVec2 win_pos = ImGui::GetWindowPos();
ImVec2 cr_min = ImGui::GetWindowContentRegionMin();
ImVec2 cr_max = ImGui::GetWindowContentRegionMax();
float x0 = win_pos.x + cr_min.x;
float x1 = win_pos.x + cr_max.x;
ImVec2 cursor = ImGui::GetCursorScreenPos();
float bar_h = ImGui::GetFrameHeight();
ImVec2 p0(x0, cursor.y);
ImVec2 p1(x1, cursor.y + bar_h);
ImU32 bg_col = ImGui::GetColorU32(ImGuiCol_HeaderActive);
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, bg_col);
// 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;
// Compute full content width and draw a filled background rectangle
ImVec2 win_pos = ImGui::GetWindowPos();
ImVec2 cr_min = ImGui::GetWindowContentRegionMin();
ImVec2 cr_max = ImGui::GetWindowContentRegionMax();
float x0 = win_pos.x + cr_min.x;
float x1 = win_pos.x + cr_max.x;
ImVec2 cursor = ImGui::GetCursorScreenPos();
float bar_h = ImGui::GetFrameHeight();
ImVec2 p0(x0, cursor.y);
ImVec2 p1(x1, cursor.y + bar_h);
ImU32 bg_col = ImGui::GetColorU32(ImGuiCol_HeaderActive);
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, bg_col);
// If a prompt is active, replace the entire status bar with the prompt text
if (ed.PromptActive()) {
msg = ed.PromptLabel();
if (!msg.empty())
msg += ": ";
msg += ed.PromptText();
} else {
msg = ed.Status();
}
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;
}
}
}
// 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
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 (kind == Editor::PromptKind::Command) {
prefix = ": ";
} else 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()) {
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::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::TextUnformatted(left.c_str());
}
// Draw right
ImGui::SetCursorScreenPos(ImVec2(std::max(right_x, left_x), p0.y + (bar_h - right_sz.y) * 0.5f));
ImGui::TextUnformatted(right.c_str());
// Draw right
ImGui::SetCursorScreenPos(ImVec2(std::max(right_x, left_x),
p0.y + (bar_h - right_sz.y) * 0.5f));
ImGui::TextUnformatted(right.c_str());
// Draw middle message centered in remaining space
if (!msg.empty()) {
float mid_left = left_x + left_sz.x + pad;
float mid_right = std::max(right_x - pad, mid_left);
float mid_w = std::max(0.0f, mid_right - mid_left);
if (mid_w > 1.0f) {
ImVec2 msg_sz = ImGui::CalcTextSize(msg.c_str());
float msg_x = mid_left + std::max(0.0f, (mid_w - msg_sz.x) * 0.5f);
// Clip to middle region
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::TextUnformatted(msg.c_str());
ImGui::PopClipRect();
// Draw middle message centered in remaining space
if (!msg.empty()) {
float mid_left = left_x + left_sz.x + pad;
float mid_right = std::max(right_x - pad, mid_left);
float mid_w = std::max(0.0f, mid_right - mid_left);
if (mid_w > 1.0f) {
ImVec2 msg_sz = ImGui::CalcTextSize(msg.c_str());
float msg_x = mid_left + std::max(0.0f, (mid_w - msg_sz.x) * 0.5f);
// Clip to middle region
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::TextUnformatted(msg.c_str());
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();

1021
GUITheme.h

File diff suppressed because it is too large Load Diff

View File

@@ -15,24 +15,26 @@ HelpText::Text()
return std::string(
"KTE - Kyle's Text Editor\n\n"
"About:\n"
" kte is Kyle's Text Editor and is probably ill-suited to everyone else. It was\n"
" inspired by Antirez' kilo text editor by way of someone's writeup of the\n"
" process of writing a text editor from scratch. It has keybindings inspired by\n"
" VDE (and the Wordstar family) and emacs; its spiritual parent is mg(1).\n"
" kte is Kyle's Text Editor. It keeps a small, fast core and uses a\n"
" WordStar/VDE-style command model with some emacs influences.\n"
"\n"
"Core keybindings:\n"
"K-commands (prefix C-k):\n"
" C-k ' Toggle read-only\n"
" C-k - Unindent region\n"
" C-k = Indent region\n"
" C-k - Unindent region (mark required)\n"
" C-k = Indent region (mark required)\n"
" C-k ; Command prompt (:\\ )\n"
" C-k C-d Kill entire line\n"
" C-k C-q Quit now (no confirm)\n"
" C-k a Mark all and jump to end\n"
" C-k 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 c Close current buffer\n"
" C-k d Kill to end of line\n"
" C-k e Open file (prompt)\n"
" C-k f Flush kill ring\n"
" C-k g Jump to line\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 n Previous buffer\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 w Show working directory\n"
" C-k x Save and quit\n"
" C-k y Yank\n"
"\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 BACKSPACE Delete previous word\n"
" ESC d Delete next word\n"
" Alt-w Copy region to kill ring\n\n"
"Buffers:\n +HELP+ is read-only. Press C-k ' to toggle if you need to edit; C-k h restores it.\n"
"\n"
"Control keys:\n"
" C-a C-e Line start / end\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 {
public:
// Returns the embedded help text as a single string with newlines.
// Project maintainers can customize the returned string below
// (in HelpText.cc) without touching the help command logic.
static std::string Text();
// Returns the embedded help text as a single string with newlines.
// Project maintainers can customize the returned string below
// (in HelpText.cc) without touching the help command logic.
static std::string Text();
};
#endif // KTE_HELPTEXT_H

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
.\" kge(1) — Kyle's Graphical Editor (GUI-first)
.\"
.\" 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
kge \- Kyle's Graphical Editor (GUI-first)
.SH SYNOPSIS
@@ -52,11 +52,8 @@ tree for the canonical reference and notes:
.PP
Enter K-command mode with Ctrl-K. Exit K-command mode with ESC or Ctrl-G.
.TP
.B C-k BACKSPACE
Delete from the cursor to the beginning of the line.
.TP
.B C-k SPACE
Toggle the mark.
.B C-k '
Toggle read-only for the current buffer.
.TP
.B C-k -
If the mark is set, unindent the region.
@@ -64,6 +61,9 @@ If the mark is set, unindent the region.
.B C-k =
If the mark is set, indent the region.
.TP
.B C-k ;
Open the generic command prompt (": ").
.TP
.B C-k a
Set the mark at the beginning of the file, then jump to the end of the file.
.TP
@@ -80,7 +80,7 @@ Delete from the cursor to the end of the line.
Delete the entire line.
.TP
.B C-k e
Edit a new file.
Edit (open) a new file.
.TP
.B C-k f
Flush the kill ring.
@@ -88,14 +88,20 @@ Flush the kill ring.
.B C-k g
Go to a specific line.
.TP
.B C-k h
Show the built-in help (+HELP+ buffer).
.TP
.B C-k j
Jump to the mark.
.TP
.B C-k l
Reload the current buffer from disk.
.TP
.B C-k m
Run make(1), reporting success or failure.
.B C-k n
Switch to the previous buffer.
.TP
.B C-k o
Change working directory (prompt).
.TP
.B C-k p
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
Immediately exit the editor.
.TP
.B C-k r
Redo changes.
.TP
.B C-k s
Save the file, prompting for a filename if needed.
.TP
.B C-k u
Undo.
.TP
.B C-k r
Redo changes.
.B C-k v
Toggle visual file picker (GUI).
.TP
.B C-k w
Show the current working directory.
.TP
.B C-k 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
Yank the kill ring.
.TP
.B C-k \e
Dump core.
.B C-k C-x
Save the file and exit.
.SS Other keybindings
.TP
.B C-g
Cancel the current operation.
.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
Refresh the display.
.TP
.B C-d
Delete the character at the cursor.
.TP
.B C-r
Regex search.
.TP
.B C-s
Incremental find.
.TP
.B C-t
Regex search and replace.
.TP
.B C-h
Search and replace.
.TP
.B C-u
Universal argument. C-u followed by numbers will repeat an operation n times.
.TP
@@ -147,6 +186,15 @@ Kill the region if the mark is set.
.B C-y
Yank the kill ring.
.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
Delete the previous word.
.TP

View File

@@ -1,7 +1,7 @@
.\" kte(1) — Kyle's Text Editor (terminal-first)
.\"
.\" 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
kte \- Kyle's Text Editor (terminal-first)
.SH SYNOPSIS
@@ -57,11 +57,8 @@ in the source tree for the canonical reference and notes.
.PP
Enter K-command mode with Ctrl-K. Exit K-command mode with ESC or Ctrl-G.
.TP
.B C-k BACKSPACE
Delete from the cursor to the beginning of the line.
.TP
.B C-k SPACE
Toggle the mark.
.B C-k '
Toggle read-only for the current buffer.
.TP
.B C-k -
If the mark is set, unindent the region.
@@ -69,6 +66,9 @@ If the mark is set, unindent the region.
.B C-k =
If the mark is set, indent the region.
.TP
.B C-k ;
Open the generic command prompt (": ").
.TP
.B C-k a
Set the mark at the beginning of the file, then jump to the end of the file.
.TP
@@ -85,7 +85,7 @@ Delete from the cursor to the end of the line.
Delete the entire line.
.TP
.B C-k e
Edit a new file.
Edit (open) a new file.
.TP
.B C-k f
Flush the kill ring.
@@ -93,14 +93,20 @@ Flush the kill ring.
.B C-k g
Go to a specific line.
.TP
.B C-k h
Show the built-in help (+HELP+ buffer).
.TP
.B C-k j
Jump to the mark.
.TP
.B C-k l
Reload the current buffer from disk.
.TP
.B C-k m
Run make(1), reporting success or failure.
.B C-k n
Switch to the previous buffer.
.TP
.B C-k o
Change working directory (prompt).
.TP
.B C-k p
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
Immediately exit the editor.
.TP
.B C-k r
Redo changes.
.TP
.B C-k s
Save the file, prompting for a filename if needed.
.TP
.B C-k u
Undo.
.TP
.B C-k r
Redo changes.
.B C-k v
Toggle visual file picker (GUI).
.TP
.B C-k w
Show the current working directory.
.TP
.B C-k 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
Yank the kill ring.
.TP
.B C-k \e
Dump core.
.B C-k C-x
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
.TP
.B C-g
Cancel the current operation.
.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
Refresh the display.
.TP
.B C-d
Delete the character at the cursor.
.TP
.B C-r
Regex search.
.TP
.B C-s
Incremental find.
.TP
.B C-t
Regex search and replace.
.TP
.B C-h
Search and replace.
.TP
.B C-u
Universal argument. C-u followed by numbers will repeat an operation n times.
.TP
@@ -152,6 +217,15 @@ Kill the region if the mark is set.
.B C-y
Yank the kill ring.
.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
Delete the previous word.
.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
buf->SetCursor(0, 0);
std::cout << "test_undo: Testing undo/redo system\n";
std::cout << "====================================\n\n";
std::cout << "test_undo: Testing undo/redo system\n";
std::cout << "====================================\n\n";
bool running = true;
// Test 1: Insert text and verify buffer contains expected text
std::cout << "Test 1: Insert text 'Hello'\n";
frontend.Input().QueueText("Hello");
// Test 1: Insert text and verify buffer contains expected text
std::cout << "Test 1: Insert text 'Hello'\n";
frontend.Input().QueueText("Hello");
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
@@ -66,9 +66,9 @@ main()
std::cout << " Buffer content: '" << line_after_insert << "'\n";
std::cout << " ✓ Text insertion verified\n\n";
// Test 2: Undo insertion - text should be removed
std::cout << "Test 2: Undo insertion\n";
frontend.Input().QueueCommand(CommandId::Undo);
// Test 2: Undo insertion - text should be removed
std::cout << "Test 2: Undo insertion\n";
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
@@ -80,9 +80,9 @@ main()
std::cout << " Buffer content: '" << line_after_undo << "'\n";
std::cout << " ✓ Undo successful - text removed\n\n";
// Test 3: Redo insertion - text should be restored
std::cout << "Test 3: Redo insertion\n";
frontend.Input().QueueCommand(CommandId::Redo);
// Test 3: Redo insertion - text should be restored
std::cout << "Test 3: Redo insertion\n";
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
@@ -94,242 +94,242 @@ main()
std::cout << " Buffer content: '" << line_after_redo << "'\n";
std::cout << " ✓ Redo successful - text restored\n\n";
// Test 4: Branching behavior redo is discarded after new edits
std::cout << "Test 4: Branching behavior (redo discarded after new edits)\n";
// Reset to empty by undoing the last redo and the original insert, then reinsert 'abc'
// Ensure buffer is empty before starting this scenario
frontend.Input().QueueCommand(CommandId::Undo); // undo Hello
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "");
// Test 4: Branching behavior redo is discarded after new edits
std::cout << "Test 4: Branching behavior (redo discarded after new edits)\n";
// Reset to empty by undoing the last redo and the original insert, then reinsert 'abc'
// Ensure buffer is empty before starting this scenario
frontend.Input().QueueCommand(CommandId::Undo); // undo Hello
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "");
// Type a contiguous word 'abc' (single batch)
frontend.Input().QueueText("abc");
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "abc");
// Type a contiguous word 'abc' (single batch)
frontend.Input().QueueText("abc");
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "abc");
// Undo once should remove the whole batch and leave empty
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "");
// Undo once should remove the whole batch and leave empty
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "");
// Now type new text 'X' this should create a new branch and discard old redo chain
frontend.Input().QueueText("X");
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "X");
// Now type new text 'X' this should create a new branch and discard old redo chain
frontend.Input().QueueText("X");
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "X");
// Attempt Redo should be a no-op (redo branch was discarded by new edit)
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "X");
// Undo and Redo along the new branch should still work
frontend.Input().QueueCommand(CommandId::Undo);
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "X");
std::cout << " ✓ Redo discarded after new edit; new branch undo/redo works\n\n";
// Attempt Redo should be a no-op (redo branch was discarded by new edit)
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "X");
// Undo and Redo along the new branch should still work
frontend.Input().QueueCommand(CommandId::Undo);
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "X");
std::cout << " ✓ Redo discarded after new edit; new branch undo/redo works\n\n";
// Clear buffer state for next tests: undo to empty if needed
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "");
// Clear buffer state for next tests: undo to empty if needed
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "");
// Test 5: UTF-8 insertion and undo/redo round-trip
std::cout << "Test 5: UTF-8 insertion 'é漢' and undo/redo\n";
const std::string utf8_text = "é漢"; // multi-byte UTF-8 (2 bytes + 3 bytes)
frontend.Input().QueueText(utf8_text);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == utf8_text);
// Undo should remove the entire contiguous insertion batch
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "");
// Redo restores it
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == utf8_text);
std::cout << " ✓ UTF-8 insert round-trips with undo/redo\n\n";
// Test 5: UTF-8 insertion and undo/redo round-trip
std::cout << "Test 5: UTF-8 insertion 'é漢' and undo/redo\n";
const std::string utf8_text = "é漢"; // multi-byte UTF-8 (2 bytes + 3 bytes)
frontend.Input().QueueText(utf8_text);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == utf8_text);
// Undo should remove the entire contiguous insertion batch
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "");
// Redo restores it
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == utf8_text);
std::cout << " ✓ UTF-8 insert round-trips with undo/redo\n\n";
// Clear for next test
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "");
// Clear for next test
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "");
// Test 6: Multi-line operations (newline split and join via backspace at BOL)
std::cout << "Test 6: Newline split and join via backspace at BOL\n";
// Insert "ab" then newline then "cd" → expect two lines
frontend.Input().QueueText("ab");
frontend.Input().QueueCommand(CommandId::Newline);
frontend.Input().QueueText("cd");
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(buf->Rows().size() >= 2);
assert(std::string(buf->Rows()[0]) == "ab");
assert(std::string(buf->Rows()[1]) == "cd");
std::cout << " ✓ Split into two lines\n";
// Test 6: Multi-line operations (newline split and join via backspace at BOL)
std::cout << "Test 6: Newline split and join via backspace at BOL\n";
// Insert "ab" then newline then "cd" → expect two lines
frontend.Input().QueueText("ab");
frontend.Input().QueueCommand(CommandId::Newline);
frontend.Input().QueueText("cd");
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(buf->Rows().size() >= 2);
assert(std::string(buf->Rows()[0]) == "ab");
assert(std::string(buf->Rows()[1]) == "cd");
std::cout << " ✓ Split into two lines\n";
// Undo once should remove "cd" insertion leaving two lines ["ab", ""] or join depending on commit
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
// Current design batches typing on the second line; after undo, the second line should exist but be empty
assert(buf->Rows().size() >= 2);
assert(std::string(buf->Rows()[0]) == "ab");
assert(std::string(buf->Rows()[1]) == "");
// Undo once should remove "cd" insertion leaving two lines ["ab", ""] or join depending on commit
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
// Current design batches typing on the second line; after undo, the second line should exist but be empty
assert(buf->Rows().size() >= 2);
assert(std::string(buf->Rows()[0]) == "ab");
assert(std::string(buf->Rows()[1]) == "");
// Undo the newline should rejoin to a single line "ab"
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(buf->Rows().size() >= 1);
assert(std::string(buf->Rows()[0]) == "ab");
// Undo the newline should rejoin to a single line "ab"
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(buf->Rows().size() >= 1);
assert(std::string(buf->Rows()[0]) == "ab");
// Redo twice to get back to ["ab","cd"]
frontend.Input().QueueCommand(CommandId::Redo);
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "ab");
assert(std::string(buf->Rows()[1]) == "cd");
std::cout << " ✓ Newline undo/redo round-trip\n";
// Redo twice to get back to ["ab","cd"]
frontend.Input().QueueCommand(CommandId::Redo);
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "ab");
assert(std::string(buf->Rows()[1]) == "cd");
std::cout << " ✓ Newline undo/redo round-trip\n";
// Now join via Backspace at beginning of second line
frontend.Input().QueueCommand(CommandId::MoveDown); // ensure we're on the second line
frontend.Input().QueueCommand(CommandId::MoveHome); // go to BOL on second line
frontend.Input().QueueCommand(CommandId::Backspace); // join with previous line
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(buf->Rows().size() >= 1);
assert(std::string(buf->Rows()[0]) == "abcd");
std::cout << " ✓ Backspace at BOL joins lines\n";
// Now join via Backspace at beginning of second line
frontend.Input().QueueCommand(CommandId::MoveDown); // ensure we're on the second line
frontend.Input().QueueCommand(CommandId::MoveHome); // go to BOL on second line
frontend.Input().QueueCommand(CommandId::Backspace); // join with previous line
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(buf->Rows().size() >= 1);
assert(std::string(buf->Rows()[0]) == "abcd");
std::cout << " ✓ Backspace at BOL joins lines\n";
// Undo/Redo the join
frontend.Input().QueueCommand(CommandId::Undo);
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(buf->Rows().size() >= 1);
assert(std::string(buf->Rows()[0]) == "abcd");
std::cout << " ✓ Join undo/redo round-trip\n\n";
// Undo/Redo the join
frontend.Input().QueueCommand(CommandId::Undo);
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(buf->Rows().size() >= 1);
assert(std::string(buf->Rows()[0]) == "abcd");
std::cout << " ✓ Join undo/redo round-trip\n\n";
// Test 7: Typing batching a contiguous word undone in one step
std::cout << "Test 7: Typing batching (single undo removes whole word)\n";
// Clear current line first
frontend.Input().QueueCommand(CommandId::MoveHome);
frontend.Input().QueueCommand(CommandId::KillToEOL);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]).empty());
// Type a word and verify one undo clears it
frontend.Input().QueueText("hello");
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "hello");
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]).empty());
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "hello");
std::cout << " ✓ Contiguous typing batched into single undo step\n\n";
// Test 7: Typing batching a contiguous word undone in one step
std::cout << "Test 7: Typing batching (single undo removes whole word)\n";
// Clear current line first
frontend.Input().QueueCommand(CommandId::MoveHome);
frontend.Input().QueueCommand(CommandId::KillToEOL);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]).empty());
// Type a word and verify one undo clears it
frontend.Input().QueueText("hello");
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "hello");
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]).empty());
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "hello");
std::cout << " ✓ Contiguous typing batched into single undo step\n\n";
// Test 8: Forward delete batching at a fixed anchor column
std::cout << "Test 8: Forward delete batching at fixed anchor (DeleteChar)\n";
// Prepare line content
frontend.Input().QueueCommand(CommandId::MoveHome);
frontend.Input().QueueCommand(CommandId::KillToEOL);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
frontend.Input().QueueText("abcdef");
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
// Ensure cursor at anchor column 0
frontend.Input().QueueCommand(CommandId::MoveHome);
// Delete three chars at cursor; should batch into one Delete node
frontend.Input().QueueCommand(CommandId::DeleteChar, "", 3);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "def");
// Single undo should restore the entire deleted run
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "abcdef");
// Redo should remove the same run again
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "def");
std::cout << " ✓ Forward delete batched and undo/redo round-trips\n\n";
// Test 8: Forward delete batching at a fixed anchor column
std::cout << "Test 8: Forward delete batching at fixed anchor (DeleteChar)\n";
// Prepare line content
frontend.Input().QueueCommand(CommandId::MoveHome);
frontend.Input().QueueCommand(CommandId::KillToEOL);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
frontend.Input().QueueText("abcdef");
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
// Ensure cursor at anchor column 0
frontend.Input().QueueCommand(CommandId::MoveHome);
// Delete three chars at cursor; should batch into one Delete node
frontend.Input().QueueCommand(CommandId::DeleteChar, "", 3);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "def");
// Single undo should restore the entire deleted run
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "abcdef");
// Redo should remove the same run again
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "def");
std::cout << " ✓ Forward delete batched and undo/redo round-trips\n\n";
// Test 9: Backspace batching with prepend rule (cursor moves left)
std::cout << "Test 9: Backspace batching with prepend rule\n";
// Restore to full string then backspace a run
frontend.Input().QueueCommand(CommandId::Undo); // bring back to "abcdef"
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "abcdef");
// Move to end and backspace three characters; should batch into one Delete node
frontend.Input().QueueCommand(CommandId::MoveEnd);
frontend.Input().QueueCommand(CommandId::Backspace, "", 3);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "abc");
// Single undo restores the deleted run
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "abcdef");
// Redo removes it again
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "abc");
std::cout << " ✓ Backspace run batched and undo/redo round-trips\n\n";
// Test 9: Backspace batching with prepend rule (cursor moves left)
std::cout << "Test 9: Backspace batching with prepend rule\n";
// Restore to full string then backspace a run
frontend.Input().QueueCommand(CommandId::Undo); // bring back to "abcdef"
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "abcdef");
// Move to end and backspace three characters; should batch into one Delete node
frontend.Input().QueueCommand(CommandId::MoveEnd);
frontend.Input().QueueCommand(CommandId::Backspace, "", 3);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "abc");
// Single undo restores the deleted run
frontend.Input().QueueCommand(CommandId::Undo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "abcdef");
// Redo removes it again
frontend.Input().QueueCommand(CommandId::Redo);
while (!frontend.Input().IsEmpty() && running) {
frontend.Step(editor, running);
}
assert(std::string(buf->Rows()[0]) == "abc");
std::cout << " ✓ Backspace run batched and undo/redo round-trips\n\n";
frontend.Shutdown();
frontend.Shutdown();
std::cout << "====================================\n";
std::cout << "All tests passed!\n";