diff --git a/.idea/workspace.xml b/.idea/workspace.xml
index cc895b2..011f3d3 100644
--- a/.idea/workspace.xml
+++ b/.idea/workspace.xml
@@ -7,6 +7,7 @@
+
@@ -34,7 +35,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -63,12 +86,13 @@
+
-
+
@@ -173,7 +197,7 @@
-
+
diff --git a/Buffer.cc b/Buffer.cc
index db05a18..b2a0899 100644
--- a/Buffer.cc
+++ b/Buffer.cc
@@ -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(y + 1), Line(tail));
+ std::string tail = rows_[y].substr(x);
+ rows_[y].erase(x);
+ rows_.insert(rows_.begin() + static_cast(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(row);
const auto x = std::min(static_cast(col), rows_[y].size());
const auto tail = rows_[y].substr(x);
- rows_[y].erase(x);
- rows_.insert(rows_.begin() + static_cast(y + 1), Line(tail));
+ rows_[y].erase(x);
+ rows_.insert(rows_.begin() + static_cast(y + 1), Line(tail));
}
@@ -459,7 +459,7 @@ Buffer::insert_row(int row, const std::string_view text)
row = 0;
if (static_cast(row) > rows_.size())
row = static_cast(rows_.size());
- rows_.insert(rows_.begin() + row, Line(std::string(text)));
+ rows_.insert(rows_.begin() + row, Line(std::string(text)));
}
diff --git a/Buffer.h b/Buffer.h
index e480f14..59e8525 100644
--- a/Buffer.h
+++ b/Buffer.h
@@ -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 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 undo_tree_;
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 46d72a0..05ae939 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -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.
diff --git a/Command.cc b/Command.cc
index c1de2b9..44a0ccb 100644
--- a/Command.cc
+++ b/Command.cc
@@ -4,12 +4,16 @@
#include
#include
#include
+#include
#include "Command.h"
#include "Editor.h"
#include "Buffer.h"
#include "UndoSystem.h"
#include "HelpText.h"
+#ifdef KTE_BUILD_GUI
+#include "GUITheme.h"
+#endif
// Keep buffer viewport offsets so that the cursor stays within the visible
@@ -64,9 +68,9 @@ ensure_cursor_visible(const Editor &ed, Buffer &buf)
// Horizontal scrolling (use rendered columns with tabs expanded)
std::size_t rx = 0;
const auto &lines = buf.Rows();
- if (cury < lines.size()) {
- rx = compute_render_x(static_cast(lines[cury]), curx, 8);
- }
+ if (cury < lines.size()) {
+ rx = compute_render_x(static_cast(lines[cury]), curx, 8);
+ }
if (rx < coloffs) {
coloffs = rx;
} else if (rx >= coloffs + cols) {
@@ -87,29 +91,31 @@ ensure_at_least_one_line(Buffer &buf)
}
}
+
// Determine if a command mutates the buffer contents (text edits)
-static bool is_mutating_command(CommandId id)
+static bool
+is_mutating_command(CommandId id)
{
- switch (id) {
- case CommandId::InsertText:
- case CommandId::Newline:
- case CommandId::Backspace:
- case CommandId::DeleteChar:
- case CommandId::KillToEOL:
- case CommandId::KillLine:
- case CommandId::Yank:
- case CommandId::DeleteWordPrev:
- case CommandId::DeleteWordNext:
- case CommandId::IndentRegion:
- case CommandId::UnindentRegion:
- case CommandId::ReflowParagraph:
- case CommandId::KillRegion:
- case CommandId::Undo:
- case CommandId::Redo:
- return true;
- default:
- return false;
- }
+ switch (id) {
+ case CommandId::InsertText:
+ case CommandId::Newline:
+ case CommandId::Backspace:
+ case CommandId::DeleteChar:
+ case CommandId::KillToEOL:
+ case CommandId::KillLine:
+ case CommandId::Yank:
+ case CommandId::DeleteWordPrev:
+ case CommandId::DeleteWordNext:
+ case CommandId::IndentRegion:
+ case CommandId::UnindentRegion:
+ case CommandId::ReflowParagraph:
+ case CommandId::KillRegion:
+ case CommandId::Undo:
+ case CommandId::Redo:
+ return true;
+ default:
+ return false;
+ }
}
@@ -154,32 +160,32 @@ compute_mark_region(Buffer &buf, std::size_t &sx, std::size_t &sy, std::size_t &
static std::string
extract_region_text(const Buffer &buf, std::size_t sx, std::size_t sy, std::size_t ex, std::size_t ey)
{
- const auto &rows = buf.Rows();
+ const auto &rows = buf.Rows();
if (sy >= rows.size())
return std::string();
if (ey >= rows.size())
ey = rows.size() - 1;
- if (sy == ey) {
- const auto &line = rows[sy];
- std::size_t xs = std::min(sx, line.size());
- std::size_t xe = std::min(ex, line.size());
- if (xe < xs)
- std::swap(xs, xe);
- return line.substr(xs, xe - xs);
- }
- std::string out;
- // first line tail
- {
- const auto &line = rows[sy];
- std::size_t xs = std::min(sx, line.size());
- out += line.substr(xs);
- out += '\n';
- }
- // middle lines full
- for (std::size_t y = sy + 1; y < ey; ++y) {
- out += static_cast(rows[y]);
- out += '\n';
- }
+ if (sy == ey) {
+ const auto &line = rows[sy];
+ std::size_t xs = std::min(sx, line.size());
+ std::size_t xe = std::min(ex, line.size());
+ if (xe < xs)
+ std::swap(xs, xe);
+ return line.substr(xs, xe - xs);
+ }
+ std::string out;
+ // first line tail
+ {
+ const auto &line = rows[sy];
+ std::size_t xs = std::min(sx, line.size());
+ out += line.substr(xs);
+ out += '\n';
+ }
+ // middle lines full
+ for (std::size_t y = sy + 1; y < ey; ++y) {
+ out += static_cast(rows[y]);
+ out += '\n';
+ }
// last line head
{
const auto &line = rows[ey];
@@ -267,7 +273,7 @@ insert_text_at_cursor(Buffer &buf, const std::string &text)
std::string after = rows[cur_y].substr(cur_x);
rows[cur_y].erase(cur_x);
// create new line after current with the 'after' tail
- rows.insert(rows.begin() + static_cast(cur_y + 1), Buffer::Line(after));
+ rows.insert(rows.begin() + static_cast(cur_y + 1), Buffer::Line(after));
// move to start of next line
cur_y += 1;
cur_x = 0;
@@ -356,11 +362,11 @@ cmd_move_cursor_to(CommandContext &ctx)
}
if (by >= lines2.size())
by = lines2.size() - 1;
- std::string line2 = static_cast(lines2[by]);
- std::size_t rx_target = bco + vx;
- std::size_t sx = inverse_render_to_source_col(line2, rx_target, 8);
- row = by;
- col = sx;
+ std::string line2 = static_cast(lines2[by]);
+ std::size_t rx_target = bco + vx;
+ std::size_t sx = inverse_render_to_source_col(line2, rx_target, 8);
+ row = by;
+ col = sx;
} else {
row = static_cast(ay);
col = static_cast(ax);
@@ -376,7 +382,7 @@ cmd_move_cursor_to(CommandContext &ctx)
}
if (row >= lines.size())
row = lines.size() - 1;
- std::string line = static_cast(lines[row]);
+ std::string line = static_cast(lines[row]);
if (col > line.size())
col = line.size();
buf->SetCursor(col, row);
@@ -393,14 +399,14 @@ search_compute_matches(const Buffer &buf, const std::string &q)
if (q.empty())
return out;
const auto &rows = buf.Rows();
- for (std::size_t y = 0; y < rows.size(); ++y) {
- std::string line = static_cast(rows[y]);
- std::size_t pos = 0;
- while (!q.empty() && (pos = line.find(q, pos)) != std::string::npos) {
- out.emplace_back(y, pos);
- pos += q.size();
- }
- }
+ for (std::size_t y = 0; y < rows.size(); ++y) {
+ std::string line = static_cast(rows[y]);
+ std::size_t pos = 0;
+ while (!q.empty() && (pos = line.find(q, pos)) != std::string::npos) {
+ out.emplace_back(y, pos);
+ pos += q.size();
+ }
+ }
return out;
}
@@ -423,16 +429,16 @@ search_compute_matches_regex(const Buffer &buf, const std::string &pattern, std:
try {
const std::regex rx(pattern);
const auto &rows = buf.Rows();
- for (std::size_t y = 0; y < rows.size(); ++y) {
- std::string line = static_cast(rows[y]);
- for (auto it = std::sregex_iterator(line.begin(), line.end(), rx);
- it != std::sregex_iterator(); ++it) {
- const auto &m = *it;
- out.push_back(RegexMatch{
- y, static_cast(m.position()), static_cast(m.length())
- });
- }
- }
+ for (std::size_t y = 0; y < rows.size(); ++y) {
+ std::string line = static_cast(rows[y]);
+ for (auto it = std::sregex_iterator(line.begin(), line.end(), rx);
+ it != std::sregex_iterator(); ++it) {
+ const auto &m = *it;
+ out.push_back(RegexMatch{
+ y, static_cast(m.position()), static_cast(m.length())
+ });
+ }
+ }
} catch (const std::regex_error &e) {
err_out = e.what();
// Return empty results on error
@@ -670,7 +676,7 @@ static bool
cmd_refresh(CommandContext &ctx)
{
// If a generic prompt is active, cancel it
- if (ctx.editor.PromptActive()) {
+ if (ctx.editor.PromptActive()) {
// If also in search mode, restore state
if (ctx.editor.SearchActive()) {
Buffer *buf = ctx.editor.CurrentBuffer();
@@ -723,6 +729,21 @@ cmd_kprefix(CommandContext &ctx)
}
+// Start generic command prompt (": ")
+static bool
+cmd_command_prompt_start(const CommandContext &ctx)
+{
+ // Close any pending edit batch before entering prompt
+ if (Buffer *b = ctx.editor.CurrentBuffer()) {
+ if (auto *u = b->Undo())
+ u->commit();
+ }
+ ctx.editor.StartPrompt(Editor::PromptKind::Command, "", "");
+ ctx.editor.SetStatus(": ");
+ return true;
+}
+
+
static bool
cmd_unknown_kcommand(CommandContext &ctx)
{
@@ -737,8 +758,135 @@ cmd_unknown_kcommand(CommandContext &ctx)
}
+// GUI theme cycling commands (available in GUI build; show message otherwise)
+#ifdef KTE_BUILD_GUI
static bool
-cmd_find_start(CommandContext &ctx)
+cmd_theme_next(CommandContext &ctx)
+{
+ auto id = kte::NextTheme();
+ ctx.editor.SetStatus(std::string("Theme: ") + kte::ThemeName(id));
+ return true;
+}
+
+
+static bool
+cmd_theme_prev(CommandContext &ctx)
+{
+ auto id = kte::PrevTheme();
+ ctx.editor.SetStatus(std::string("Theme: ") + kte::ThemeName(id));
+ return true;
+}
+#else
+static bool
+cmd_theme_next(CommandContext &ctx)
+{
+ ctx.editor.SetStatus("Theme switching only available in GUI build");
+ return true;
+}
+
+static bool
+cmd_theme_prev(CommandContext &ctx)
+{
+ ctx.editor.SetStatus("Theme switching only available in GUI build");
+ return true;
+}
+#endif
+
+
+// Theme set by name command
+#ifdef KTE_BUILD_GUI
+static bool
+cmd_theme_set_by_name(const CommandContext &ctx)
+{
+ std::string name = ctx.arg;
+ // trim spaces
+ auto ltrim = [](std::string &s) {
+ s.erase(s.begin(), std::find_if(s.begin(), s.end(), [](unsigned char ch) {
+ return !std::isspace(ch);
+ }));
+ };
+ auto rtrim = [](std::string &s) {
+ s.erase(std::find_if(s.rbegin(), s.rend(), [](unsigned char ch) {
+ return !std::isspace(ch);
+ }).base(), s.end());
+ };
+ ltrim(name);
+ rtrim(name);
+ if (name.empty()) {
+ ctx.editor.SetStatus("theme: missing name");
+ return true;
+ }
+ if (kte::ApplyThemeByName(name)) {
+ ctx.editor.SetStatus(
+ std::string("Theme: ") + name + std::string(" (bg: ") + kte::BackgroundModeName() + ")");
+ } else {
+ // Build list of available themes
+ const auto ® = kte::ThemeRegistry();
+ std::string avail;
+ for (size_t i = 0; i < reg.size(); ++i) {
+ if (i)
+ avail += ", ";
+ avail += reg[i]->Name();
+ }
+ ctx.editor.SetStatus(std::string("Unknown theme; available: ") + avail);
+ }
+ return true;
+}
+#else
+static bool
+cmd_theme_set_by_name(CommandContext &ctx)
+{
+ (void) ctx;
+ // No-op in terminal build
+ return true;
+}
+#endif
+
+
+// Background set command (GUI)
+#ifdef KTE_BUILD_GUI
+static bool
+cmd_background_set(const CommandContext &ctx)
+{
+ std::string mode = ctx.arg;
+ // trim
+ auto ltrim = [](std::string &s) {
+ s.erase(s.begin(), std::find_if(s.begin(), s.end(), [](unsigned char ch) {
+ return !std::isspace(ch);
+ }));
+ };
+ auto rtrim = [](std::string &s) {
+ s.erase(std::find_if(s.rbegin(), s.rend(), [](unsigned char ch) {
+ return !std::isspace(ch);
+ }).base(), s.end());
+ };
+ ltrim(mode);
+ rtrim(mode);
+ std::transform(mode.begin(), mode.end(), mode.begin(), [](unsigned char c) {
+ return (char) std::tolower(c);
+ });
+ if (mode != "light" && mode != "dark") {
+ ctx.editor.SetStatus("background: expected 'light' or 'dark'");
+ return true;
+ }
+ kte::SetBackgroundMode(mode == "light" ? kte::BackgroundMode::Light : kte::BackgroundMode::Dark);
+ // Re-apply current theme to reflect background change
+ kte::ApplyThemeByName(kte::CurrentThemeName());
+ ctx.editor.SetStatus(std::string("Background: ") + mode + std::string("; Theme: ") + kte::CurrentThemeName());
+ return true;
+}
+#else
+static bool
+cmd_background_set(CommandContext &ctx)
+{
+ (void) ctx;
+ return true;
+}
+#endif
+
+
+static bool
+cmd_find_start(const CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf) {
@@ -761,7 +909,7 @@ cmd_find_start(CommandContext &ctx)
static bool
-cmd_regex_find_start(CommandContext &ctx)
+cmd_regex_find_start(const CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf) {
@@ -784,7 +932,7 @@ cmd_regex_find_start(CommandContext &ctx)
static bool
-cmd_search_replace_start(CommandContext &ctx)
+cmd_search_replace_start(const CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf) {
@@ -808,31 +956,31 @@ cmd_search_replace_start(CommandContext &ctx)
static bool
-cmd_regex_replace_start(CommandContext &ctx)
+cmd_regex_replace_start(const CommandContext &ctx)
{
- Buffer *buf = ctx.editor.CurrentBuffer();
- if (!buf) {
- ctx.editor.SetStatus("No buffer to search");
- return false;
- }
- // Save original cursor/viewport to restore on cancel
- ctx.editor.SetSearchOrigin(buf->Curx(), buf->Cury(), buf->Rowoffs(), buf->Coloffs());
- // Enter search-highlighting mode for the find step (regex)
- ctx.editor.SetSearchActive(true);
- ctx.editor.SetSearchQuery("");
- ctx.editor.SetSearchMatch(0, 0, 0);
- ctx.editor.SetSearchIndex(-1);
- // Two-step prompt: first collect regex find pattern, then replacement
- ctx.editor.SetReplaceFindTmp("");
- ctx.editor.SetReplaceWithTmp("");
- ctx.editor.StartPrompt(Editor::PromptKind::RegexReplaceFind, "Regex replace: find", "");
- ctx.editor.SetStatus("Regex replace: find: ");
- return true;
+ Buffer *buf = ctx.editor.CurrentBuffer();
+ if (!buf) {
+ ctx.editor.SetStatus("No buffer to search");
+ return false;
+ }
+ // Save original cursor/viewport to restore on cancel
+ ctx.editor.SetSearchOrigin(buf->Curx(), buf->Cury(), buf->Rowoffs(), buf->Coloffs());
+ // Enter search-highlighting mode for the find step (regex)
+ ctx.editor.SetSearchActive(true);
+ ctx.editor.SetSearchQuery("");
+ ctx.editor.SetSearchMatch(0, 0, 0);
+ ctx.editor.SetSearchIndex(-1);
+ // Two-step prompt: first collect regex find pattern, then replacement
+ ctx.editor.SetReplaceFindTmp("");
+ ctx.editor.SetReplaceWithTmp("");
+ ctx.editor.StartPrompt(Editor::PromptKind::RegexReplaceFind, "Regex replace: find", "");
+ ctx.editor.SetStatus("Regex replace: find: ");
+ return true;
}
static bool
-cmd_open_file_start(CommandContext &ctx)
+cmd_open_file_start(const CommandContext &ctx)
{
// Start a generic prompt to read a path
ctx.editor.StartPrompt(Editor::PromptKind::OpenFile, "Open", "");
@@ -843,7 +991,7 @@ cmd_open_file_start(CommandContext &ctx)
// GUI: toggle visual file picker (no-op in terminal; renderer will consume flag)
static bool
-cmd_visual_file_picker_toggle(CommandContext &ctx)
+cmd_visual_file_picker_toggle(const CommandContext &ctx)
{
// Toggle visibility
bool show = !ctx.editor.FilePickerVisible();
@@ -866,7 +1014,7 @@ cmd_visual_file_picker_toggle(CommandContext &ctx)
static bool
-cmd_jump_to_line_start(CommandContext &ctx)
+cmd_jump_to_line_start(const CommandContext &ctx)
{
// Start a prompt to read a 1-based line number and jump there (clamped)
ctx.editor.StartPrompt(Editor::PromptKind::GotoLine, "Goto", "");
@@ -877,7 +1025,7 @@ cmd_jump_to_line_start(CommandContext &ctx)
// --- Buffers: switch/next/prev/close ---
static bool
-cmd_buffer_switch_start(CommandContext &ctx)
+cmd_buffer_switch_start(const CommandContext &ctx)
{
// If only one (or zero) buffer is open, do nothing per spec
if (ctx.editor.BufferCount() <= 1) {
@@ -895,7 +1043,7 @@ buffer_display_name(const Buffer &b)
{
if (!b.Filename().empty())
return b.Filename();
- return std::string("");
+ return {""};
}
@@ -904,7 +1052,7 @@ buffer_basename(const Buffer &b)
{
const std::string &p = b.Filename();
if (p.empty())
- return std::string("");
+ return {""};
auto pos = p.find_last_of("/\\");
if (pos == std::string::npos)
return p;
@@ -913,7 +1061,7 @@ buffer_basename(const Buffer &b)
static bool
-cmd_buffer_next(CommandContext &ctx)
+cmd_buffer_next(const CommandContext &ctx)
{
const auto cnt = ctx.editor.BufferCount();
if (cnt <= 1) {
@@ -930,7 +1078,7 @@ cmd_buffer_next(CommandContext &ctx)
static bool
-cmd_buffer_prev(CommandContext &ctx)
+cmd_buffer_prev(const CommandContext &ctx)
{
const auto cnt = ctx.editor.BufferCount();
if (cnt <= 1) {
@@ -947,7 +1095,7 @@ cmd_buffer_prev(CommandContext &ctx)
static bool
-cmd_buffer_close(CommandContext &ctx)
+cmd_buffer_close(const CommandContext &ctx)
{
if (ctx.editor.BufferCount() == 0)
return true;
@@ -1116,38 +1264,117 @@ cmd_insert_text(CommandContext &ctx)
ctx.editor.SetStatus(ctx.editor.PromptLabel() + ": " + ctx.editor.PromptText());
return true;
}
+
+ // Generic command prompt completion
+ if (kind == Editor::PromptKind::Command) {
+ std::string text = ctx.editor.PromptText();
+ // Split into command and arg prefix
+ auto sp = text.find(' ');
+ if (sp == std::string::npos) {
+ // complete command name from public commands
+ std::string prefix = text;
+ std::vector names;
+ for (const auto &c: CommandRegistry::All()) {
+ if (c.isPublic) {
+ if (prefix.empty() || c.name.rfind(prefix, 0) == 0)
+ names.push_back(c.name);
+ }
+ }
+ if (names.empty()) {
+ // no change
+ } else if (names.size() == 1) {
+ ctx.editor.SetPromptText(names[0]);
+ } else {
+ // compute LCP
+ std::string lcp = names[0];
+ for (size_t i = 1; i < names.size(); ++i) {
+ const std::string &s = names[i];
+ size_t j = 0;
+ while (j < lcp.size() && j < s.size() && lcp[j] == s[j])
+ ++j;
+ lcp.resize(j);
+ if (lcp.empty())
+ break;
+ }
+ if (!lcp.empty() && lcp != text)
+ ctx.editor.SetPromptText(lcp);
+ }
+ ctx.editor.SetStatus(std::string(": ") + ctx.editor.PromptText());
+ return true;
+ } else {
+ std::string cmd = text.substr(0, sp);
+ std::string argprefix = text.substr(sp + 1);
+ // Only special-case argument completion for certain commands
+ if (cmd == "theme") {
+#ifdef KTE_BUILD_GUI
+ std::vector cands;
+ const auto ® = kte::ThemeRegistry();
+ for (const auto &t: reg) {
+ std::string n = t->Name();
+ if (argprefix.empty() || n.rfind(argprefix, 0) == 0)
+ cands.push_back(n);
+ }
+ if (cands.empty()) {
+ // no change
+ } else if (cands.size() == 1) {
+ ctx.editor.SetPromptText(cmd + std::string(" ") + cands[0]);
+ } else {
+ std::string lcp = cands[0];
+ for (size_t i = 1; i < cands.size(); ++i) {
+ const std::string &s = cands[i];
+ size_t j = 0;
+ while (j < lcp.size() && j < s.size() && lcp[j] == s[j])
+ ++j;
+ lcp.resize(j);
+ if (lcp.empty())
+ break;
+ }
+ if (!lcp.empty() && lcp != argprefix)
+ ctx.editor.SetPromptText(cmd + std::string(" ") + lcp);
+ }
+ ctx.editor.SetStatus(std::string(": ") + ctx.editor.PromptText());
+ return true;
+#else
+ (void) argprefix; // no completion in non-GUI build
+#endif
+ }
+ // default: no special arg completion
+ ctx.editor.SetStatus(std::string(": ") + ctx.editor.PromptText());
+ return true;
+ }
+ }
}
ctx.editor.AppendPromptText(ctx.arg);
// If it's a search prompt, mirror text to search state
- if (ctx.editor.CurrentPromptKind() == Editor::PromptKind::Search ||
- ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexSearch ||
- ctx.editor.CurrentPromptKind() == Editor::PromptKind::ReplaceFind ||
- ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind) {
- ctx.editor.SetSearchQuery(ctx.editor.PromptText());
- if (ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexSearch ||
- ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind) {
- std::string err;
- auto rmatches = search_compute_matches_regex(*buf, ctx.editor.SearchQuery(), err);
- if (!err.empty()) {
- ctx.editor.SetStatus(
- std::string("Regex: ") + ctx.editor.PromptText() + " [error: " + err +
- "]");
- }
- if (ctx.editor.SearchIndex() >= static_cast(rmatches.size()))
- ctx.editor.SetSearchIndex(rmatches.empty() ? -1 : 0);
- search_apply_match_regex(ctx.editor, *buf, rmatches);
- } else {
- auto matches = search_compute_matches(*buf, ctx.editor.SearchQuery());
- // Keep index stable unless out of range
- if (ctx.editor.SearchIndex() >= static_cast(matches.size()))
- ctx.editor.SetSearchIndex(matches.empty() ? -1 : 0);
- search_apply_match(ctx.editor, *buf, matches);
- }
- } else {
- // For other prompts, just echo label:text in status
- ctx.editor.SetStatus(ctx.editor.PromptLabel() + ": " + ctx.editor.PromptText());
- }
+ if (ctx.editor.CurrentPromptKind() == Editor::PromptKind::Search ||
+ ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexSearch ||
+ ctx.editor.CurrentPromptKind() == Editor::PromptKind::ReplaceFind ||
+ ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind) {
+ ctx.editor.SetSearchQuery(ctx.editor.PromptText());
+ if (ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexSearch ||
+ ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind) {
+ std::string err;
+ auto rmatches = search_compute_matches_regex(*buf, ctx.editor.SearchQuery(), err);
+ if (!err.empty()) {
+ ctx.editor.SetStatus(
+ std::string("Regex: ") + ctx.editor.PromptText() + " [error: " + err +
+ "]");
+ }
+ if (ctx.editor.SearchIndex() >= static_cast(rmatches.size()))
+ ctx.editor.SetSearchIndex(rmatches.empty() ? -1 : 0);
+ search_apply_match_regex(ctx.editor, *buf, rmatches);
+ } else {
+ auto matches = search_compute_matches(*buf, ctx.editor.SearchQuery());
+ // Keep index stable unless out of range
+ if (ctx.editor.SearchIndex() >= static_cast(matches.size()))
+ ctx.editor.SetSearchIndex(matches.empty() ? -1 : 0);
+ search_apply_match(ctx.editor, *buf, matches);
+ }
+ } else {
+ // For other prompts, just echo label:text in status
+ ctx.editor.SetStatus(ctx.editor.PromptLabel() + ": " + ctx.editor.PromptText());
+ }
return true;
}
// If in search mode, treat printable input as query update
@@ -1161,8 +1388,8 @@ cmd_insert_text(CommandContext &ctx)
std::vector > matches;
if (!q.empty()) {
for (std::size_t y = 0; y < rows.size(); ++y) {
- std::string line = static_cast(rows[y]);
- std::size_t pos = 0;
+ std::string line = static_cast(rows[y]);
+ std::size_t pos = 0;
while (!q.empty() && (pos = line.find(q, pos)) != std::string::npos) {
matches.emplace_back(y, pos);
pos += q.size();
@@ -1223,18 +1450,19 @@ cmd_insert_text(CommandContext &ctx)
return true;
}
+
// Toggle read-only state of the current buffer
static bool
cmd_toggle_read_only(CommandContext &ctx)
{
- Buffer *buf = ctx.editor.CurrentBuffer();
- if (!buf) {
- ctx.editor.SetStatus("No buffer");
- return false;
- }
- buf->ToggleReadOnly();
- ctx.editor.SetStatus(std::string("Read-only: ") + (buf->IsReadOnly() ? "ON" : "OFF"));
- return true;
+ Buffer *buf = ctx.editor.CurrentBuffer();
+ if (!buf) {
+ ctx.editor.SetStatus("No buffer");
+ return false;
+ }
+ buf->ToggleReadOnly();
+ ctx.editor.SetStatus(std::string("Read-only: ") + (buf->IsReadOnly() ? "ON" : "OFF"));
+ return true;
}
@@ -1242,183 +1470,206 @@ cmd_toggle_read_only(CommandContext &ctx)
static bool
cmd_show_help(CommandContext &ctx)
{
- const std::string help_name = "+HELP+";
- // Try to locate existing +HELP+ buffer
- std::vector &bufs = ctx.editor.Buffers();
- std::size_t help_index = static_cast(-1);
- for (std::size_t i = 0; i < bufs.size(); ++i) {
- if (bufs[i].Filename() == help_name && !bufs[i].IsFileBacked()) {
- help_index = i;
- break;
- }
- }
+ const std::string help_name = "+HELP+";
+ // Try to locate existing +HELP+ buffer
+ std::vector &bufs = ctx.editor.Buffers();
+ std::size_t help_index = static_cast(-1);
+ for (std::size_t i = 0; i < bufs.size(); ++i) {
+ if (bufs[i].Filename() == help_name && !bufs[i].IsFileBacked()) {
+ help_index = i;
+ break;
+ }
+ }
- auto roff_to_text = [](const std::string &in) -> std::string {
- std::istringstream iss(in);
- std::ostringstream out;
- std::string line;
- auto unquote = [](std::string s) {
- if (!s.empty() && (s.front() == '"' || s.front() == '\'')) s.erase(s.begin());
- if (!s.empty() && (s.back() == '"' || s.back() == '\'')) s.pop_back();
- return s;
- };
- while (std::getline(iss, line)) {
- if (line.rfind("'", 0) == 0) {
- continue; // comment line
- }
- if (line.rfind(".", 0) == 0) {
- // Macro line
- std::istringstream ls(line);
- std::string dot, macro;
- ls >> dot >> macro;
- if (macro == "TH" || macro == "SH") {
- std::string title;
- std::getline(ls, title);
- // trim leading spaces
- while (!title.empty() && (title.front() == ' ' || title.front() == '\t')) title.erase(title.begin());
- title = unquote(title);
- out << "\n\n";
- for (auto &c : title) c = static_cast(std::toupper(static_cast(c)));
- out << title << "\n";
- } else if (macro == "PP" || macro == "P" || macro == "TP") {
- out << "\n";
- } else if (macro == "B" || macro == "I" || macro == "BR" || macro == "IR") {
- std::string rest;
- std::getline(ls, rest);
- while (!rest.empty() && (rest.front() == ' ' || rest.front() == '\t')) rest.erase(rest.begin());
- out << unquote(rest) << "\n";
- } else if (macro == "nf" || macro == "fi") {
- // ignore fill mode toggles for now
- } else {
- // Unhandled macro: ignore
- }
- continue;
- }
- // Regular text; apply minimal escape replacements
- for (std::size_t i = 0; i < line.size(); ++i) {
- if (line[i] == '\\') {
- if (i + 1 < line.size() && line[i + 1] == '-') { out << '-'; ++i; continue; }
- if (i + 3 < line.size() && line[i + 1] == '(') {
- std::string esc = line.substr(i + 2, 2);
- if (esc == "em") { out << "—"; i += 3; continue; }
- if (esc == "en") { out << "-"; i += 3; continue; }
- }
- }
- out << line[i];
- }
- out << "\n";
- }
- return out.str();
- };
+ auto roff_to_text = [](const std::string &in) -> std::string {
+ std::istringstream iss(in);
+ std::ostringstream out;
+ std::string line;
+ auto unquote = [](std::string s) {
+ if (!s.empty() && (s.front() == '"' || s.front() == '\''))
+ s.erase(s.begin());
+ if (!s.empty() && (s.back() == '"' || s.back() == '\''))
+ s.pop_back();
+ return s;
+ };
+ while (std::getline(iss, line)) {
+ if (line.rfind("'", 0) == 0) {
+ continue; // comment line
+ }
+ if (line.rfind(".", 0) == 0) {
+ // Macro line
+ std::istringstream ls(line);
+ std::string dot, macro;
+ ls >> dot >> macro;
+ if (macro == "TH" || macro == "SH") {
+ std::string title;
+ std::getline(ls, title);
+ // trim leading spaces
+ while (!title.empty() && (title.front() == ' ' || title.front() == '\t'))
+ title.erase(title.begin());
+ title = unquote(title);
+ out << "\n\n";
+ for (auto &c: title)
+ c = static_cast(std::toupper(static_cast(c)));
+ out << title << "\n";
+ } else if (macro == "PP" || macro == "P" || macro == "TP") {
+ out << "\n";
+ } else if (macro == "B" || macro == "I" || macro == "BR" || macro == "IR") {
+ std::string rest;
+ std::getline(ls, rest);
+ while (!rest.empty() && (rest.front() == ' ' || rest.front() == '\t'))
+ rest.erase(rest.begin());
+ out << unquote(rest) << "\n";
+ } else if (macro == "nf" || macro == "fi") {
+ // ignore fill mode toggles for now
+ } else {
+ // Unhandled macro: ignore
+ }
+ continue;
+ }
+ // Regular text; apply minimal escape replacements
+ for (std::size_t i = 0; i < line.size(); ++i) {
+ if (line[i] == '\\') {
+ if (i + 1 < line.size() && line[i + 1] == '-') {
+ out << '-';
+ ++i;
+ continue;
+ }
+ if (i + 3 < line.size() && line[i + 1] == '(') {
+ std::string esc = line.substr(i + 2, 2);
+ if (esc == "em") {
+ out << "—";
+ i += 3;
+ continue;
+ }
+ if (esc == "en") {
+ out << "-";
+ i += 3;
+ continue;
+ }
+ }
+ }
+ out << line[i];
+ }
+ out << "\n";
+ }
+ return out.str();
+ };
- auto load_help_text = [&](bool &used_man) -> std::string {
- // 1) Prefer embedded/customizable help content
- {
- std::string embedded = HelpText::Text();
- if (!embedded.empty()) { used_man = false; return embedded; }
- }
+ auto load_help_text = [&](bool &used_man) -> std::string {
+ // 1) Prefer embedded/customizable help content
+ {
+ std::string embedded = HelpText::Text();
+ if (!embedded.empty()) {
+ used_man = false;
+ return embedded;
+ }
+ }
- // 2) Fall back to the manpage and convert roff to plain text
- const char *man_candidates[] = {
- "docs/kte.1",
- "./docs/kte.1",
- "/usr/local/share/man/man1/kte.1",
- "/usr/share/man/man1/kte.1"
- };
- for (const char *p : man_candidates) {
- std::ifstream in(p);
- if (in.good()) {
- std::string s((std::istreambuf_iterator(in)), std::istreambuf_iterator());
- if (!s.empty()) { used_man = true; return roff_to_text(s); }
- }
- }
- // Fallback minimal help text
- used_man = false;
- return std::string(
- "KTE - Kyle's Text Editor\n\n"
- "About:\n"
- " kte is Kyle's Text Editor and is probably ill-suited to everyone else. It was\n"
- " inspired by Antirez' kilo text editor by way of someone's writeup of the\n"
- " process of writing a text editor from scratch. It has keybindings inspired by\n"
- " VDE (and the Wordstar family) and emacs; its spiritual parent is mg(1).\n\n"
- "Core keybindings:\n"
- " C-k h Show this help\n"
- " C-k s Save buffer\n"
- " C-k x Save and quit\n"
- " C-k q Quit (confirm if dirty)\n"
- " C-k C-q Quit now (no confirm)\n"
- " C-k c Close current buffer\n"
- " C-k b Switch buffer\n"
- " C-k p Next buffer\n"
- " C-k n Previous buffer\n"
- " C-k e Open file (prompt)\n"
- " C-k g Jump to line\n"
- " C-k u Undo\n"
- " C-k r Redo\n"
- " C-k d Kill to end of line\n"
- " C-k C-d Kill entire line\n"
- " C-k = Indent region\n"
- " C-k - Unindent region\n"
- " C-k ' Toggle read-only\n"
- " C-k l Reload buffer from disk\n"
- " C-k a Mark all and jump to end\n"
- " C-k v Toggle visual file picker (GUI)\n"
- " C-k w Show working directory\n"
- " C-k o Change working directory (prompt)\n\n"
- "ESC/Alt commands:\n"
- " ESC q Reflow paragraph\n"
- " ESC BACKSPACE Delete previous word\n"
- " ESC d Delete next word\n"
- " Alt-w Copy region to kill ring\n\n"
- "Buffers:\n +HELP+ is read-only. Press C-k ' to toggle if you need to edit; C-k h restores it.\n");
- };
+ // 2) Fall back to the manpage and convert roff to plain text
+ const char *man_candidates[] = {
+ "docs/kte.1",
+ "./docs/kte.1",
+ "/usr/local/share/man/man1/kte.1",
+ "/usr/share/man/man1/kte.1"
+ };
+ for (const char *p: man_candidates) {
+ std::ifstream in(p);
+ if (in.good()) {
+ std::string s((std::istreambuf_iterator(in)), std::istreambuf_iterator());
+ if (!s.empty()) {
+ used_man = true;
+ return roff_to_text(s);
+ }
+ }
+ }
+ // Fallback minimal help text
+ used_man = false;
+ return std::string(
+ "KTE - Kyle's Text Editor\n\n"
+ "About:\n"
+ " kte is Kyle's Text Editor and is probably ill-suited to everyone else. It was\n"
+ " inspired by Antirez' kilo text editor by way of someone's writeup of the\n"
+ " process of writing a text editor from scratch. It has keybindings inspired by\n"
+ " VDE (and the Wordstar family) and emacs; its spiritual parent is mg(1).\n\n"
+ "Core keybindings:\n"
+ " C-k h Show this help\n"
+ " C-k s Save buffer\n"
+ " C-k x Save and quit\n"
+ " C-k q Quit (confirm if dirty)\n"
+ " C-k C-q Quit now (no confirm)\n"
+ " C-k c Close current buffer\n"
+ " C-k b Switch buffer\n"
+ " C-k p Next buffer\n"
+ " C-k n Previous buffer\n"
+ " C-k e Open file (prompt)\n"
+ " C-k g Jump to line\n"
+ " C-k u Undo\n"
+ " C-k r Redo\n"
+ " C-k d Kill to end of line\n"
+ " C-k C-d Kill entire line\n"
+ " C-k = Indent region\n"
+ " C-k - Unindent region\n"
+ " C-k ' Toggle read-only\n"
+ " C-k l Reload buffer from disk\n"
+ " C-k a Mark all and jump to end\n"
+ " C-k v Toggle visual file picker (GUI)\n"
+ " C-k w Show working directory\n"
+ " C-k o Change working directory (prompt)\n\n"
+ "ESC/Alt commands:\n"
+ " ESC q Reflow paragraph\n"
+ " ESC BACKSPACE Delete previous word\n"
+ " ESC d Delete next word\n"
+ " Alt-w Copy region to kill ring\n\n"
+ "Buffers:\n +HELP+ is read-only. Press C-k ' to toggle if you need to edit; C-k h restores it.\n");
+ };
- auto populate_from_text = [](Buffer &b, const std::string &text) {
- auto &rows = b.Rows();
- rows.clear();
- std::string line;
- line.reserve(128);
- for (char ch : text) {
- if (ch == '\n') {
- rows.emplace_back(line);
- line.clear();
- } else if (ch != '\r') {
- line.push_back(ch);
- }
- }
- // Add last line (even if empty)
- rows.emplace_back(line);
- b.SetDirty(false);
- b.SetCursor(0, 0);
- b.SetOffsets(0, 0);
- b.SetRenderX(0);
- };
+ auto populate_from_text = [](Buffer &b, const std::string &text) {
+ auto &rows = b.Rows();
+ rows.clear();
+ std::string line;
+ line.reserve(128);
+ for (char ch: text) {
+ if (ch == '\n') {
+ rows.emplace_back(line);
+ line.clear();
+ } else if (ch != '\r') {
+ line.push_back(ch);
+ }
+ }
+ // Add last line (even if empty)
+ rows.emplace_back(line);
+ b.SetDirty(false);
+ b.SetCursor(0, 0);
+ b.SetOffsets(0, 0);
+ b.SetRenderX(0);
+ };
- if (help_index != static_cast(-1)) {
- Buffer &hb = bufs[help_index];
- // If dirty, overwrite with original contents
- if (hb.Dirty()) {
- bool used_man = false;
- std::string text = load_help_text(used_man);
- populate_from_text(hb, text);
- }
- hb.SetReadOnly(true);
- ctx.editor.SwitchTo(help_index);
- ctx.editor.SetStatus("Help opened");
- return true;
- }
+ if (help_index != static_cast(-1)) {
+ Buffer &hb = bufs[help_index];
+ // If dirty, overwrite with original contents
+ if (hb.Dirty()) {
+ bool used_man = false;
+ std::string text = load_help_text(used_man);
+ populate_from_text(hb, text);
+ }
+ hb.SetReadOnly(true);
+ ctx.editor.SwitchTo(help_index);
+ ctx.editor.SetStatus("Help opened");
+ return true;
+ }
- // Create a new help buffer
- Buffer help;
- help.SetVirtualName(help_name);
- bool used_man = false;
- std::string text = load_help_text(used_man);
- populate_from_text(help, text);
- help.SetReadOnly(true);
- std::size_t idx = ctx.editor.AddBuffer(std::move(help));
- ctx.editor.SwitchTo(idx);
- ctx.editor.SetStatus("Help opened");
- return true;
+ // Create a new help buffer
+ Buffer help;
+ help.SetVirtualName(help_name);
+ bool used_man = false;
+ std::string text = load_help_text(used_man);
+ populate_from_text(help, text);
+ help.SetReadOnly(true);
+ std::size_t idx = ctx.editor.AddBuffer(std::move(help));
+ ctx.editor.SwitchTo(idx);
+ ctx.editor.SetStatus("Help opened");
+ return true;
}
@@ -1430,6 +1681,46 @@ cmd_newline(CommandContext &ctx)
Editor::PromptKind kind = ctx.editor.CurrentPromptKind();
std::string value = ctx.editor.PromptText();
ctx.editor.AcceptPrompt();
+ if (kind == Editor::PromptKind::Command) {
+ // Parse COMMAND ARG and dispatch only public commands
+ // Trim leading/trailing spaces
+ auto ltrim = [](std::string &s) {
+ s.erase(s.begin(), std::find_if(s.begin(), s.end(), [](unsigned char ch) {
+ return !std::isspace(ch);
+ }));
+ };
+ auto rtrim = [](std::string &s) {
+ s.erase(std::find_if(s.rbegin(), s.rend(), [](unsigned char ch) {
+ return !std::isspace(ch);
+ }).base(), s.end());
+ };
+ ltrim(value);
+ rtrim(value);
+ if (value.empty()) {
+ ctx.editor.SetStatus("Canceled");
+ return true;
+ }
+ // Split first token
+ std::string cmdname;
+ std::string arg;
+ auto sp = value.find(' ');
+ if (sp == std::string::npos) {
+ cmdname = value;
+ } else {
+ cmdname = value.substr(0, sp);
+ arg = value.substr(sp + 1);
+ }
+ const Command *cmd = CommandRegistry::FindByName(cmdname);
+ if (!cmd || !cmd->isPublic) {
+ ctx.editor.SetStatus(std::string("Unknown command: ") + cmdname);
+ return true;
+ }
+ bool ok = Execute(ctx.editor, cmdname, arg);
+ if (!ok) {
+ ctx.editor.SetStatus(std::string("Command failed: ") + cmdname);
+ }
+ return true;
+ }
if (kind == Editor::PromptKind::Search || kind == Editor::PromptKind::RegexSearch) {
// Finish search: keep cursor where it is, clear search UI prompt
ctx.editor.SetSearchActive(false);
@@ -1452,24 +1743,24 @@ cmd_newline(CommandContext &ctx)
ctx.editor.StartPrompt(Editor::PromptKind::ReplaceWith, "Replace: with", "");
ctx.editor.SetStatus("Replace: with: ");
return true;
- } else if (kind == Editor::PromptKind::ReplaceWith) {
- // Execute replace-all
- Buffer *buf = ctx.editor.CurrentBuffer();
- if (!buf)
- return false;
- if (buf->IsReadOnly()) {
- ctx.editor.SetStatus("Read-only buffer");
- // Clear search UI state
- ctx.editor.SetSearchActive(false);
- ctx.editor.SetSearchQuery("");
- ctx.editor.SetSearchMatch(0, 0, 0);
- ctx.editor.ClearSearchOrigin();
- ctx.editor.SetSearchIndex(-1);
- return true;
- }
- const std::string find = ctx.editor.ReplaceFindTmp();
- const std::string with = value;
- ctx.editor.SetReplaceWithTmp(with);
+ } else if (kind == Editor::PromptKind::ReplaceWith) {
+ // Execute replace-all
+ Buffer *buf = ctx.editor.CurrentBuffer();
+ if (!buf)
+ return false;
+ if (buf->IsReadOnly()) {
+ ctx.editor.SetStatus("Read-only buffer");
+ // Clear search UI state
+ ctx.editor.SetSearchActive(false);
+ ctx.editor.SetSearchQuery("");
+ ctx.editor.SetSearchMatch(0, 0, 0);
+ ctx.editor.ClearSearchOrigin();
+ ctx.editor.SetSearchIndex(-1);
+ return true;
+ }
+ const std::string find = ctx.editor.ReplaceFindTmp();
+ const std::string with = value;
+ ctx.editor.SetReplaceWithTmp(with);
if (find.empty()) {
ctx.editor.SetStatus("Replace canceled (empty find)");
// Clear search UI state
@@ -1744,24 +2035,24 @@ cmd_newline(CommandContext &ctx)
ctx.editor.StartPrompt(Editor::PromptKind::RegexReplaceWith, "Regex replace: with", "");
ctx.editor.SetStatus("Regex replace: with: ");
return true;
- } else if (kind == Editor::PromptKind::RegexReplaceWith) {
- // Execute regex replace-all
- Buffer *buf = ctx.editor.CurrentBuffer();
- if (!buf)
- return false;
- if (buf->IsReadOnly()) {
- ctx.editor.SetStatus("Read-only buffer");
- // Clear search UI state
- ctx.editor.SetSearchActive(false);
- ctx.editor.SetSearchQuery("");
- ctx.editor.SetSearchMatch(0, 0, 0);
- ctx.editor.ClearSearchOrigin();
- ctx.editor.SetSearchIndex(-1);
- return true;
- }
- const std::string patt = ctx.editor.ReplaceFindTmp();
- const std::string repl = value;
- ctx.editor.SetReplaceWithTmp(repl);
+ } else if (kind == Editor::PromptKind::RegexReplaceWith) {
+ // Execute regex replace-all
+ Buffer *buf = ctx.editor.CurrentBuffer();
+ if (!buf)
+ return false;
+ if (buf->IsReadOnly()) {
+ ctx.editor.SetStatus("Read-only buffer");
+ // Clear search UI state
+ ctx.editor.SetSearchActive(false);
+ ctx.editor.SetSearchQuery("");
+ ctx.editor.SetSearchMatch(0, 0, 0);
+ ctx.editor.ClearSearchOrigin();
+ ctx.editor.SetSearchIndex(-1);
+ return true;
+ }
+ const std::string patt = ctx.editor.ReplaceFindTmp();
+ const std::string repl = value;
+ ctx.editor.SetReplaceWithTmp(repl);
if (patt.empty()) {
ctx.editor.SetStatus("Regex replace canceled (empty pattern)");
ctx.editor.SetSearchActive(false);
@@ -1784,16 +2075,16 @@ cmd_newline(CommandContext &ctx)
ctx.editor.SetSearchIndex(-1);
return true;
}
- auto &rows = buf->Rows();
+ auto &rows = buf->Rows();
std::size_t changed = 0;
- for (auto &line : rows) {
- std::string before = static_cast(line);
- std::string after = std::regex_replace(before, rx, repl);
- if (after != before) {
- line = after;
- ++changed;
- }
- }
+ for (auto &line: rows) {
+ std::string before = static_cast(line);
+ std::string after = std::regex_replace(before, rx, repl);
+ if (after != before) {
+ line = after;
+ ++changed;
+ }
+ }
buf->SetDirty(true);
ctx.editor.SetStatus("Regex replaced in " + std::to_string(changed) + " line(s)");
// Clear search UI state
@@ -1838,7 +2129,7 @@ cmd_newline(CommandContext &ctx)
tail = line.substr(x);
line.erase(x);
}
- rows.insert(rows.begin() + static_cast(y + 1), Buffer::Line(tail));
+ rows.insert(rows.begin() + static_cast(y + 1), Buffer::Line(tail));
y += 1;
x = 0;
}
@@ -1860,33 +2151,33 @@ cmd_backspace(CommandContext &ctx)
// If a prompt is active, backspace edits the prompt text
if (ctx.editor.PromptActive()) {
ctx.editor.BackspacePromptText();
- if (ctx.editor.CurrentPromptKind() == Editor::PromptKind::Search ||
- ctx.editor.CurrentPromptKind() == Editor::PromptKind::ReplaceFind ||
- ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexSearch ||
- ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind) {
- Buffer *buf2 = ctx.editor.CurrentBuffer();
- if (buf2) {
- ctx.editor.SetSearchQuery(ctx.editor.PromptText());
- if (ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexSearch ||
- ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind) {
- std::string err;
- auto rm = search_compute_matches_regex(*buf2, ctx.editor.SearchQuery(), err);
- if (!err.empty()) {
- ctx.editor.SetStatus(
- std::string("Regex: ") + ctx.editor.PromptText() + " [error: "
- + err + "]");
- }
- search_apply_match_regex(ctx.editor, *buf2, rm);
- } else {
- auto matches = search_compute_matches(*buf2, ctx.editor.SearchQuery());
- search_apply_match(ctx.editor, *buf2, matches);
- }
- }
- } else {
- ctx.editor.SetStatus(ctx.editor.PromptLabel() + ": " + ctx.editor.PromptText());
- }
- return true;
- }
+ if (ctx.editor.CurrentPromptKind() == Editor::PromptKind::Search ||
+ ctx.editor.CurrentPromptKind() == Editor::PromptKind::ReplaceFind ||
+ ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexSearch ||
+ ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind) {
+ Buffer *buf2 = ctx.editor.CurrentBuffer();
+ if (buf2) {
+ ctx.editor.SetSearchQuery(ctx.editor.PromptText());
+ if (ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexSearch ||
+ ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind) {
+ std::string err;
+ auto rm = search_compute_matches_regex(*buf2, ctx.editor.SearchQuery(), err);
+ if (!err.empty()) {
+ ctx.editor.SetStatus(
+ std::string("Regex: ") + ctx.editor.PromptText() + " [error: "
+ + err + "]");
+ }
+ search_apply_match_regex(ctx.editor, *buf2, rm);
+ } else {
+ auto matches = search_compute_matches(*buf2, ctx.editor.SearchQuery());
+ search_apply_match(ctx.editor, *buf2, matches);
+ }
+ }
+ } else {
+ ctx.editor.SetStatus(ctx.editor.PromptLabel() + ": " + ctx.editor.PromptText());
+ }
+ return true;
+ }
// In search mode, backspace edits the query
if (ctx.editor.SearchActive()) {
if (!ctx.editor.SearchQuery().empty()) {
@@ -2099,12 +2390,12 @@ cmd_kill_line(CommandContext &ctx)
break;
if (rows.size() == 1) {
// last remaining line: clear its contents
- killed_total += static_cast(rows[0]);
+ killed_total += static_cast(rows[0]);
rows[0].Clear();
y = 0;
} else if (y < rows.size()) {
// erase current line; keep y pointing at the next line
- killed_total += static_cast(rows[y]);
+ killed_total += static_cast(rows[y]);
killed_total += "\n";
rows.erase(rows.begin() + static_cast(y));
if (y >= rows.size()) {
@@ -2829,7 +3120,7 @@ cmd_delete_word_prev(CommandContext &ctx)
// Then collect complete lines between y and start_y
for (std::size_t ly = y + 1; ly < start_y; ++ly) {
deleted += "\n";
- deleted += static_cast(rows[ly]);
+ deleted += static_cast(rows[ly]);
}
// Finally, collect from beginning of start_y to start_x
if (start_y < rows.size()) {
@@ -2926,7 +3217,7 @@ cmd_delete_word_next(CommandContext &ctx)
// Then collect complete lines between start_y and y
for (std::size_t ly = start_y + 1; ly < y; ++ly) {
deleted += "\n";
- deleted += static_cast(rows[ly]);
+ deleted += static_cast(rows[ly]);
}
// Finally, collect from beginning of y to x
if (y < rows.size()) {
@@ -3266,24 +3557,42 @@ InstallDefaultCommands()
});
// Undo/Redo
CommandRegistry::Register({CommandId::Undo, "undo", "Undo last edit", cmd_undo});
- CommandRegistry::Register({CommandId::Redo, "redo", "Redo edit", cmd_redo});
- // Region formatting
- CommandRegistry::Register({CommandId::IndentRegion, "indent-region", "Indent region", cmd_indent_region});
- CommandRegistry::Register(
- {CommandId::UnindentRegion, "unindent-region", "Unindent region", cmd_unindent_region});
- CommandRegistry::Register({
- CommandId::ReflowParagraph, "reflow-paragraph", "Reflow paragraph to column width", cmd_reflow_paragraph
- });
- // Read-only
- CommandRegistry::Register({CommandId::ToggleReadOnly, "toggle-read-only", "Toggle buffer read-only", cmd_toggle_read_only});
- // Buffer operations
- CommandRegistry::Register({
- CommandId::ReloadBuffer, "reload-buffer", "Reload buffer from disk", cmd_reload_buffer
- });
- // Help
- CommandRegistry::Register({
- CommandId::ShowHelp, "help", "+HELP+ buffer with manual text", cmd_show_help
- });
+ CommandRegistry::Register({CommandId::Redo, "redo", "Redo edit", cmd_redo});
+ // Region formatting
+ CommandRegistry::Register({CommandId::IndentRegion, "indent-region", "Indent region", cmd_indent_region});
+ CommandRegistry::Register(
+ {CommandId::UnindentRegion, "unindent-region", "Unindent region", cmd_unindent_region});
+ CommandRegistry::Register({
+ CommandId::ReflowParagraph, "reflow-paragraph", "Reflow paragraph to column width", cmd_reflow_paragraph
+ });
+ // Read-only
+ CommandRegistry::Register({
+ CommandId::ToggleReadOnly, "toggle-read-only", "Toggle buffer read-only", cmd_toggle_read_only
+ });
+ // GUI Themes
+ CommandRegistry::Register({CommandId::ThemeNext, "theme-next", "Cycle to next GUI theme", cmd_theme_next});
+ CommandRegistry::Register({CommandId::ThemePrev, "theme-prev", "Cycle to previous GUI theme", cmd_theme_prev});
+ // Theme by name (public in command prompt)
+ CommandRegistry::Register({
+ CommandId::ThemeSetByName, "theme", "Set GUI theme by name", cmd_theme_set_by_name, true
+ });
+ // Background light/dark (public)
+ CommandRegistry::Register({
+ CommandId::BackgroundSet, "background", "Set GUI background light|dark", cmd_background_set, true
+ });
+ // Generic command prompt (C-k ;)
+ CommandRegistry::Register({
+ CommandId::CommandPromptStart, "command-prompt-start", "Start generic command prompt",
+ cmd_command_prompt_start
+ });
+ // Buffer operations
+ CommandRegistry::Register({
+ CommandId::ReloadBuffer, "reload-buffer", "Reload buffer from disk", cmd_reload_buffer
+ });
+ // Help
+ CommandRegistry::Register({
+ CommandId::ShowHelp, "help", "+HELP+ buffer with manual text", cmd_show_help
+ });
CommandRegistry::Register({
CommandId::MarkAllAndJumpEnd, "mark-all-jump-end", "Set mark at beginning and jump to end",
cmd_mark_all_and_jump_end
@@ -3311,9 +3620,9 @@ InstallDefaultCommands()
bool
Execute(Editor &ed, CommandId id, const std::string &arg, int count)
{
- const Command *cmd = CommandRegistry::FindById(id);
- if (!cmd)
- return false;
+ const Command *cmd = CommandRegistry::FindById(id);
+ if (!cmd)
+ return false;
// If a quit confirmation was pending and the user invoked something other
// than the soft quit again, cancel the pending confirmation.
if (ed.QuitConfirmPending() && id != CommandId::Quit && id != CommandId::KPrefix) {
@@ -3324,17 +3633,17 @@ Execute(Editor &ed, CommandId id, const std::string &arg, int count)
CommandId::CopyRegion && id != CommandId::DeleteWordPrev && id != CommandId::DeleteWordNext) {
ed.SetKillChain(false);
}
- // If buffer is read-only, block mutating commands outside of prompts
- if (!ed.PromptActive()) {
- Buffer *b = ed.CurrentBuffer();
- if (b && b->IsReadOnly() && is_mutating_command(id)) {
- ed.SetStatus("Read-only buffer");
- return true; // treated as handled, but no change
- }
- }
+ // If buffer is read-only, block mutating commands outside of prompts
+ if (!ed.PromptActive()) {
+ Buffer *b = ed.CurrentBuffer();
+ if (b && b->IsReadOnly() && is_mutating_command(id)) {
+ ed.SetStatus("Read-only buffer");
+ return true; // treated as handled, but no change
+ }
+ }
- CommandContext ctx{ed, arg, count};
- return cmd->handler ? cmd->handler(ctx) : false;
+ CommandContext ctx{ed, arg, count};
+ return cmd->handler ? cmd->handler(ctx) : false;
}
diff --git a/Command.h b/Command.h
index 5d2bdd4..e08f85e 100644
--- a/Command.h
+++ b/Command.h
@@ -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;
};
diff --git a/Editor.h b/Editor.h
index dddd6a2..9ab8fe3 100644
--- a/Editor.h
+++ b/Editor.h
@@ -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
diff --git a/GUIConfig.cc b/GUIConfig.cc
index 6a4a0a9..4dd5651 100644
--- a/GUIConfig.cc
+++ b/GUIConfig.cc
@@ -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;
}
}
diff --git a/GUIConfig.h b/GUIConfig.h
index f1961e6..f43e97f 100644
--- a/GUIConfig.h
+++ b/GUIConfig.h
@@ -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();
diff --git a/GUIFrontend.cc b/GUIFrontend.cc
index b5484e6..560521f 100644
--- a/GUIFrontend.cc
+++ b/GUIFrontend.cc
@@ -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(columns * font_size);
- int h = static_cast((rows * 2) * font_size);
+ int w = cfg.columns * static_cast(cfg.font_size);
+ int h = cfg.rows * static_cast(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::floor(avail_h / line_h));
+ auto content_rows = static_cast(std::floor(avail_h / line_h));
// Editor::Rows includes the status line; add 1 back for it.
std::size_t rows = std::max(1, content_rows + 1);
std::size_t cols = static_cast(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();
diff --git a/GUIFrontend.h b/GUIFrontend.h
index 2860a80..0ad7ef9 100644
--- a/GUIFrontend.h
+++ b/GUIFrontend.h
@@ -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_{};
diff --git a/GUIInputHandler.cc b/GUIInputHandler.cc
index 8acf046..3b75a41 100644
--- a/GUIInputHandler.cc
+++ b/GUIInputHandler.cc
@@ -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) {
diff --git a/GUIRenderer.cc b/GUIRenderer.cc
index d70adc1..022ccb8 100644
--- a/GUIRenderer.cc
+++ b/GUIRenderer.cc
@@ -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(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(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(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(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(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(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::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(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::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(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(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(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> 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(m.position());
- std::size_t ex = sx + static_cast(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(vx0) * space_w, line_pos.y);
- ImVec2 p1 = ImVec2(line_pos.x + static_cast(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 > 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(m.position());
+ std::size_t ex = sx + static_cast(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(vx0) * space_w, line_pos.y);
+ ImVec2 p1 = ImVec2(line_pos.x + static_cast(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(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(idx1));
- left += "/";
- left += std::to_string(static_cast(total));
- left += "] ";
- }
- }
- left += fname;
- if (buf->Dirty())
- left += " *";
- // Append total line count as "L"
- {
- unsigned long lcount = static_cast(buf->Rows().size());
- left += " ";
- left += std::to_string(lcount);
- left += "L";
- }
-
- // Build right text (cursor/mark)
- int row1 = static_cast(buf->Cury()) + 1;
- int col1 = static_cast(buf->Curx()) + 1;
- bool have_mark = buf->MarkSet();
- int mrow1 = have_mark ? static_cast(buf->MarkCury()) + 1 : 0;
- int mcol1 = have_mark ? static_cast(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(
+ 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(idx1));
+ left += "/";
+ left += std::to_string(static_cast(total));
+ left += "] ";
+ }
+ }
+ left += fname;
+ if (buf->Dirty())
+ left += " *";
+ // Append total line count as "L"
+ {
+ unsigned long lcount = static_cast(buf->Rows().size());
+ left += " ";
+ left += std::to_string(lcount);
+ left += "L";
+ }
+
+ // Build right text (cursor/mark)
+ int row1 = static_cast(buf->Cury()) + 1;
+ int col1 = static_cast(buf->Curx()) + 1;
+ bool have_mark = buf->MarkSet();
+ int mrow1 = have_mark ? static_cast(buf->MarkCury()) + 1 : 0;
+ int mcol1 = have_mark ? static_cast(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();
diff --git a/GUITheme.h b/GUITheme.h
index 532c0f7..e9a3770 100644
--- a/GUITheme.h
+++ b/GUITheme.h
@@ -1,11 +1,47 @@
// GUITheme.h - ImGui theme configuration for kte GUI
-// Provides a Nord-inspired color palette and style settings.
+// Provides theme palettes and style settings (Nord + Gruvbox variants).
#pragma once
#include
+#include
+#include
+#include
+#include
namespace kte {
+// Theme identifiers (legacy API kept for compatibility)
+enum class ThemeId {
+ Nord,
+ GruvboxDarkMedium,
+ GruvboxLightMedium,
+ EInk, // monochrome e-ink style
+ Solarized, // solarized (light/dark via background)
+ Plan9, // plan9-inspired minimal theme (single acme-like palette)
+};
+
+// Background mode for themes that support light/dark variants
+enum class BackgroundMode {
+ Dark,
+ Light,
+};
+
+// Forward declaration of registry helpers
+class Theme;
+
+static inline const std::vector > &ThemeRegistry();
+
+static inline size_t ThemeIndexFromId(ThemeId id);
+
+static inline ThemeId ThemeIdFromIndex(size_t idx);
+
+// Keep track of current theme (program-wide)
+static inline ThemeId gCurrentTheme = ThemeId::Nord;
+// Background preference (defaults to Dark)
+static inline BackgroundMode gBackgroundMode = BackgroundMode::Dark;
+// Mirror index of current theme in the registry; keep it consistent with gCurrentTheme
+// Current alphabetical order: 0=eink, 1=gruvbox, 2=nord, 3=solarized
+static inline size_t gCurrentThemeIndex = ThemeIndexFromId(gCurrentTheme);
// Convert RGB hex (0xRRGGBB) to ImVec4 with optional alpha
static inline ImVec4
RGBA(unsigned int rgb, float a = 1.0f)
@@ -17,6 +53,28 @@ RGBA(unsigned int rgb, float a = 1.0f)
}
+// Helpers to set/query background mode
+static inline void
+SetBackgroundMode(BackgroundMode m)
+{
+ gBackgroundMode = m;
+}
+
+
+static inline BackgroundMode
+GetBackgroundMode()
+{
+ return gBackgroundMode;
+}
+
+
+static inline const char *
+BackgroundModeName()
+{
+ return gBackgroundMode == BackgroundMode::Light ? "light" : "dark";
+}
+
+
// Apply a Nord-inspired theme to the current ImGui style.
// Safe to call after ImGui::CreateContext().
static inline void
@@ -124,4 +182,965 @@ ApplyNordImGuiTheme()
colors[ImGuiCol_PlotHistogram] = nord13;
colors[ImGuiCol_PlotHistogramHovered] = nord12;
}
+
+
+// Apply a single Plan 9 acme-inspired theme (no light/dark variants)
+// Palette: light yellow paper, black text, thin black borders, bright blue accents.
+static inline void
+ApplyPlan9Theme()
+{
+ // Acme-like colors
+ const ImVec4 paper = RGBA(0xFFFFE8); // pale yellow paper
+ const ImVec4 pane = RGBA(0xFFF4C1); // slightly deeper for frames
+ const ImVec4 ink = RGBA(0x000000); // black text
+ const ImVec4 dim = ImVec4(0, 0, 0, 0.60f);
+ const ImVec4 border = RGBA(0x000000); // 1px black
+ const ImVec4 blue = RGBA(0x0064FF); // acme-ish blue accents
+ const ImVec4 blueH = RGBA(0x4C8DFF); // hover/active
+
+ ImGuiStyle &style = ImGui::GetStyle();
+ style.WindowPadding = ImVec2(6.0f, 6.0f);
+ style.FramePadding = ImVec2(5.0f, 3.0f);
+ style.CellPadding = ImVec2(5.0f, 3.0f);
+ style.ItemSpacing = ImVec2(6.0f, 6.0f);
+ style.ItemInnerSpacing = ImVec2(6.0f, 4.0f);
+ style.ScrollbarSize = 14.0f;
+ style.GrabMinSize = 10.0f;
+ style.WindowRounding = 0.0f;
+ style.FrameRounding = 0.0f;
+ style.PopupRounding = 0.0f;
+ style.GrabRounding = 0.0f;
+ style.TabRounding = 0.0f;
+ style.WindowBorderSize = 1.0f;
+ style.FrameBorderSize = 1.0f;
+
+ ImVec4 *c = style.Colors;
+ c[ImGuiCol_Text] = ink;
+ c[ImGuiCol_TextDisabled] = dim;
+ c[ImGuiCol_WindowBg] = paper;
+ c[ImGuiCol_ChildBg] = paper;
+ c[ImGuiCol_PopupBg] = ImVec4(pane.x, pane.y, pane.z, 0.98f);
+ c[ImGuiCol_Border] = border;
+ c[ImGuiCol_BorderShadow] = RGBA(0x000000, 0.0f);
+ c[ImGuiCol_FrameBg] = pane;
+ c[ImGuiCol_FrameBgHovered] = RGBA(0xFFEBA0);
+ c[ImGuiCol_FrameBgActive] = RGBA(0xFFE387);
+ c[ImGuiCol_TitleBg] = pane;
+ c[ImGuiCol_TitleBgActive] = RGBA(0xFFE8A6);
+ c[ImGuiCol_TitleBgCollapsed] = pane;
+ c[ImGuiCol_MenuBarBg] = pane;
+ c[ImGuiCol_ScrollbarBg] = paper;
+ c[ImGuiCol_ScrollbarGrab] = RGBA(0xEADFA5);
+ c[ImGuiCol_ScrollbarGrabHovered] = RGBA(0xE2D37F);
+ c[ImGuiCol_ScrollbarGrabActive] = RGBA(0xD8C757);
+ c[ImGuiCol_CheckMark] = blue;
+ c[ImGuiCol_SliderGrab] = blue;
+ c[ImGuiCol_SliderGrabActive] = blueH;
+ c[ImGuiCol_Button] = RGBA(0xFFF1B0);
+ c[ImGuiCol_ButtonHovered] = RGBA(0xFFE892);
+ c[ImGuiCol_ButtonActive] = RGBA(0xFFE072);
+ c[ImGuiCol_Header] = RGBA(0xFFF1B0);
+ c[ImGuiCol_HeaderHovered] = RGBA(0xFFE892);
+ c[ImGuiCol_HeaderActive] = RGBA(0xFFE072);
+ c[ImGuiCol_Separator] = border;
+ c[ImGuiCol_SeparatorHovered] = blue;
+ c[ImGuiCol_SeparatorActive] = blueH;
+ c[ImGuiCol_ResizeGrip] = ImVec4(0, 0, 0, 0.12f);
+ c[ImGuiCol_ResizeGripHovered] = ImVec4(blue.x, blue.y, blue.z, 0.67f);
+ c[ImGuiCol_ResizeGripActive] = blueH;
+ c[ImGuiCol_Tab] = RGBA(0xFFE8A6);
+ c[ImGuiCol_TabHovered] = RGBA(0xFFE072);
+ c[ImGuiCol_TabActive] = RGBA(0xFFD859);
+ c[ImGuiCol_TabUnfocused] = RGBA(0xFFE8A6);
+ c[ImGuiCol_TabUnfocusedActive] = RGBA(0xFFD859);
+ c[ImGuiCol_TableHeaderBg] = RGBA(0xFFE8A6);
+ c[ImGuiCol_TableBorderStrong] = border;
+ c[ImGuiCol_TableBorderLight] = ImVec4(0, 0, 0, 0.35f);
+ c[ImGuiCol_TableRowBg] = ImVec4(0, 0, 0, 0.04f);
+ c[ImGuiCol_TableRowBgAlt] = ImVec4(0, 0, 0, 0.08f);
+ c[ImGuiCol_TextSelectedBg] = ImVec4(blueH.x, blueH.y, blueH.z, 0.35f);
+ c[ImGuiCol_DragDropTarget] = blue;
+ c[ImGuiCol_NavHighlight] = blue;
+ c[ImGuiCol_NavWindowingHighlight] = ImVec4(0, 0, 0, 0.20f);
+ c[ImGuiCol_NavWindowingDimBg] = ImVec4(0, 0, 0, 0.20f);
+ c[ImGuiCol_ModalWindowDimBg] = ImVec4(0, 0, 0, 0.20f);
+ c[ImGuiCol_PlotLines] = blue;
+ c[ImGuiCol_PlotLinesHovered] = blueH;
+ c[ImGuiCol_PlotHistogram] = blue;
+ c[ImGuiCol_PlotHistogramHovered] = blueH;
+}
+
+
+// Apply Solarized (Dark)
+static inline void
+ApplySolarizedDarkTheme()
+{
+ // Base colors from Ethan Schoonover Solarized
+ const ImVec4 base03 = RGBA(0x002b36);
+ const ImVec4 base02 = RGBA(0x073642);
+ const ImVec4 base01 = RGBA(0x586e75);
+ const ImVec4 base00 = RGBA(0x657b83);
+ const ImVec4 base0 = RGBA(0x839496);
+ const ImVec4 base1 = RGBA(0x93a1a1);
+ const ImVec4 base2 = RGBA(0xeee8d5);
+ const ImVec4 yellow = RGBA(0xb58900);
+ const ImVec4 orange = RGBA(0xcb4b16);
+ const ImVec4 blue = RGBA(0x268bd2);
+ const ImVec4 cyan = RGBA(0x2aa198);
+ // Note: red, magenta, violet, green and base3 are intentionally omitted until used
+
+ ImGuiStyle &style = ImGui::GetStyle();
+ style.WindowPadding = ImVec2(8.0f, 8.0f);
+ style.FramePadding = ImVec2(6.0f, 4.0f);
+ style.CellPadding = ImVec2(6.0f, 4.0f);
+ style.ItemSpacing = ImVec2(6.0f, 6.0f);
+ style.ItemInnerSpacing = ImVec2(6.0f, 4.0f);
+ style.ScrollbarSize = 14.0f;
+ style.GrabMinSize = 10.0f;
+ style.WindowRounding = 3.0f;
+ style.FrameRounding = 3.0f;
+ style.PopupRounding = 3.0f;
+ style.GrabRounding = 3.0f;
+ style.TabRounding = 3.0f;
+ style.WindowBorderSize = 1.0f;
+ style.FrameBorderSize = 1.0f;
+
+ ImVec4 *c = style.Colors;
+ c[ImGuiCol_Text] = base0;
+ c[ImGuiCol_TextDisabled] = ImVec4(base01.x, base01.y, base01.z, 1.0f);
+ c[ImGuiCol_WindowBg] = base03;
+ c[ImGuiCol_ChildBg] = base03;
+ c[ImGuiCol_PopupBg] = ImVec4(base02.x, base02.y, base02.z, 0.98f);
+ c[ImGuiCol_Border] = base02;
+ c[ImGuiCol_BorderShadow] = RGBA(0x000000, 0.0f);
+ c[ImGuiCol_FrameBg] = base02;
+ c[ImGuiCol_FrameBgHovered] = base01;
+ c[ImGuiCol_FrameBgActive] = base00;
+ c[ImGuiCol_TitleBg] = base02;
+ c[ImGuiCol_TitleBgActive] = base01;
+ c[ImGuiCol_TitleBgCollapsed] = base02;
+ c[ImGuiCol_MenuBarBg] = base02;
+ c[ImGuiCol_ScrollbarBg] = base02;
+ c[ImGuiCol_ScrollbarGrab] = base01;
+ c[ImGuiCol_ScrollbarGrabHovered] = base00;
+ c[ImGuiCol_ScrollbarGrabActive] = blue;
+ c[ImGuiCol_CheckMark] = cyan;
+ c[ImGuiCol_SliderGrab] = cyan;
+ c[ImGuiCol_SliderGrabActive] = blue;
+ c[ImGuiCol_Button] = base01;
+ c[ImGuiCol_ButtonHovered] = base00;
+ c[ImGuiCol_ButtonActive] = blue;
+ c[ImGuiCol_Header] = base01;
+ c[ImGuiCol_HeaderHovered] = base00;
+ c[ImGuiCol_HeaderActive] = base00;
+ c[ImGuiCol_Separator] = base01;
+ c[ImGuiCol_SeparatorHovered] = base00;
+ c[ImGuiCol_SeparatorActive] = blue;
+ c[ImGuiCol_ResizeGrip] = ImVec4(base1.x, base1.y, base1.z, 0.12f);
+ c[ImGuiCol_ResizeGripHovered] = ImVec4(cyan.x, cyan.y, cyan.z, 0.67f);
+ c[ImGuiCol_ResizeGripActive] = blue;
+ c[ImGuiCol_Tab] = base01;
+ c[ImGuiCol_TabHovered] = base00;
+ c[ImGuiCol_TabActive] = base02;
+ c[ImGuiCol_TabUnfocused] = base01;
+ c[ImGuiCol_TabUnfocusedActive] = base02;
+ c[ImGuiCol_TableHeaderBg] = base01;
+ c[ImGuiCol_TableBorderStrong] = base00;
+ c[ImGuiCol_TableBorderLight] = ImVec4(base00.x, base00.y, base00.z, 0.6f);
+ c[ImGuiCol_TableRowBg] = ImVec4(base02.x, base02.y, base02.z, 0.2f);
+ c[ImGuiCol_TableRowBgAlt] = ImVec4(base02.x, base02.y, base02.z, 0.35f);
+ c[ImGuiCol_TextSelectedBg] = ImVec4(cyan.x, cyan.y, cyan.z, 0.30f);
+ c[ImGuiCol_DragDropTarget] = yellow;
+ c[ImGuiCol_NavHighlight] = blue;
+ c[ImGuiCol_NavWindowingHighlight] = ImVec4(base2.x, base2.y, base2.z, 0.70f);
+ c[ImGuiCol_NavWindowingDimBg] = ImVec4(base03.x, base03.y, base03.z, 0.60f);
+ c[ImGuiCol_ModalWindowDimBg] = ImVec4(base03.x, base03.y, base03.z, 0.60f);
+ c[ImGuiCol_PlotLines] = cyan;
+ c[ImGuiCol_PlotLinesHovered] = blue;
+ c[ImGuiCol_PlotHistogram] = yellow;
+ c[ImGuiCol_PlotHistogramHovered] = orange;
+}
+
+
+// Apply Solarized (Light)
+static inline void
+ApplySolarizedLightTheme()
+{
+ // Swap base shades for light mode
+ const ImVec4 base03 = RGBA(0xfdf6e3);
+ const ImVec4 base02 = RGBA(0xeee8d5);
+ // base01/base00 not currently used in light variant
+ const ImVec4 base0 = RGBA(0x657b83);
+ const ImVec4 base1 = RGBA(0x586e75);
+ const ImVec4 base2 = RGBA(0x073642);
+ const ImVec4 yellow = RGBA(0xb58900);
+ const ImVec4 orange = RGBA(0xcb4b16);
+ const ImVec4 blue = RGBA(0x268bd2);
+ const ImVec4 cyan = RGBA(0x2aa198);
+ // Note: red, magenta, violet, green and base3 are intentionally omitted until used
+
+ ImGuiStyle &style = ImGui::GetStyle();
+ style.WindowPadding = ImVec2(8.0f, 8.0f);
+ style.FramePadding = ImVec2(6.0f, 4.0f);
+ style.CellPadding = ImVec2(6.0f, 4.0f);
+ style.ItemSpacing = ImVec2(6.0f, 6.0f);
+ style.ItemInnerSpacing = ImVec2(6.0f, 4.0f);
+ style.ScrollbarSize = 14.0f;
+ style.GrabMinSize = 10.0f;
+ style.WindowRounding = 3.0f;
+ style.FrameRounding = 3.0f;
+ style.PopupRounding = 3.0f;
+ style.GrabRounding = 3.0f;
+ style.TabRounding = 3.0f;
+ style.WindowBorderSize = 1.0f;
+ style.FrameBorderSize = 1.0f;
+
+ ImVec4 *c = style.Colors;
+ c[ImGuiCol_Text] = base0;
+ c[ImGuiCol_TextDisabled] = ImVec4(base1.x, base1.y, base1.z, 1.0f);
+ c[ImGuiCol_WindowBg] = base03;
+ c[ImGuiCol_ChildBg] = base03;
+ c[ImGuiCol_PopupBg] = ImVec4(base02.x, base02.y, base02.z, 0.98f);
+ c[ImGuiCol_Border] = base02;
+ c[ImGuiCol_BorderShadow] = RGBA(0x000000, 0.0f);
+ c[ImGuiCol_FrameBg] = base02;
+ c[ImGuiCol_FrameBgHovered] = base1;
+ c[ImGuiCol_FrameBgActive] = base0;
+ c[ImGuiCol_TitleBg] = base02;
+ c[ImGuiCol_TitleBgActive] = base1;
+ c[ImGuiCol_TitleBgCollapsed] = base02;
+ c[ImGuiCol_MenuBarBg] = base02;
+ c[ImGuiCol_ScrollbarBg] = base02;
+ c[ImGuiCol_ScrollbarGrab] = base1;
+ c[ImGuiCol_ScrollbarGrabHovered] = base0;
+ c[ImGuiCol_ScrollbarGrabActive] = blue;
+ c[ImGuiCol_CheckMark] = cyan;
+ c[ImGuiCol_SliderGrab] = cyan;
+ c[ImGuiCol_SliderGrabActive] = blue;
+ c[ImGuiCol_Button] = base1;
+ c[ImGuiCol_ButtonHovered] = base0;
+ c[ImGuiCol_ButtonActive] = blue;
+ c[ImGuiCol_Header] = base1;
+ c[ImGuiCol_HeaderHovered] = base0;
+ c[ImGuiCol_HeaderActive] = base0;
+ c[ImGuiCol_Separator] = base1;
+ c[ImGuiCol_SeparatorHovered] = base0;
+ c[ImGuiCol_SeparatorActive] = blue;
+ c[ImGuiCol_ResizeGrip] = ImVec4(base1.x, base1.y, base1.z, 0.12f);
+ c[ImGuiCol_ResizeGripHovered] = ImVec4(cyan.x, cyan.y, cyan.z, 0.67f);
+ c[ImGuiCol_ResizeGripActive] = blue;
+ c[ImGuiCol_Tab] = base1;
+ c[ImGuiCol_TabHovered] = base0;
+ c[ImGuiCol_TabActive] = base2;
+ c[ImGuiCol_TabUnfocused] = base1;
+ c[ImGuiCol_TabUnfocusedActive] = base2;
+ c[ImGuiCol_TableHeaderBg] = base1;
+ c[ImGuiCol_TableBorderStrong] = base0;
+ c[ImGuiCol_TableBorderLight] = ImVec4(base0.x, base0.y, base0.z, 0.6f);
+ c[ImGuiCol_TableRowBg] = ImVec4(base02.x, base02.y, base02.z, 0.2f);
+ c[ImGuiCol_TableRowBgAlt] = ImVec4(base02.x, base02.y, base02.z, 0.35f);
+ c[ImGuiCol_TextSelectedBg] = ImVec4(cyan.x, cyan.y, cyan.z, 0.30f);
+ c[ImGuiCol_DragDropTarget] = yellow;
+ c[ImGuiCol_NavHighlight] = blue;
+ c[ImGuiCol_NavWindowingHighlight] = ImVec4(base2.x, base2.y, base2.z, 0.70f);
+ c[ImGuiCol_NavWindowingDimBg] = ImVec4(base03.x, base03.y, base03.z, 0.60f);
+ c[ImGuiCol_ModalWindowDimBg] = ImVec4(base03.x, base03.y, base03.z, 0.60f);
+ c[ImGuiCol_PlotLines] = cyan;
+ c[ImGuiCol_PlotLinesHovered] = blue;
+ c[ImGuiCol_PlotHistogram] = yellow;
+ c[ImGuiCol_PlotHistogramHovered] = orange;
+}
+
+
+// Apply Gruvbox Dark (medium contrast) theme to the current ImGui style
+static inline void
+ApplyGruvboxDarkMediumTheme()
+{
+ // Gruvbox (dark, medium) palette
+ const ImVec4 bg0 = RGBA(0x282828); // dark0
+ const ImVec4 bg1 = RGBA(0x3C3836); // dark1
+ const ImVec4 bg2 = RGBA(0x504945); // dark2
+ const ImVec4 bg3 = RGBA(0x665C54); // dark3
+ const ImVec4 fg1 = RGBA(0xEBDBB2); // light1
+ const ImVec4 fg0 = RGBA(0xFBF1C7); // light0
+ // accent colors (selected subset used throughout)
+ const ImVec4 yellow = RGBA(0xFABD2F);
+ const ImVec4 blue = RGBA(0x83A598);
+ const ImVec4 aqua = RGBA(0x8EC07C);
+ const ImVec4 orange = RGBA(0xFE8019);
+
+ ImGuiStyle &style = ImGui::GetStyle();
+ style.WindowPadding = ImVec2(8.0f, 8.0f);
+ style.FramePadding = ImVec2(6.0f, 4.0f);
+ style.CellPadding = ImVec2(6.0f, 4.0f);
+ style.ItemSpacing = ImVec2(6.0f, 6.0f);
+ style.ItemInnerSpacing = ImVec2(6.0f, 4.0f);
+ style.ScrollbarSize = 14.0f;
+ style.GrabMinSize = 10.0f;
+ style.WindowRounding = 4.0f;
+ style.FrameRounding = 3.0f;
+ style.PopupRounding = 4.0f;
+ style.GrabRounding = 3.0f;
+ style.TabRounding = 4.0f;
+ style.WindowBorderSize = 1.0f;
+ style.FrameBorderSize = 1.0f;
+
+ ImVec4 *colors = style.Colors;
+ colors[ImGuiCol_Text] = fg1;
+ colors[ImGuiCol_TextDisabled] = ImVec4(fg1.x, fg1.y, fg1.z, 0.55f);
+ colors[ImGuiCol_WindowBg] = bg0;
+ colors[ImGuiCol_ChildBg] = bg0;
+ colors[ImGuiCol_PopupBg] = ImVec4(bg1.x, bg1.y, bg1.z, 0.98f);
+ colors[ImGuiCol_Border] = bg2;
+ colors[ImGuiCol_BorderShadow] = RGBA(0x000000, 0.0f);
+
+ colors[ImGuiCol_FrameBg] = bg2;
+ colors[ImGuiCol_FrameBgHovered] = bg3;
+ colors[ImGuiCol_FrameBgActive] = bg1;
+
+ colors[ImGuiCol_TitleBg] = bg1;
+ colors[ImGuiCol_TitleBgActive] = bg2;
+ colors[ImGuiCol_TitleBgCollapsed] = bg1;
+
+ colors[ImGuiCol_MenuBarBg] = bg1;
+ colors[ImGuiCol_ScrollbarBg] = bg0;
+ colors[ImGuiCol_ScrollbarGrab] = bg3;
+ colors[ImGuiCol_ScrollbarGrabHovered] = bg2;
+ colors[ImGuiCol_ScrollbarGrabActive] = bg1;
+
+ colors[ImGuiCol_CheckMark] = aqua;
+ colors[ImGuiCol_SliderGrab] = aqua;
+ colors[ImGuiCol_SliderGrabActive] = blue;
+
+ colors[ImGuiCol_Button] = bg3;
+ colors[ImGuiCol_ButtonHovered] = bg2;
+ colors[ImGuiCol_ButtonActive] = bg1;
+
+ colors[ImGuiCol_Header] = bg3;
+ colors[ImGuiCol_HeaderHovered] = bg2;
+ colors[ImGuiCol_HeaderActive] = bg2;
+
+ colors[ImGuiCol_Separator] = bg2;
+ colors[ImGuiCol_SeparatorHovered] = bg1;
+ colors[ImGuiCol_SeparatorActive] = blue;
+
+ colors[ImGuiCol_ResizeGrip] = ImVec4(fg0.x, fg0.y, fg0.z, 0.12f);
+ colors[ImGuiCol_ResizeGripHovered] = ImVec4(aqua.x, aqua.y, aqua.z, 0.67f);
+ colors[ImGuiCol_ResizeGripActive] = blue;
+
+ colors[ImGuiCol_Tab] = bg2;
+ colors[ImGuiCol_TabHovered] = bg1;
+ colors[ImGuiCol_TabActive] = bg3;
+ colors[ImGuiCol_TabUnfocused] = bg2;
+ colors[ImGuiCol_TabUnfocusedActive] = bg3;
+
+ colors[ImGuiCol_TableHeaderBg] = bg2;
+ colors[ImGuiCol_TableBorderStrong] = bg1;
+ colors[ImGuiCol_TableBorderLight] = ImVec4(bg1.x, bg1.y, bg1.z, 0.6f);
+ colors[ImGuiCol_TableRowBg] = ImVec4(bg1.x, bg1.y, bg1.z, 0.2f);
+ colors[ImGuiCol_TableRowBgAlt] = ImVec4(bg1.x, bg1.y, bg1.z, 0.35f);
+
+ colors[ImGuiCol_TextSelectedBg] = ImVec4(aqua.x, aqua.y, aqua.z, 0.30f);
+ colors[ImGuiCol_DragDropTarget] = yellow;
+ colors[ImGuiCol_NavHighlight] = blue;
+ colors[ImGuiCol_NavWindowingHighlight] = ImVec4(fg0.x, fg0.y, fg0.z, 0.70f);
+ colors[ImGuiCol_NavWindowingDimBg] = ImVec4(bg0.x, bg0.y, bg0.z, 0.60f);
+ colors[ImGuiCol_ModalWindowDimBg] = ImVec4(bg0.x, bg0.y, bg0.z, 0.60f);
+
+ colors[ImGuiCol_PlotLines] = aqua;
+ colors[ImGuiCol_PlotLinesHovered] = blue;
+ colors[ImGuiCol_PlotHistogram] = yellow;
+ colors[ImGuiCol_PlotHistogramHovered] = orange;
+}
+
+
+// Apply Gruvbox Light (medium contrast) theme to the current ImGui style
+static inline void
+ApplyGruvboxLightMediumTheme()
+{
+ // Gruvbox (light, medium) palette
+ const ImVec4 bg0 = RGBA(0xFBF1C7); // light0
+ const ImVec4 bg1 = RGBA(0xEBDBB2); // light1
+ const ImVec4 bg2 = RGBA(0xD5C4A1); // light2
+ const ImVec4 bg3 = RGBA(0xBDAE93); // light3
+ const ImVec4 fg1 = RGBA(0x3C3836); // dark1 (text)
+ const ImVec4 fg0 = RGBA(0x282828); // dark0
+ // accent colors (selected subset used throughout)
+ const ImVec4 yellow = RGBA(0xB57614);
+ const ImVec4 blue = RGBA(0x076678);
+ const ImVec4 aqua = RGBA(0x427B58);
+ const ImVec4 orange = RGBA(0xAF3A03);
+
+ ImGuiStyle &style = ImGui::GetStyle();
+ style.WindowPadding = ImVec2(8.0f, 8.0f);
+ style.FramePadding = ImVec2(6.0f, 4.0f);
+ style.CellPadding = ImVec2(6.0f, 4.0f);
+ style.ItemSpacing = ImVec2(6.0f, 6.0f);
+ style.ItemInnerSpacing = ImVec2(6.0f, 4.0f);
+ style.ScrollbarSize = 14.0f;
+ style.GrabMinSize = 10.0f;
+ style.WindowRounding = 4.0f;
+ style.FrameRounding = 3.0f;
+ style.PopupRounding = 4.0f;
+ style.GrabRounding = 3.0f;
+ style.TabRounding = 4.0f;
+ style.WindowBorderSize = 1.0f;
+ style.FrameBorderSize = 1.0f;
+
+ ImVec4 *colors = style.Colors;
+ colors[ImGuiCol_Text] = fg1;
+ colors[ImGuiCol_TextDisabled] = ImVec4(fg1.x, fg1.y, fg1.z, 0.55f);
+ colors[ImGuiCol_WindowBg] = bg0;
+ colors[ImGuiCol_ChildBg] = bg0;
+ colors[ImGuiCol_PopupBg] = ImVec4(bg1.x, bg1.y, bg1.z, 0.98f);
+ colors[ImGuiCol_Border] = bg2;
+ colors[ImGuiCol_BorderShadow] = RGBA(0x000000, 0.0f);
+
+ colors[ImGuiCol_FrameBg] = bg2;
+ colors[ImGuiCol_FrameBgHovered] = bg3;
+ colors[ImGuiCol_FrameBgActive] = bg1;
+
+ colors[ImGuiCol_TitleBg] = bg1;
+ colors[ImGuiCol_TitleBgActive] = bg2;
+ colors[ImGuiCol_TitleBgCollapsed] = bg1;
+
+ colors[ImGuiCol_MenuBarBg] = bg1;
+ colors[ImGuiCol_ScrollbarBg] = bg0;
+ colors[ImGuiCol_ScrollbarGrab] = bg3;
+ colors[ImGuiCol_ScrollbarGrabHovered] = bg2;
+ colors[ImGuiCol_ScrollbarGrabActive] = bg1;
+
+ colors[ImGuiCol_CheckMark] = aqua;
+ colors[ImGuiCol_SliderGrab] = aqua;
+ colors[ImGuiCol_SliderGrabActive] = blue;
+
+ colors[ImGuiCol_Button] = bg3;
+ colors[ImGuiCol_ButtonHovered] = bg2;
+ colors[ImGuiCol_ButtonActive] = bg1;
+
+ colors[ImGuiCol_Header] = bg3;
+ colors[ImGuiCol_HeaderHovered] = bg2;
+ colors[ImGuiCol_HeaderActive] = bg2;
+
+ colors[ImGuiCol_Separator] = bg2;
+ colors[ImGuiCol_SeparatorHovered] = bg1;
+ colors[ImGuiCol_SeparatorActive] = blue;
+
+ colors[ImGuiCol_ResizeGrip] = ImVec4(fg0.x, fg0.y, fg0.z, 0.12f);
+ colors[ImGuiCol_ResizeGripHovered] = ImVec4(aqua.x, aqua.y, aqua.z, 0.67f);
+ colors[ImGuiCol_ResizeGripActive] = blue;
+
+ colors[ImGuiCol_Tab] = bg2;
+ colors[ImGuiCol_TabHovered] = bg1;
+ colors[ImGuiCol_TabActive] = bg3;
+ colors[ImGuiCol_TabUnfocused] = bg2;
+ colors[ImGuiCol_TabUnfocusedActive] = bg3;
+
+ colors[ImGuiCol_TableHeaderBg] = bg2;
+ colors[ImGuiCol_TableBorderStrong] = bg1;
+ colors[ImGuiCol_TableBorderLight] = ImVec4(bg1.x, bg1.y, bg1.z, 0.6f);
+ colors[ImGuiCol_TableRowBg] = ImVec4(bg1.x, bg1.y, bg1.z, 0.2f);
+ colors[ImGuiCol_TableRowBgAlt] = ImVec4(bg1.x, bg1.y, bg1.z, 0.35f);
+
+ colors[ImGuiCol_TextSelectedBg] = ImVec4(aqua.x, aqua.y, aqua.z, 0.30f);
+ colors[ImGuiCol_DragDropTarget] = yellow;
+ colors[ImGuiCol_NavHighlight] = blue;
+ colors[ImGuiCol_NavWindowingHighlight] = ImVec4(fg0.x, fg0.y, fg0.z, 0.70f);
+ colors[ImGuiCol_NavWindowingDimBg] = ImVec4(bg0.x, bg0.y, bg0.z, 0.60f);
+ colors[ImGuiCol_ModalWindowDimBg] = ImVec4(bg0.x, bg0.y, bg0.z, 0.60f);
+
+ colors[ImGuiCol_PlotLines] = aqua;
+ colors[ImGuiCol_PlotLinesHovered] = blue;
+ colors[ImGuiCol_PlotHistogram] = yellow;
+ colors[ImGuiCol_PlotHistogramHovered] = orange;
+}
+
+
+// Apply a monochrome e-ink inspired theme (paper-like background, near-black text)
+static inline void
+ApplyEInkImGuiTheme()
+{
+ // E-Ink grayscale palette
+ const ImVec4 paper = RGBA(0xF2F2EE); // light paper
+ const ImVec4 bg1 = RGBA(0xE6E6E2);
+ const ImVec4 bg2 = RGBA(0xDADAD5);
+ const ImVec4 bg3 = RGBA(0xCFCFCA);
+ const ImVec4 ink = RGBA(0x111111); // primary text (near black)
+ const ImVec4 dim = RGBA(0x666666); // disabled text
+ const ImVec4 border = RGBA(0xB8B8B3);
+ const ImVec4 accent = RGBA(0x222222); // controls/active
+
+ ImGuiStyle &style = ImGui::GetStyle();
+ // Flatter visuals: minimal rounding, subtle borders
+ style.WindowPadding = ImVec2(8.0f, 8.0f);
+ style.FramePadding = ImVec2(6.0f, 4.0f);
+ style.CellPadding = ImVec2(6.0f, 4.0f);
+ style.ItemSpacing = ImVec2(6.0f, 6.0f);
+ style.ItemInnerSpacing = ImVec2(6.0f, 4.0f);
+ style.ScrollbarSize = 14.0f;
+ style.GrabMinSize = 10.0f;
+ style.WindowRounding = 0.0f;
+ style.FrameRounding = 0.0f;
+ style.PopupRounding = 0.0f;
+ style.GrabRounding = 0.0f;
+ style.TabRounding = 0.0f;
+ style.WindowBorderSize = 1.0f;
+ style.FrameBorderSize = 1.0f;
+
+ ImVec4 *colors = style.Colors;
+
+ colors[ImGuiCol_Text] = ink;
+ colors[ImGuiCol_TextDisabled] = ImVec4(dim.x, dim.y, dim.z, 1.0f);
+ colors[ImGuiCol_WindowBg] = paper;
+ colors[ImGuiCol_ChildBg] = paper;
+ colors[ImGuiCol_PopupBg] = ImVec4(bg1.x, bg1.y, bg1.z, 0.98f);
+ colors[ImGuiCol_Border] = border;
+ colors[ImGuiCol_BorderShadow] = RGBA(0x000000, 0.0f);
+
+ colors[ImGuiCol_FrameBg] = bg2;
+ colors[ImGuiCol_FrameBgHovered] = bg3;
+ colors[ImGuiCol_FrameBgActive] = bg1;
+
+ colors[ImGuiCol_TitleBg] = bg1;
+ colors[ImGuiCol_TitleBgActive] = bg2;
+ colors[ImGuiCol_TitleBgCollapsed] = bg1;
+
+ colors[ImGuiCol_MenuBarBg] = bg1;
+ colors[ImGuiCol_ScrollbarBg] = paper;
+ colors[ImGuiCol_ScrollbarGrab] = bg3;
+ colors[ImGuiCol_ScrollbarGrabHovered] = bg2;
+ colors[ImGuiCol_ScrollbarGrabActive] = bg1;
+
+ colors[ImGuiCol_CheckMark] = accent;
+ colors[ImGuiCol_SliderGrab] = accent;
+ colors[ImGuiCol_SliderGrabActive] = ink;
+
+ colors[ImGuiCol_Button] = bg3;
+ colors[ImGuiCol_ButtonHovered] = bg2;
+ colors[ImGuiCol_ButtonActive] = bg1;
+
+ colors[ImGuiCol_Header] = bg3;
+ colors[ImGuiCol_HeaderHovered] = bg2;
+ colors[ImGuiCol_HeaderActive] = bg2;
+
+ colors[ImGuiCol_Separator] = border;
+ colors[ImGuiCol_SeparatorHovered] = bg2;
+ colors[ImGuiCol_SeparatorActive] = accent;
+
+ colors[ImGuiCol_ResizeGrip] = ImVec4(ink.x, ink.y, ink.z, 0.12f);
+ colors[ImGuiCol_ResizeGripHovered] = ImVec4(accent.x, accent.y, accent.z, 0.50f);
+ colors[ImGuiCol_ResizeGripActive] = ink;
+
+ colors[ImGuiCol_Tab] = bg2;
+ colors[ImGuiCol_TabHovered] = bg1;
+ colors[ImGuiCol_TabActive] = bg3;
+ colors[ImGuiCol_TabUnfocused] = bg2;
+ colors[ImGuiCol_TabUnfocusedActive] = bg3;
+
+ colors[ImGuiCol_TableHeaderBg] = bg2;
+ colors[ImGuiCol_TableBorderStrong] = bg1;
+ colors[ImGuiCol_TableBorderLight] = ImVec4(bg1.x, bg1.y, bg1.z, 0.6f);
+ colors[ImGuiCol_TableRowBg] = ImVec4(bg1.x, bg1.y, bg1.z, 0.20f);
+ colors[ImGuiCol_TableRowBgAlt] = ImVec4(bg1.x, bg1.y, bg1.z, 0.35f);
+
+ // Selection should remain readable with black text; use a light mid-gray
+ colors[ImGuiCol_TextSelectedBg] = ImVec4(0.74f, 0.74f, 0.72f, 0.65f); // ~#BDBDB8
+ colors[ImGuiCol_DragDropTarget] = accent;
+ colors[ImGuiCol_NavHighlight] = accent;
+ colors[ImGuiCol_NavWindowingHighlight] = ImVec4(ink.x, ink.y, ink.z, 0.70f);
+ colors[ImGuiCol_NavWindowingDimBg] = ImVec4(0.0f, 0.0f, 0.0f, 0.45f);
+ colors[ImGuiCol_ModalWindowDimBg] = ImVec4(0.0f, 0.0f, 0.0f, 0.45f);
+
+ // Plots (grayscale)
+ colors[ImGuiCol_PlotLines] = accent;
+ colors[ImGuiCol_PlotLinesHovered] = ink;
+ colors[ImGuiCol_PlotHistogram] = accent;
+ colors[ImGuiCol_PlotHistogramHovered] = ink;
+}
+
+
+// E-Ink Dark variant (for low-light; darker paper, lighter UI accents)
+static inline void
+ApplyEInkDarkImGuiTheme()
+{
+ const ImVec4 paper = RGBA(0x202020);
+ const ImVec4 bg1 = RGBA(0x2A2A2A);
+ const ImVec4 bg2 = RGBA(0x333333);
+ const ImVec4 bg3 = RGBA(0x3C3C3C);
+ const ImVec4 ink = RGBA(0xE8E8E8);
+ const ImVec4 dim = RGBA(0xA0A0A0);
+ const ImVec4 border = RGBA(0x444444);
+ const ImVec4 accent = RGBA(0xDDDDDD);
+
+ ImGuiStyle &style = ImGui::GetStyle();
+ style.WindowPadding = ImVec2(8.0f, 8.0f);
+ style.FramePadding = ImVec2(6.0f, 4.0f);
+ style.CellPadding = ImVec2(6.0f, 4.0f);
+ style.ItemSpacing = ImVec2(6.0f, 6.0f);
+ style.ItemInnerSpacing = ImVec2(6.0f, 4.0f);
+ style.ScrollbarSize = 14.0f;
+ style.GrabMinSize = 10.0f;
+ style.WindowRounding = 0.0f;
+ style.FrameRounding = 0.0f;
+ style.PopupRounding = 0.0f;
+ style.GrabRounding = 0.0f;
+ style.TabRounding = 0.0f;
+ style.WindowBorderSize = 1.0f;
+ style.FrameBorderSize = 1.0f;
+
+ ImVec4 *colors = style.Colors;
+ colors[ImGuiCol_Text] = ink;
+ colors[ImGuiCol_TextDisabled] = ImVec4(dim.x, dim.y, dim.z, 1.0f);
+ colors[ImGuiCol_WindowBg] = paper;
+ colors[ImGuiCol_ChildBg] = paper;
+ colors[ImGuiCol_PopupBg] = ImVec4(bg1.x, bg1.y, bg1.z, 0.98f);
+ colors[ImGuiCol_Border] = border;
+ colors[ImGuiCol_BorderShadow] = RGBA(0x000000, 0.0f);
+ colors[ImGuiCol_FrameBg] = bg2;
+ colors[ImGuiCol_FrameBgHovered] = bg3;
+ colors[ImGuiCol_FrameBgActive] = bg1;
+ colors[ImGuiCol_TitleBg] = bg1;
+ colors[ImGuiCol_TitleBgActive] = bg2;
+ colors[ImGuiCol_TitleBgCollapsed] = bg1;
+ colors[ImGuiCol_MenuBarBg] = bg1;
+ colors[ImGuiCol_ScrollbarBg] = paper;
+ colors[ImGuiCol_ScrollbarGrab] = bg3;
+ colors[ImGuiCol_ScrollbarGrabHovered] = bg2;
+ colors[ImGuiCol_ScrollbarGrabActive] = ink;
+ colors[ImGuiCol_CheckMark] = accent;
+ colors[ImGuiCol_SliderGrab] = accent;
+ colors[ImGuiCol_SliderGrabActive] = ink;
+ colors[ImGuiCol_Button] = bg3;
+ colors[ImGuiCol_ButtonHovered] = bg2;
+ colors[ImGuiCol_ButtonActive] = bg1;
+ colors[ImGuiCol_Header] = bg3;
+ colors[ImGuiCol_HeaderHovered] = bg2;
+ colors[ImGuiCol_HeaderActive] = bg2;
+ colors[ImGuiCol_Separator] = bg2;
+ colors[ImGuiCol_SeparatorHovered] = bg1;
+ colors[ImGuiCol_SeparatorActive] = ink;
+ colors[ImGuiCol_ResizeGrip] = ImVec4(ink.x, ink.y, ink.z, 0.12f);
+ colors[ImGuiCol_ResizeGripHovered] = ImVec4(accent.x, accent.y, accent.z, 0.67f);
+ colors[ImGuiCol_ResizeGripActive] = ink;
+ colors[ImGuiCol_Tab] = bg2;
+ colors[ImGuiCol_TabHovered] = bg1;
+ colors[ImGuiCol_TabActive] = bg3;
+ colors[ImGuiCol_TabUnfocused] = bg2;
+ colors[ImGuiCol_TabUnfocusedActive] = bg3;
+ colors[ImGuiCol_TableHeaderBg] = bg2;
+ colors[ImGuiCol_TableBorderStrong] = bg1;
+ colors[ImGuiCol_TableBorderLight] = ImVec4(bg1.x, bg1.y, bg1.z, 0.6f);
+ colors[ImGuiCol_TableRowBg] = ImVec4(bg1.x, bg1.y, bg1.z, 0.2f);
+ colors[ImGuiCol_TableRowBgAlt] = ImVec4(bg1.x, bg1.y, bg1.z, 0.35f);
+ colors[ImGuiCol_TextSelectedBg] = ImVec4(accent.x, accent.y, accent.z, 0.30f);
+ colors[ImGuiCol_DragDropTarget] = accent;
+ colors[ImGuiCol_NavHighlight] = accent;
+ colors[ImGuiCol_NavWindowingHighlight] = ImVec4(ink.x, ink.y, ink.z, 0.70f);
+ colors[ImGuiCol_NavWindowingDimBg] = ImVec4(0.0f, 0.0f, 0.0f, 0.45f);
+ colors[ImGuiCol_ModalWindowDimBg] = ImVec4(0.0f, 0.0f, 0.0f, 0.45f);
+ colors[ImGuiCol_PlotLines] = accent;
+ colors[ImGuiCol_PlotLinesHovered] = ink;
+ colors[ImGuiCol_PlotHistogram] = accent;
+ colors[ImGuiCol_PlotHistogramHovered] = ink;
+}
+
+
+// Theme abstraction and registry (generalized theme system)
+class Theme {
+public:
+ virtual ~Theme() = default;
+
+ virtual const char *Name() const = 0; // canonical name (e.g., "nord", "gruvbox-dark")
+ virtual void Apply() const = 0; // apply to current ImGui style
+ ThemeId Id();
+};
+
+namespace detail {
+struct NordTheme final : Theme {
+ const char *Name() const override
+ {
+ return "nord";
+ }
+
+
+ void Apply() const override
+ {
+ ApplyNordImGuiTheme();
+ }
+
+
+ ThemeId Id()
+ {
+ return ThemeId::Nord;
+ }
+};
+
+struct GruvboxTheme final : Theme {
+ const char *Name() const override
+ {
+ return "gruvbox";
+ }
+
+
+ void Apply() const override
+ {
+ if (gBackgroundMode == BackgroundMode::Light)
+ ApplyGruvboxLightMediumTheme();
+ else
+ ApplyGruvboxDarkMediumTheme();
+ }
+
+
+ ThemeId Id()
+ {
+ // Legacy maps to dark; unified under base id GruvboxDarkMedium
+ return ThemeId::GruvboxDarkMedium;
+ }
+};
+
+struct EInkTheme final : Theme {
+ const char *Name() const override
+ {
+ return "eink";
+ }
+
+
+ void Apply() const override
+ {
+ if (gBackgroundMode == BackgroundMode::Dark)
+ ApplyEInkDarkImGuiTheme();
+ else
+ ApplyEInkImGuiTheme();
+ }
+
+
+ static ThemeId Id()
+ {
+ return ThemeId::EInk;
+ }
+};
+
+struct SolarizedTheme final : Theme {
+ const char *Name() const override
+ {
+ return "solarized";
+ }
+
+
+ void Apply() const override
+ {
+ if (gBackgroundMode == BackgroundMode::Light)
+ ApplySolarizedLightTheme();
+ else
+ ApplySolarizedDarkTheme();
+ }
+
+
+ ThemeId Id()
+ {
+ return ThemeId::Solarized;
+ }
+};
+
+struct Plan9Theme final : Theme {
+ const char *Name() const override
+ {
+ return "plan9";
+ }
+
+
+ void Apply() const override
+ {
+ ApplyPlan9Theme();
+ }
+
+
+ ThemeId Id()
+ {
+ return ThemeId::Plan9;
+ }
+};
+} // namespace detail
+
+static inline const std::vector > &
+ThemeRegistry()
+{
+ static std::vector > reg;
+ if (reg.empty()) {
+ // Alphabetical by canonical name: eink, gruvbox, nord, plan9, solarized
+ reg.emplace_back(std::make_unique());
+ reg.emplace_back(std::make_unique());
+ reg.emplace_back(std::make_unique());
+ reg.emplace_back(std::make_unique());
+ reg.emplace_back(std::make_unique());
+ }
+ return reg;
+}
+
+
+// Canonical theme name for a given ThemeId (via registry order)
+static inline const char *
+ThemeName(ThemeId id)
+{
+ const auto ® = ThemeRegistry();
+ size_t idx = ThemeIndexFromId(id);
+ if (idx < reg.size())
+ return reg[idx]->Name();
+ return "unknown";
+}
+
+
+// Helper to apply a theme by id and update current theme
+static inline void
+ApplyTheme(const ThemeId id)
+{
+ const auto ® = ThemeRegistry();
+ size_t idx = ThemeIndexFromId(id);
+ if (idx < reg.size()) {
+ reg[idx]->Apply();
+ gCurrentTheme = id;
+ gCurrentThemeIndex = idx;
+ }
+}
+
+
+static inline ThemeId
+CurrentTheme()
+{
+ return gCurrentTheme;
+}
+
+
+// Cycle helpers
+static inline ThemeId
+NextTheme()
+{
+ const auto ® = ThemeRegistry();
+ if (reg.empty())
+ return gCurrentTheme;
+ size_t nxt = (gCurrentThemeIndex + 1) % reg.size();
+ ApplyTheme(ThemeIdFromIndex(nxt));
+ return gCurrentTheme;
+}
+
+
+static inline ThemeId
+PrevTheme()
+{
+ const auto ® = ThemeRegistry();
+ if (reg.empty())
+ return gCurrentTheme;
+ size_t prv = (gCurrentThemeIndex + reg.size() - 1) % reg.size();
+ ApplyTheme(ThemeIdFromIndex(prv));
+ return gCurrentTheme;
+}
+
+
+// Name-based API
+static inline const Theme *
+GetThemeByName(const std::string &name)
+{
+ const auto ® = ThemeRegistry();
+ for (const auto &t: reg) {
+ if (name == t->Name())
+ return t.get();
+ }
+ return nullptr;
+}
+
+
+static inline bool
+ApplyThemeByName(const std::string &name)
+{
+ // Handle aliases and background-specific names
+ std::string n = name;
+ // lowercase copy
+ std::transform(n.begin(), n.end(), n.begin(), [](unsigned char c) {
+ return (char) std::tolower(c);
+ });
+
+ if (n == "gruvbox-dark") {
+ SetBackgroundMode(BackgroundMode::Dark);
+ n = "gruvbox";
+ } else if (n == "gruvbox-light") {
+ SetBackgroundMode(BackgroundMode::Light);
+ n = "gruvbox";
+ } else if (n == "solarized-dark") {
+ SetBackgroundMode(BackgroundMode::Dark);
+ n = "solarized";
+ } else if (n == "solarized-light") {
+ SetBackgroundMode(BackgroundMode::Light);
+ n = "solarized";
+ } else if (n == "eink-dark") {
+ SetBackgroundMode(BackgroundMode::Dark);
+ n = "eink";
+ } else
+ if (n == "eink-light") {
+ SetBackgroundMode(BackgroundMode::Light);
+ n = "eink";
+ }
+ // plan9 is a single theme; no light/dark aliases
+
+ const auto ® = ThemeRegistry();
+ for (size_t i = 0; i < reg.size(); ++i) {
+ if (n == reg[i]->Name()) {
+ reg[i]->Apply();
+ gCurrentThemeIndex = i;
+ gCurrentTheme = ThemeIdFromIndex(i);
+ return true;
+ }
+ }
+ return false;
+}
+
+
+static inline const char *
+CurrentThemeName()
+{
+ const auto ® = ThemeRegistry();
+ if (gCurrentThemeIndex < reg.size())
+ return reg[gCurrentThemeIndex]->Name();
+ return "unknown";
+}
+
+
+// Helpers to map between legacy ThemeId and registry index
+static inline size_t
+ThemeIndexFromId(ThemeId id)
+{
+ switch (id) {
+ case ThemeId::EInk:
+ return 0;
+ case ThemeId::GruvboxDarkMedium:
+ return 1;
+ case ThemeId::GruvboxLightMedium: // legacy alias maps to unified gruvbox index
+ return 1;
+ case ThemeId::Nord:
+ return 2;
+ case ThemeId::Plan9:
+ return 3;
+ case ThemeId::Solarized:
+ return 4;
+ }
+ return 0;
+}
+
+
+static inline ThemeId
+ThemeIdFromIndex(size_t idx)
+{
+ switch (idx) {
+ default:
+ case 0:
+ return ThemeId::EInk;
+ case 1:
+ return ThemeId::GruvboxDarkMedium; // unified gruvbox
+ case 2:
+ return ThemeId::Nord;
+ case 3:
+ return ThemeId::Plan9;
+ case 4:
+ return ThemeId::Solarized;
+ }
+}
} // namespace kte
diff --git a/HelpText.cc b/HelpText.cc
index 2dc4405..f81dfc7 100644
--- a/HelpText.cc
+++ b/HelpText.cc
@@ -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"
);
}
diff --git a/HelpText.h b/HelpText.h
index 27c7965..1e7f922 100644
--- a/HelpText.h
+++ b/HelpText.h
@@ -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
diff --git a/KKeymap.cc b/KKeymap.cc
index d44318a..18ee035 100644
--- a/KKeymap.cc
+++ b/KKeymap.cc
@@ -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;
diff --git a/TerminalRenderer.cc b/TerminalRenderer.cc
index 8b1012a..860fade 100644
--- a/TerminalRenderer.cc
+++ b/TerminalRenderer.cc
@@ -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(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> ranges; // [start, end)
- if (search_mode && li < lines.size()) {
- std::string sline = static_cast(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(m.position());
- std::size_t ex = sx + static_cast(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(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(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(
- 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(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(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(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 > ranges; // [start, end)
+ if (search_mode && li < lines.size()) {
+ std::string sline = static_cast(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(m.position());
+ std::size_t ex = sx + static_cast(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(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(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(
+ 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(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(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(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(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).
diff --git a/UndoSystem.cc b/UndoSystem.cc
index c905d9c..addff21 100644
--- a/UndoSystem.cc
+++ b/UndoSystem.cc
@@ -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(buf_->Cury());
- const int col = static_cast(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(tree_.pending->col);
- if (anchor == static_cast(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(col) + 1 == anchor) {
- tree_.pending->col = col;
- pending_prepend_ = true;
- return;
- }
- } else {
- std::size_t expected = static_cast(tree_.pending->col) + tree_.pending->text.
- size();
- if (expected == static_cast(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(buf_->Cury());
+ const int col = static_cast(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(tree_.pending->col);
+ if (anchor == static_cast(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(col) + 1 == anchor) {
+ tree_.pending->col = col;
+ pending_prepend_ = true;
+ return;
+ }
+ } else {
+ std::size_t expected = static_cast(tree_.pending->col) + tree_.pending->text.
+ size();
+ if (expected == static_cast(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(buf_->Cury());
- int col = static_cast(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(buf_->Cury());
+ int col = static_cast(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
}
diff --git a/UndoSystem.h b/UndoSystem.h
index 13f7f24..979e272 100644
--- a/UndoSystem.h
+++ b/UndoSystem.h
@@ -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
diff --git a/docs/kge.1 b/docs/kge.1
index 4ea4d80..c6303bf 100644
--- a/docs/kge.1
+++ b/docs/kge.1
@@ -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
diff --git a/docs/kte.1 b/docs/kte.1
index 32acbf8..8f70e94 100644
--- a/docs/kte.1
+++ b/docs/kte.1
@@ -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
diff --git a/docs/syntax on.md b/docs/syntax on.md
new file mode 100644
index 0000000..7a588db
--- /dev/null
+++ b/docs/syntax on.md
@@ -0,0 +1,102 @@
+### Objective
+Introduce fast, minimal‑dependency 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 per‑line highlighting; handle single‑line comments and strings; defer multi‑line state to v2.
+- Toggle: `:syntax on|off` and per‑buffer 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; };` // 0‑based columns in buffer indices per rendered line
+ - `struct LineHighlight { std::vector spans; uint64_t version; };`
+
+2. Interfaces (new):
+ - `class LanguageHighlighter { public: virtual ~LanguageHighlighter() = default; virtual void HighlightLine(const Buffer& buf, int row, std::vector& out) const = 0; virtual bool Stateful() const { return false; } };`
+ - `class HighlighterEngine { public: void SetHighlighter(std::unique_ptr); 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:
+ - Per‑Buffer settings: `bool syntax_enabled; std::string filetype; std::unique_ptr 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.
+- Z‑order 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 semi‑transparent 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/16‑color terminals (e.g., comments=dim, keywords=bold, strings=yellow/green if available).
+
+### Language detection
+- v1: by file extension; allow manual `:set filetype=`.
+- v2: add shebang detection for scripts, simple modelines (optional).
+
+### Commands/UX
+- `:syntax on|off` — global default; buffer inherits on open.
+- `:set filetype=` — per‑buffer 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 hand‑rolled 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 per‑row 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
+ - Viewport‑first highlighting: compute only visible rows each frame; background task warms cache around viewport.
+ - Reuse span buffers, avoid allocations; small‑vector 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 Tree‑sitter 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: feature‑detect 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: per‑buffer 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.
\ No newline at end of file
diff --git a/test_undo.cc b/test_undo.cc
index 9912685..abf6c4b 100644
--- a/test_undo.cc
+++ b/test_undo.cc
@@ -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";