Files
kte/Command.cc
T
Claude 7e16680a26 Terminal: draw control characters as ^X; bound GUI config values
- C0 controls and DEL were passed to ncurses, which acted on them: a
  carriage return moved to column 0 and the line padding then erased
  the text, so every line of a CRLF file drew blank, and a backspace
  ate the previous character. They are now drawn in caret notation
  (^M, ^H, ^?) and counted as two cells. C1 controls (and stray bytes
  0x80-0x9F, which decode to them; U+009B is CSI) are drawn as '?'.
- The width rule lives in TermWidth.h and is shared by the renderer's
  drawing loop, its cursor placement and the command layer's
  horizontal scrolling and mouse mapping, which also disagreed on NUL
  and invalid bytes.
- GUI config: columns/rows must be 1..1000 and the font size a finite
  4..200 (TOML 'size = inf' and huge values were accepted, leading to
  UB in float-to-int conversion and int overflow downstream).

Verified in tmux: a CRLF file now shows 'first line^M' where the
baseline showed a blank line.

Audit findings B9 (and the CR/BS case from the follow-up review).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
2026-09-24 09:16:04 -07:00

5627 lines
164 KiB
C++

#include <algorithm>
#include <map>
#include <cwchar>
#include <filesystem>
#include <cstdlib>
#include <regex>
#include <fstream>
#include <sstream>
#include <cmath>
#include <cctype>
#include <string_view>
#include "Command.h"
#include "RegexGuard.h"
#include "TermWidth.h"
#include "syntax/HighlighterRegistry.h"
#include "syntax/NullHighlighter.h"
#include "Editor.h"
#include "Buffer.h"
#include "UndoSystem.h"
#include "HelpText.h"
#include "syntax/LanguageHighlighter.h"
#include "syntax/HighlighterEngine.h"
#include "syntax/CppHighlighter.h"
#ifdef KTE_BUILD_GUI
# include "GUITheme.h"
# if !defined(KTE_USE_QT)
# include "fonts/FontRegistry.h"
# include "imgui.h"
# endif
# if defined(KTE_USE_QT)
# include <QFontDatabase>
# include <QStringList>
# endif
#endif
// Define cross-frontend theme change flags declared in GUITheme.h
namespace kte {
bool gThemeChangePending = false;
std::string gThemeChangeRequest;
// Qt font change globals
bool gFontChangePending = false;
std::string gFontFamilyRequest;
float gFontSizeRequest = 0.0f;
std::string gCurrentFontFamily;
float gCurrentFontSize = 0.0f;
// Request Qt visual font dialog
bool gFontDialogRequested = false;
}
// UTF-8 aware stepping. Cursor columns are byte offsets; these keep motion
// and deletion on code point boundaries so a multibyte character is never
// split. Malformed bytes are treated as single-byte characters.
static inline bool
utf8_is_cont(const char c)
{
return (static_cast<unsigned char>(c) & 0xC0) == 0x80;
}
// Byte length of the character ending at x (x > 0, x <= line.size()).
static std::size_t
utf8_prev_len(std::string_view line, std::size_t x)
{
if (x == 0)
return 0;
if (x > line.size())
return 1;
std::size_t n = 1;
while (n < 4 && n < x && utf8_is_cont(line[x - n]))
++n;
// Accept only if a lead byte starts the run and declares this length.
const auto lead = static_cast<unsigned char>(line[x - n]);
std::size_t want = 1;
if (lead >= 0xF0)
want = 4;
else if (lead >= 0xE0)
want = 3;
else if (lead >= 0xC0)
want = 2;
return (n > 1 && want == n) ? n : 1;
}
// Byte length of the character starting at x (x < line.size()).
static std::size_t
utf8_next_len(std::string_view line, std::size_t x)
{
if (x >= line.size())
return 0;
const auto lead = static_cast<unsigned char>(line[x]);
std::size_t want = 1;
if (lead >= 0xF0 && lead < 0xF8)
want = 4;
else if (lead >= 0xE0)
want = 3;
else if (lead >= 0xC0)
want = 2;
if (want == 1 || x + want > line.size())
return 1;
for (std::size_t i = 1; i < want; ++i) {
if (!utf8_is_cont(line[x + i]))
return 1;
}
return want;
}
static inline std::string_view
line_view(const Buffer::Line &l)
{
return std::string_view(l.Data(), l.Size());
}
static inline std::string_view
line_view(const std::string &l)
{
return std::string_view(l);
}
// Keep buffer viewport offsets so that the cursor stays within the visible
// window based on the editor's current dimensions. The bottom row is reserved
// for the status line.
// Decode the character at line[i]: its byte length and display width in cells
// (tabs expand to the next multiple of tabw from column rx). Measures the way
// TerminalRenderer draws: invalid bytes are one cell each.
static void
measure_char(std::string_view line, std::size_t i, std::size_t rx, std::size_t tabw, std::size_t &len,
std::size_t &width)
{
std::mbstate_t state{};
wchar_t wch = 0;
const auto rc = std::mbrtowc(&wch, line.data() + i, line.size() - i, &state);
if (rc == static_cast<std::size_t>(-1) || rc == static_cast<std::size_t>(-2) || rc == 0) {
// Invalid byte or NUL: the renderer draws the byte value itself.
wch = static_cast<unsigned char>(line[i]);
len = 1;
} else {
len = rc;
}
if (wch == L'\t')
width = tabw - (rx % tabw);
else
width = static_cast<std::size_t>(kte::CellWidth(wch));
}
// Display column of byte offset curx, in terminal cells.
static std::size_t
compute_render_x(std::string_view line, const std::size_t curx, const std::size_t tabw)
{
std::size_t rx = 0;
std::size_t i = 0;
while (i < curx && i < line.size()) {
std::size_t len = 1, width = 1;
measure_char(line, i, rx, tabw, len, width);
rx += width;
i += len;
}
return rx;
}
// Clamp (x, y) to an existing row and to that row's length.
static void
clamp_position(const Buffer &buf, std::size_t &x, std::size_t &y)
{
const std::size_t nrows = buf.Nrows();
if (nrows == 0) {
x = y = 0;
return;
}
// A row past the end means a position past the end of the buffer: clamp
// it to the end of the last line, not to its column on that line.
const bool past_end = y >= nrows;
if (past_end)
y = nrows - 1;
const auto [start, end] = buf.GetLineRange(y);
std::size_t len = end - start;
// GetLineRange includes the trailing newline, if any.
if (len > 0 && y + 1 < nrows)
--len;
x = past_end ? len : std::min(x, len);
}
// Clamp the cursor to an existing row and to that row's length. Commands that
// set the cursor from remembered or scrolled positions must call this: edits
// at a column past end-of-line are misapplied (and mis-recorded for undo).
static void
clamp_cursor_to_buffer(Buffer &buf)
{
std::size_t x = buf.Curx();
std::size_t y = buf.Cury();
clamp_position(buf, x, y);
buf.SetCursor(x, y);
}
static void
ensure_cursor_visible(const Editor &ed, Buffer &buf)
{
const std::size_t cols = ed.Cols();
if (cols == 0)
return;
const std::size_t content_rows = ed.ContentRows();
const std::size_t cury = buf.Cury();
const std::size_t curx = buf.Curx();
std::size_t rowoffs = buf.Rowoffs();
std::size_t coloffs = buf.Coloffs();
// Vertical scrolling
if (cury < rowoffs) {
rowoffs = cury;
} else if (content_rows > 0 && cury >= rowoffs + content_rows) {
rowoffs = cury - content_rows + 1;
}
// Clamp vertical offset to available content. Nrows() reads the line index
// and equals Rows().size(); calling Rows() here would rebuild a string for
// every line of the file after each edit.
const auto total_rows = buf.Nrows();
if (content_rows < total_rows) {
std::size_t max_rowoffs = total_rows - content_rows;
if (rowoffs > max_rowoffs)
rowoffs = max_rowoffs;
} else {
rowoffs = 0;
}
// Horizontal scrolling (use rendered columns with tabs expanded)
std::size_t rx = 0;
if (cury < total_rows) {
// GetLineView would materialize the whole buffer after an edit; copying
// the one line is cheaper.
rx = compute_render_x(buf.GetLineString(cury), curx, 8);
}
if (rx < coloffs) {
coloffs = rx;
} else if (rx >= coloffs + cols) {
coloffs = rx - cols + 1;
}
buf.SetOffsets(rowoffs, coloffs);
buf.SetRenderX(rx);
}
static bool
cmd_new_window(CommandContext &ctx)
{
ctx.editor.SetNewWindowRequested(true);
return true;
}
static bool
cmd_center_on_cursor(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
std::size_t total = buf->Nrows();
std::size_t content = ctx.editor.ContentRows();
if (content == 0)
content = 1;
std::size_t cy = buf->Cury();
std::size_t half = content / 2;
std::size_t new_rowoffs = (cy > half) ? (cy - half) : 0;
// Clamp to valid range
if (total > content) {
std::size_t max_rowoffs = total - content;
if (new_rowoffs > max_rowoffs)
new_rowoffs = max_rowoffs;
} else {
new_rowoffs = 0;
}
buf->SetOffsets(new_rowoffs, buf->Coloffs());
return true;
}
static void
ensure_at_least_one_line(Buffer &buf)
{
if (buf.Nrows() == 0) {
buf.insert_row(0, "");
buf.SetDirty(true);
}
}
// RAII helper: brackets a multi-edit command so its individual undo nodes
// undo/redo as a single atomic step, per the project's undo-grouping convention.
struct UndoGroupGuard {
UndoSystem *u;
explicit UndoGroupGuard(UndoSystem *u_) : u(u_)
{
if (u)
u->BeginGroup();
}
~UndoGroupGuard()
{
if (u)
u->EndGroup();
}
};
// Determine if a command mutates the buffer contents (text edits)
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;
}
}
// True if `path` names the file `buf` is backed by (same inode).
static bool
is_buffers_own_file(const Buffer &buf, const std::string &path)
{
if (!buf.IsFileBacked() || buf.Filename().empty())
return false;
try {
return std::filesystem::equivalent(path, buf.Filename());
} catch (...) {
return false;
}
}
// Commands that may run while a prompt is open: prompt editing and
// navigation (their handlers check PromptActive), cancel, and view-only
// commands that do not touch buffer contents or buffer selection.
static bool
allowed_during_prompt(CommandId id)
{
switch (id) {
case CommandId::Refresh:
case CommandId::InsertText:
case CommandId::Newline:
case CommandId::SmartNewline:
case CommandId::Backspace:
case CommandId::MoveLeft:
case CommandId::MoveRight:
case CommandId::MoveUp:
case CommandId::MoveDown:
case CommandId::UArgStatus:
case CommandId::KPrefix:
case CommandId::UnknownKCommand:
case CommandId::UnknownEscCommand:
case CommandId::ScrollUp:
case CommandId::ScrollDown:
case CommandId::ThemeNext:
case CommandId::ThemePrev:
case CommandId::FontZoomIn:
case CommandId::FontZoomOut:
case CommandId::FontZoomReset:
case CommandId::VisualFilePickerToggle:
case CommandId::VisualFontPickerToggle:
return true;
default:
return false;
}
}
// --- UI/status helpers ---
static bool
cmd_uarg_status(CommandContext &ctx)
{
// ctx.arg should contain the digits/minus entered so far (may be empty)
ctx.editor.SetStatus(std::string("C-u ") + ctx.arg);
return true;
}
// Helper: compute ordered region between mark and cursor. Returns false if no mark set or zero-length.
static bool
compute_mark_region(Buffer &buf, std::size_t &sx, std::size_t &sy, std::size_t &ex, std::size_t &ey)
{
if (!buf.MarkSet())
return false;
std::size_t cx = buf.Curx();
std::size_t cy = buf.Cury();
std::size_t mx = buf.MarkCurx();
std::size_t my = buf.MarkCury();
// Marks are not adjusted by edits, so either end may now lie past the end
// of its line or of the buffer; clamp both before ordering them.
clamp_position(buf, cx, cy);
clamp_position(buf, mx, my);
if (cy < my || (cy == my && cx < mx)) {
sy = cy;
sx = cx;
ey = my;
ex = mx;
} else {
sy = my;
sx = mx;
ey = cy;
ex = cx;
}
if (sy == ey && sx == ex)
return false; // empty region
return true;
}
// Row access for commands that touch a few rows. Buffer::Rows() rebuilds a
// string for every line of the file on the first call after any edit, which
// made every motion and delete O(file size). RowsView fetches rows on demand
// and caches them for the current buffer version. Strings fetched for an
// older version are retired, not freed, so a reference obtained before an
// edit stays valid (if stale) for the lifetime of the view, as with Rows().
class RowsView {
public:
explicit RowsView(const Buffer &buf) : buf_(&buf) {}
[[nodiscard]] std::size_t size() const
{
return buf_->Nrows();
}
[[nodiscard]] bool empty() const
{
return size() == 0;
}
const std::string &operator[](std::size_t row) const
{
if (buf_->Version() != version_) {
if (!cache_.empty())
retired_.push_back(std::move(cache_));
cache_.clear();
version_ = buf_->Version();
}
auto it = cache_.find(row);
if (it == cache_.end())
it = cache_.emplace(row, buf_->GetLineString(row)).first;
return it->second;
}
private:
const Buffer *buf_;
mutable std::uint64_t version_ = ~std::uint64_t{0};
mutable std::map<std::size_t, std::string> cache_;
mutable std::vector<std::map<std::size_t, std::string> > retired_;
};
static RowsView
rows_of(const Buffer &buf)
{
return RowsView(buf);
}
// Helper: extract text from [sx,sy) to [ex,ey) without modifying buffer. Newlines inserted between lines.
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 = rows_of(buf);
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<std::string>(rows[y]);
out += '\n';
}
// last line head
{
const auto &line = rows[ey];
std::size_t xe = std::min(ex, line.size());
out += line.substr(0, xe);
}
return out;
}
// Helper: delete region and leave cursor at start (sx,sy). Adjust lines appropriately.
// If `u` is non-null, each underlying mutation is recorded as its own undo node;
// callers that want the whole region-delete to undo/redo as one step should
// wrap the call in an UndoGroupGuard.
static void
delete_region(Buffer &buf, std::size_t sx, std::size_t sy, std::size_t ex, std::size_t ey,
UndoSystem *u = nullptr)
{
std::size_t nrows = buf.Nrows();
if (nrows == 0)
return;
if (sy >= nrows)
return;
if (ey >= nrows)
ey = nrows - 1;
if (sy == ey) {
// Single line: delete text from xs to xe
const auto &rows = rows_of(buf);
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);
std::string deleted = std::string(line.substr(xs, xe - xs));
buf.delete_text(static_cast<int>(sy), static_cast<int>(xs), xe - xs);
if (u && !deleted.empty()) {
buf.SetCursor(xs, sy);
u->Begin(UndoType::Delete);
u->Append(std::string_view(deleted));
u->commit();
}
} else {
// Multi-line: delete from (sx,sy) to (ex,ey) as one contiguous range.
// Deleting whole rows instead would leave a stray newline when the
// region ends on a last line that has no trailing newline.
std::string deleted;
std::size_t xs = 0;
for (std::size_t y = sy; y <= ey; ++y) {
const std::string line = buf.GetLineString(y);
if (y == sy) {
xs = std::min(sx, line.size());
deleted.append(line, xs, std::string::npos);
deleted.push_back('\n');
} else if (y < ey) {
deleted.append(line);
deleted.push_back('\n');
} else {
deleted.append(line, 0, std::min(ex, line.size()));
}
}
buf.delete_text(static_cast<int>(sy), static_cast<int>(xs), deleted.size());
if (u && !deleted.empty()) {
buf.SetCursor(xs, sy);
u->Begin(UndoType::Delete);
u->Append(std::string_view(deleted));
u->commit();
}
}
buf.SetCursor(sx, sy);
buf.SetDirty(true);
}
// Replace the text of rows [y, y + old_text lines) in place with new_text,
// where old_text is those rows joined by '\n' (no trailing newline). Unlike
// delete_row/insert_row this never adds or removes the newline after the
// last replaced row, so a file without a trailing newline keeps that shape.
static void
replace_rows_text(Buffer &buf, std::size_t y, const std::string &old_text, const std::string &new_text,
UndoSystem *u)
{
if (!old_text.empty()) {
buf.delete_text(static_cast<int>(y), 0, old_text.size());
if (u) {
buf.SetCursor(0, y);
u->Begin(UndoType::Delete);
u->Append(std::string_view(old_text));
u->commit();
}
}
if (!new_text.empty()) {
buf.insert_text(static_cast<int>(y), 0, new_text);
if (u) {
buf.SetCursor(0, y);
u->Begin(UndoType::Insert);
u->Append(std::string_view(new_text));
u->commit();
}
}
}
// Insert arbitrary text at cursor, supporting newlines. Updates cursor position.
static void
insert_text_at_cursor(Buffer &buf, const std::string &text)
{
std::size_t nrows = buf.Nrows();
std::size_t y = buf.Cury();
std::size_t x = buf.Curx();
if (y > nrows)
y = nrows;
if (nrows == 0) {
buf.insert_row(0, "");
nrows = 1;
}
if (y >= nrows) {
buf.insert_row(static_cast<int>(nrows), "");
nrows = buf.Nrows();
}
std::size_t cur_y = y;
std::size_t cur_x = x;
std::string remain = text;
while (true) {
auto pos = remain.find('\n');
if (pos == std::string::npos) {
// insert remaining into current line
nrows = buf.Nrows();
if (cur_y >= nrows) {
buf.insert_row(static_cast<int>(nrows), "");
}
const auto &rows = rows_of(buf);
if (cur_x > rows[cur_y].size())
cur_x = rows[cur_y].size();
buf.insert_text(static_cast<int>(cur_y), static_cast<int>(cur_x), remain);
cur_x += remain.size();
break;
}
// insert segment before newline
std::string seg = remain.substr(0, pos);
{
const auto &rows = rows_of(buf);
if (cur_x > rows[cur_y].size())
cur_x = rows[cur_y].size();
}
buf.insert_text(static_cast<int>(cur_y), static_cast<int>(cur_x), seg);
// split line at cur_x + seg.size()
cur_x += seg.size();
buf.split_line(static_cast<int>(cur_y), static_cast<int>(cur_x));
// move to start of next line
cur_y += 1;
cur_x = 0;
// advance remain after newline
remain.erase(0, pos + 1);
}
buf.SetCursor(cur_x, cur_y);
buf.SetDirty(true);
}
// Byte offset (on a character boundary) whose display column is closest to
// rx_target; the inverse of compute_render_x.
static std::size_t
inverse_render_to_source_col(const std::string &line, std::size_t rx_target, std::size_t tabw)
{
if (rx_target == 0)
return 0;
std::size_t rx = 0;
std::size_t i = 0;
std::size_t best_col = 0;
std::size_t best_dist = rx_target;
while (true) {
const std::size_t dist = (rx > rx_target) ? (rx - rx_target) : (rx_target - rx);
if (dist <= best_dist) {
best_dist = dist;
best_col = i;
}
if (i >= line.size() || rx >= rx_target)
break;
std::size_t len = 1, width = 1;
measure_char(line, i, rx, tabw, len, width);
rx += width;
i += len;
}
return best_col;
}
// --- Direct cursor placement ---
static bool
cmd_move_cursor_to(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
ensure_at_least_one_line(*buf);
// Accept either:
// - "row:col" (buffer coordinates)
// - "@row:col" (screen coordinates within viewport; translated using offsets)
std::size_t row = buf->Cury();
std::size_t col = buf->Curx();
const std::string &a = ctx.arg;
if (!a.empty()) {
bool screen = false;
std::size_t start = 0;
if (!a.empty() && a[0] == '@') {
screen = true;
start = 1;
}
std::size_t p = a.find(':', start);
if (p != std::string::npos) {
try {
long ay = std::stol(a.substr(start, p - start));
long ax = std::stol(a.substr(p + 1));
if (ay < 0)
ay = 0;
if (ax < 0)
ax = 0;
if (screen) {
// Translate screen to buffer coordinates using offsets and tab expansion for x
std::size_t vy = static_cast<std::size_t>(ay);
std::size_t vx = static_cast<std::size_t>(ax);
std::size_t bro = buf->Rowoffs();
std::size_t bco = buf->Coloffs();
std::size_t by = bro + vy;
// Clamp by to existing lines later
ensure_at_least_one_line(*buf);
const auto &lines2 = rows_of(*buf);
if (by >= lines2.size())
by = lines2.size() - 1;
std::string line2 = static_cast<std::string>(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<std::size_t>(ay);
col = static_cast<std::size_t>(ax);
}
} catch (...) {
// ignore parse errors
}
}
}
ensure_at_least_one_line(*buf);
const auto &lines = rows_of(*buf);
if (row >= lines.size())
row = lines.size() - 1;
std::string line = static_cast<std::string>(lines[row]);
if (col > line.size())
col = line.size();
buf->SetCursor(col, row);
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
// --- Search helpers (UI-agnostic) ---
static std::vector<std::pair<std::size_t, std::size_t> >
search_compute_matches(const Buffer &buf, const std::string &q)
{
std::vector<std::pair<std::size_t, std::size_t> > out;
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<std::string>(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;
}
// Regex-based matches (per-line), capturing match length for highlighting
struct RegexMatch {
std::size_t y;
std::size_t x;
std::size_t len;
};
static std::vector<RegexMatch>
search_compute_matches_regex(const Buffer &buf, const std::string &pattern, std::string &err_out)
{
std::vector<RegexMatch> out;
err_out.clear();
if (pattern.empty())
return out;
try {
const std::regex rx(pattern);
const auto &rows = buf.Rows();
// std::regex recurses per matched character; see RegexGuard.h.
kte::RunWithLargeStack([&] {
for (std::size_t y = 0; y < rows.size(); ++y) {
std::string line = static_cast<std::string>(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<std::size_t>(m.position()), static_cast<std::size_t>(m.length())
});
}
}
});
} catch (const std::regex_error &e) {
err_out = e.what();
// Return empty results on error
}
return out;
}
static void
search_apply_match_regex(Editor &ed, Buffer &buf, const std::vector<RegexMatch> &matches)
{
const std::string &q = ed.SearchQuery();
if (matches.empty()) {
ed.SetSearchMatch(0, 0, 0);
// Restore cursor to origin if present
if (ed.SearchOriginSet()) {
buf.SetCursor(ed.SearchOrigX(), ed.SearchOrigY());
buf.SetOffsets(ed.SearchOrigRowoffs(), ed.SearchOrigColoffs());
}
ed.SetSearchIndex(-1);
ed.SetStatus("Regex: " + q);
return;
}
int idx = ed.SearchIndex();
if (idx < 0 || idx >= static_cast<int>(matches.size()))
idx = 0;
const auto &m = matches[static_cast<std::size_t>(idx)];
ed.SetSearchMatch(m.y, m.x, m.len);
buf.SetCursor(m.x, m.y);
ensure_cursor_visible(ed, buf);
char tmp[64];
snprintf(tmp, sizeof(tmp), "%d/%zu", idx + 1, matches.size());
ed.SetStatus(std::string("Regex: ") + q + " " + tmp);
ed.SetSearchIndex(idx);
}
static void
search_apply_match(Editor &ed, Buffer &buf, const std::vector<std::pair<std::size_t, std::size_t> > &matches)
{
const std::string &q = ed.SearchQuery();
if (matches.empty()) {
ed.SetSearchMatch(0, 0, 0);
// Restore cursor to origin if present
if (ed.SearchOriginSet()) {
buf.SetCursor(ed.SearchOrigX(), ed.SearchOrigY());
buf.SetOffsets(ed.SearchOrigRowoffs(), ed.SearchOrigColoffs());
}
ed.SetSearchIndex(-1);
ed.SetStatus("Find: " + q);
return;
}
int idx = ed.SearchIndex();
if (idx < 0 || idx >= static_cast<int>(matches.size()))
idx = 0;
auto [y, x] = matches[static_cast<std::size_t>(idx)];
ed.SetSearchMatch(y, x, q.size());
buf.SetCursor(x, y);
ensure_cursor_visible(ed, buf);
char tmp[64];
snprintf(tmp, sizeof(tmp), "%d/%zu", idx + 1, matches.size());
ed.SetStatus(std::string("Find: ") + q + " " + tmp);
ed.SetSearchIndex(idx);
}
// --- File/Session commands ---
static bool
cmd_save(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf) {
ctx.editor.SetStatus("No buffer to save");
return false;
}
std::string err;
// Allow saving directly to a filename if buffer was opened with a
// non-existent path (not yet file-backed but has a filename).
if (!buf->IsFileBacked()) {
if (!buf->Filename().empty()) {
// If first-time save to an existing path, confirm overwrite
if (std::filesystem::exists(buf->Filename())) {
ctx.editor.StartPrompt(Editor::PromptKind::Confirm, "Overwrite", "");
ctx.editor.SetPendingOverwritePath(buf->Filename());
ctx.editor.SetStatus(
std::string("Overwrite existing file '") + buf->Filename() + "'? (y/N)");
return true;
} else {
if (!buf->SaveAs(buf->Filename(), err)) {
ctx.editor.SetStatus(err);
return false;
}
buf->SetDirty(false);
if (auto *sm = ctx.editor.Swap())
sm->ResetJournal(*buf);
ctx.editor.SetStatus("Saved " + buf->Filename());
if (auto *u = buf->Undo())
u->mark_saved();
return true;
}
}
// If buffer has no name, prompt for a filename
ctx.editor.StartPrompt(Editor::PromptKind::SaveAs, "Save as", "");
ctx.editor.SetStatus("Save as: ");
return true;
}
// External modification detection: if the on-disk file changed since we last observed it,
// require confirmation before overwriting.
if (buf->ExternallyModifiedOnDisk()) {
ctx.editor.StartPrompt(Editor::PromptKind::Confirm, "Overwrite", "");
ctx.editor.SetPendingOverwritePath(buf->Filename());
ctx.editor.SetStatus(
std::string("File changed on disk: overwrite '") + buf->Filename() + "'? (y/N)");
return true;
}
if (!buf->Save(err)) {
ctx.editor.SetStatus(err);
return false;
}
buf->SetDirty(false);
if (auto *sm = ctx.editor.Swap())
sm->ResetJournal(*buf);
ctx.editor.SetStatus("Saved " + buf->Filename());
if (auto *u = buf->Undo())
u->mark_saved();
return true;
}
// --- Working directory commands ---
static bool
cmd_show_working_directory(CommandContext &ctx)
{
try {
std::filesystem::path cwd = std::filesystem::current_path();
ctx.editor.SetStatus(std::string("cwd: ") + cwd.string());
return true;
} catch (const std::exception &e) {
ctx.editor.SetStatus(std::string("cwd: <error> ") + e.what());
return false;
}
}
static bool
cmd_change_working_directory_start(CommandContext &ctx)
{
std::string initial;
try {
initial = std::filesystem::current_path().string() + "/";
} catch (...) {
initial.clear();
}
ctx.editor.StartPrompt(Editor::PromptKind::Chdir, "chdir", initial);
ctx.editor.SetStatus(std::string("chdir: ") + ctx.editor.PromptText());
return true;
}
static bool
cmd_save_as(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf) {
ctx.editor.SetStatus("No buffer to save");
return false;
}
if (ctx.arg.empty()) {
ctx.editor.SetStatus("save-as requires a filename");
return false;
}
// Ask before replacing an existing file other than the buffer's own.
if (std::filesystem::exists(ctx.arg) && !is_buffers_own_file(*buf, ctx.arg)) {
ctx.editor.StartPrompt(Editor::PromptKind::Confirm, "Overwrite", "");
ctx.editor.SetPendingOverwritePath(ctx.arg);
ctx.editor.SetStatus(std::string("Overwrite existing file '") + ctx.arg + "'? (y/N)");
return true;
}
std::string err;
if (!buf->SaveAs(ctx.arg, err)) {
ctx.editor.SetStatus(err);
return false;
}
if (auto *sm = ctx.editor.Swap()) {
sm->NotifyFilenameChanged(*buf);
sm->ResetJournal(*buf);
}
ctx.editor.SetStatus("Saved as " + ctx.arg);
if (auto *u = buf->Undo())
u->mark_saved();
return true;
}
static bool
cmd_quit(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
// If a confirmation is already pending, quit now without saving
if (ctx.editor.QuitConfirmPending()) {
ctx.editor.SetQuitConfirmPending(false);
ctx.editor.SetQuitRequested(true);
ctx.editor.SetStatus("Quit requested");
return true;
}
// If current buffer exists and is dirty, warn and arm confirmation
if (buf && buf->Dirty()) {
ctx.editor.SetStatus("Unsaved changes. C-k q to quit without saving");
ctx.editor.SetQuitConfirmPending(true);
return true;
}
// Otherwise quit immediately
ctx.editor.SetQuitRequested(true);
ctx.editor.SetStatus("Quit requested");
return true;
}
static bool
cmd_save_and_quit(CommandContext &ctx)
{
// Try save current buffer (if any), then mark quit requested.
Buffer *buf = ctx.editor.CurrentBuffer();
if (buf && buf->Dirty()) {
std::string err;
// Same guard as save: do not silently overwrite a file that changed
// on disk since it was loaded.
if (buf->IsFileBacked() && buf->ExternallyModifiedOnDisk()) {
ctx.editor.SetStatus("File changed on disk; save with C-k s to confirm overwriting, then quit");
return false;
}
if (buf->IsFileBacked()) {
if (buf->Save(err)) {
buf->SetDirty(false);
if (auto *sm = ctx.editor.Swap())
sm->ResetJournal(*buf);
} else {
ctx.editor.SetStatus(err);
return false;
}
} else if (!buf->Filename().empty()) {
if (buf->SaveAs(buf->Filename(), err)) {
buf->SetDirty(false);
if (auto *sm = ctx.editor.Swap())
sm->ResetJournal(*buf);
} else {
ctx.editor.SetStatus(err);
return false;
}
} else {
ctx.editor.SetStatus("Buffer not file-backed; use save-as before quitting");
return false;
}
}
ctx.editor.SetStatus("Save and quit requested");
ctx.editor.SetQuitRequested(true);
return true;
}
static bool
cmd_quit_now(CommandContext &ctx)
{
ctx.editor.SetQuitRequested(true);
ctx.editor.SetStatus("Quit requested");
return true;
}
static bool
cmd_refresh(CommandContext &ctx)
{
// C-g is mapped to Refresh and acts as a general cancel key.
// Cancel visual-line (multicursor) mode if active.
if (Buffer *buf = ctx.editor.CurrentBuffer()) {
if (buf->VisualLineActive()) {
buf->VisualLineClear();
ctx.editor.SetStatus("Visual line: OFF");
return true;
}
}
// If a generic prompt is active, cancel it
if (ctx.editor.PromptActive()) {
// If also in search mode, restore state
if (ctx.editor.SearchActive()) {
Buffer *buf = ctx.editor.CurrentBuffer();
if (buf && ctx.editor.SearchOriginSet()) {
buf->SetCursor(ctx.editor.SearchOrigX(), ctx.editor.SearchOrigY());
buf->SetOffsets(ctx.editor.SearchOrigRowoffs(), ctx.editor.SearchOrigColoffs());
}
ctx.editor.SetSearchActive(false);
ctx.editor.SetSearchQuery("");
ctx.editor.SetSearchMatch(0, 0, 0);
ctx.editor.ClearSearchOrigin();
ctx.editor.SetSearchIndex(-1);
}
// Clear any pending close/overwrite state associated with prompts
ctx.editor.SetCloseConfirmPending(false);
ctx.editor.SetCloseAfterSave(false);
ctx.editor.ClearPendingOverwritePath();
ctx.editor.CancelRecoveryPrompt();
ctx.editor.CancelPrompt();
ctx.editor.SetStatus("Canceled");
return true;
}
// If in search mode (legacy flag), treat refresh/ESC/C-g as cancel
if (ctx.editor.SearchActive()) {
Buffer *buf = ctx.editor.CurrentBuffer();
if (buf && ctx.editor.SearchOriginSet()) {
buf->SetCursor(ctx.editor.SearchOrigX(), ctx.editor.SearchOrigY());
buf->SetOffsets(ctx.editor.SearchOrigRowoffs(), ctx.editor.SearchOrigColoffs());
}
ctx.editor.SetSearchActive(false);
ctx.editor.SetSearchQuery("");
ctx.editor.SetSearchMatch(0, 0, 0);
ctx.editor.ClearSearchOrigin();
ctx.editor.SetSearchIndex(-1);
ctx.editor.SetStatus("Find canceled");
return true;
}
// If nothing else to cancel, treat C-g/refresh as a mark clear (ke behavior).
if (Buffer *buf = ctx.editor.CurrentBuffer()) {
if (buf->MarkSet()) {
buf->ClearMark();
ctx.editor.SetStatus("Mark cleared");
return true;
}
}
// Otherwise just a hint; renderer will redraw
ctx.editor.SetStatus("");
return true;
}
static bool
cmd_kprefix(CommandContext &ctx)
{
// Close any pending edit batch before entering k-prefix
if (Buffer *b = ctx.editor.CurrentBuffer()) {
if (auto *u = b->Undo())
u->commit();
}
// Show k-command mode hint in status
ctx.editor.SetStatus("C-k _");
return true;
}
// 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)
{
char ch = '?';
if (!ctx.arg.empty()) {
ch = ctx.arg[0];
}
char buf[64];
std::snprintf(buf, sizeof(buf), "unknown k-command %c", ch);
ctx.editor.SetStatus(buf);
return true;
}
static bool
cmd_unknown_esc_command(CommandContext &ctx)
{
(void) ctx;
ctx.editor.SetStatus("invalid escape command");
return true;
}
// --- Syntax highlighting commands ---
static void
apply_filetype(Buffer &buf, const std::string &ft)
{
// Only reachable from explicit user commands (:syntax on, :set
// filetype=...) - mark so frontends that re-apply config-driven syntax
// defaults every frame (e.g. ImGuiFrontend) don't stomp this choice.
buf.SetSyntaxUserOverride(true);
buf.EnsureHighlighter();
auto *eng = buf.Highlighter();
if (!eng)
return;
std::string val = ft;
// trim + lower
auto trim = [](const std::string &s) {
std::string r = s;
auto notsp = [](int ch) {
return !std::isspace(ch);
};
r.erase(r.begin(), std::find_if(r.begin(), r.end(), notsp));
r.erase(std::find_if(r.rbegin(), r.rend(), notsp).base(), r.end());
return r;
};
val = trim(val);
for (auto &ch: val)
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
if (val == "off") {
eng->SetHighlighter(nullptr);
buf.SetFiletype("");
buf.SetSyntaxEnabled(false);
return;
}
if (val.empty()) {
// Empty means unknown/unspecified -> use NullHighlighter but keep syntax enabled
buf.SetFiletype("");
buf.SetSyntaxEnabled(true);
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
eng->InvalidateFrom(0);
return;
}
// Normalize and create via registry
std::string norm = kte::HighlighterRegistry::Normalize(val);
auto hl = kte::HighlighterRegistry::CreateFor(norm);
if (hl) {
eng->SetHighlighter(std::move(hl));
buf.SetFiletype(norm);
buf.SetSyntaxEnabled(true);
eng->InvalidateFrom(0);
} else {
// Unknown -> install NullHighlighter and keep syntax enabled
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
buf.SetFiletype(val); // record what user asked even if unsupported
buf.SetSyntaxEnabled(true);
eng->InvalidateFrom(0);
}
}
static bool
cmd_syntax(CommandContext &ctx)
{
Buffer *b = ctx.editor.CurrentBuffer();
if (!b) {
ctx.editor.SetStatus("No buffer");
return true;
}
std::string arg = ctx.arg;
// trim
auto trim = [](std::string &s) {
auto notsp = [](int ch) {
return !std::isspace(ch);
};
s.erase(s.begin(), std::find_if(s.begin(), s.end(), notsp));
s.erase(std::find_if(s.rbegin(), s.rend(), notsp).base(), s.end());
};
trim(arg);
if (arg == "on") {
b->SetSyntaxUserOverride(true);
b->SetSyntaxEnabled(true);
// If no highlighter but filetype is cpp by extension, set it
if (!b->Highlighter() || !b->Highlighter()->HasHighlighter()) {
apply_filetype(*b, b->Filetype().empty() ? std::string("cpp") : b->Filetype());
}
ctx.editor.SetStatus("syntax: on");
} else if (arg == "off") {
b->SetSyntaxUserOverride(true);
b->SetSyntaxEnabled(false);
ctx.editor.SetStatus("syntax: off");
} else if (arg == "reload") {
if (auto *eng = b->Highlighter())
eng->InvalidateFrom(0);
ctx.editor.SetStatus("syntax: reloaded");
} else {
ctx.editor.SetStatus("usage: :syntax on|off|reload");
}
return true;
}
static bool
cmd_set_option(CommandContext &ctx)
{
Buffer *b = ctx.editor.CurrentBuffer();
if (!b) {
ctx.editor.SetStatus("No buffer");
return true;
}
// Expect key=value
auto eq = ctx.arg.find('=');
if (eq == std::string::npos) {
ctx.editor.SetStatus("usage: :set key=value");
return true;
}
std::string key = ctx.arg.substr(0, eq);
std::string val = ctx.arg.substr(eq + 1);
// trim
auto trim = [](std::string &s) {
auto notsp = [](int ch) {
return !std::isspace(ch);
};
s.erase(s.begin(), std::find_if(s.begin(), s.end(), notsp));
s.erase(std::find_if(s.rbegin(), s.rend(), notsp).base(), s.end());
};
trim(key);
trim(val);
// lower-case value for filetype
for (auto &ch: val)
ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
if (key == "filetype") {
apply_filetype(*b, val);
if (b->SyntaxEnabled())
ctx.editor.SetStatus(
std::string("filetype: ") + (b->Filetype().empty() ? "off" : b->Filetype()));
else
ctx.editor.SetStatus("filetype: off");
return true;
}
ctx.editor.SetStatus("unknown option: " + key);
return true;
}
// GUI theme cycling commands (available in GUI build; ImGui-only for now)
#if defined(KTE_BUILD_GUI) && !defined(KTE_USE_QT)
static bool
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
#if defined(KTE_BUILD_GUI) && !defined(KTE_USE_QT)
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()) {
// Show current theme when no argument provided
ctx.editor.SetStatus(
std::string("Current theme: ") + kte::CurrentThemeName());
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 &reg = 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)
{
# if defined(KTE_BUILD_GUI) && defined(KTE_USE_QT)
// Qt GUI build: schedule theme change for frontend
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: provide a name (e.g., nord, solarized-dark, gruvbox-light, eink)");
return true;
}
kte::gThemeChangeRequest = name;
kte::gThemeChangePending = true;
ctx.editor.SetStatus(std::string("Theme requested: ") + name);
return true;
# else
(void) ctx;
// No-op in terminal build
return true;
# endif
}
#endif
// Font set by name (GUI)
#if defined(KTE_BUILD_GUI) && !defined(KTE_USE_QT)
static bool
cmd_font_set_by_name(const CommandContext &ctx)
{
using namespace kte::Fonts;
std::string name = 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(name);
rtrim(name);
std::transform(name.begin(), name.end(), name.begin(), [](unsigned char c) {
return (char) std::tolower(c);
});
if (name.empty()) {
// Show current font when no argument provided
auto &reg = FontRegistry::Instance();
std::string current_font = reg.CurrentFontName();
if (current_font.empty())
current_font = "default";
ctx.editor.SetStatus(std::string("Current font: ") + current_font);
return true;
}
auto &reg = FontRegistry::Instance();
if (!reg.HasFont(name)) {
ctx.editor.SetStatus("font: unknown name");
return true;
}
float size = reg.CurrentFontSize();
if (size <= 0.0f) {
// Fallback to current ImGui font size if available
size = ImGui::GetFontSize();
if (size <= 0.0f)
size = 16.0f;
}
reg.RequestLoadFont(name, size);
ctx.editor.SetStatus(std::string("Font: ") + name + " (" + std::to_string((int) std::round(size)) + ")");
return true;
}
#else
static bool
cmd_font_set_by_name(CommandContext &ctx)
{
// Qt build: queue font family change
std::string name = 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(name);
rtrim(name);
if (name.empty()) {
// Show current font when no argument provided
std::string cur = kte::gCurrentFontFamily.empty() ? std::string("default") : kte::gCurrentFontFamily;
ctx.editor.SetStatus(std::string("Current font: ") + cur);
return true;
}
kte::gFontFamilyRequest = name;
// Keep size if not specified by user; signal change
kte::gFontChangePending = true;
ctx.editor.SetStatus(std::string("Font requested: ") + name);
return true;
}
#endif
// Font size set (GUI, ImGui-only for now)
#if defined(KTE_BUILD_GUI) && !defined(KTE_USE_QT)
static bool
cmd_font_set_size(const CommandContext &ctx)
{
using namespace kte::Fonts;
std::string a = ctx.arg;
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(a);
rtrim(a);
if (a.empty()) {
// Show current font size when no argument provided
auto &reg = FontRegistry::Instance();
float current_size = reg.CurrentFontSize();
if (current_size <= 0.0f) {
// Fallback to current ImGui font size if available
current_size = ImGui::GetFontSize();
if (current_size <= 0.0f)
current_size = 16.0f;
}
ctx.editor.SetStatus(
std::string("Current font size: ") + std::to_string((int) std::round(current_size)));
return true;
}
char *endp = nullptr;
float size = strtof(a.c_str(), &endp);
if (endp == a.c_str() || !std::isfinite(size)) {
ctx.editor.SetStatus("font-size: expected number");
return true;
}
// Clamp to a reasonable range
if (size < 6.0f)
size = 6.0f;
if (size > 96.0f)
size = 96.0f;
auto &reg = FontRegistry::Instance();
std::string name = reg.CurrentFontName();
if (name.empty())
name = "default";
if (!reg.HasFont(name))
name = "default";
reg.RequestLoadFont(name, size);
ctx.editor.SetStatus(std::string("Font size: ") + std::to_string((int) std::round(size)));
return true;
}
#else
static bool
cmd_font_set_size(CommandContext &ctx)
{
// Qt build: parse size and queue change
std::string a = ctx.arg;
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(a);
rtrim(a);
if (a.empty()) {
float cur = (kte::gCurrentFontSize > 0.0f) ? kte::gCurrentFontSize : 18.0f;
ctx.editor.SetStatus(std::string("Current font size: ") + std::to_string((int) std::round(cur)));
return true;
}
char *endp = nullptr;
float size = strtof(a.c_str(), &endp);
if (endp == a.c_str() || !std::isfinite(size)) {
ctx.editor.SetStatus("font-size: expected number");
return true;
}
if (size < 6.0f)
size = 6.0f;
if (size > 96.0f)
size = 96.0f;
kte::gFontSizeRequest = size;
kte::gFontChangePending = true;
ctx.editor.SetStatus(std::string("Font size requested: ") + std::to_string((int) std::round(size)));
return true;
}
#endif
// Toggle edit mode (code/writing) for current buffer
static bool
cmd_toggle_edit_mode(const CommandContext &ctx)
{
Buffer *b = ctx.editor.CurrentBuffer();
if (!b)
return false;
std::string arg = ctx.arg;
std::transform(arg.begin(), arg.end(), arg.begin(), [](unsigned char c) {
return static_cast<char>(std::tolower(c));
});
// Trim whitespace
auto start = arg.find_first_not_of(" \t");
if (start != std::string::npos)
arg = arg.substr(start);
auto end = arg.find_last_not_of(" \t");
if (end != std::string::npos)
arg = arg.substr(0, end + 1);
if (arg == "code") {
b->SetEditMode(EditMode::Code);
} else if (arg == "writing") {
b->SetEditMode(EditMode::Writing);
} else {
b->ToggleEditMode();
}
// Writing mode disables syntax highlighting; code mode re-enables it.
b->SetSyntaxEnabled(b->GetEditMode() == EditMode::Code);
const char *mode_str = (b->GetEditMode() == EditMode::Writing) ? "writing" : "code";
ctx.editor.SetStatus(std::string("Mode: ") + mode_str);
return true;
}
// Background set command (GUI, ImGui-only for now)
#if defined(KTE_BUILD_GUI) && !defined(KTE_USE_QT)
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.empty()) {
ctx.editor.SetStatus(std::string("Background: ") + kte::BackgroundModeName());
return true;
}
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) {
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 mode; start a generic prompt for search
ctx.editor.SetSearchActive(true);
ctx.editor.SetSearchQuery("");
ctx.editor.SetSearchMatch(0, 0, 0);
ctx.editor.SetSearchIndex(-1);
ctx.editor.StartPrompt(Editor::PromptKind::Search, "Find", "");
ctx.editor.SetStatus("Find: ");
return true;
}
static bool
cmd_regex_find_start(const CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf) {
ctx.editor.SetStatus("No buffer to search");
return false;
}
// Save origin for cancel
ctx.editor.SetSearchOrigin(buf->Curx(), buf->Cury(), buf->Rowoffs(), buf->Coloffs());
// Enter regex search mode using the generic prompt system
ctx.editor.SetSearchActive(true);
ctx.editor.SetSearchQuery("");
ctx.editor.SetSearchMatch(0, 0, 0);
ctx.editor.SetSearchIndex(-1);
ctx.editor.StartPrompt(Editor::PromptKind::RegexSearch, "Regex", "");
ctx.editor.SetStatus("Regex: ");
return true;
}
static bool
cmd_search_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
ctx.editor.SetSearchActive(true);
ctx.editor.SetSearchQuery("");
ctx.editor.SetSearchMatch(0, 0, 0);
ctx.editor.SetSearchIndex(-1);
// Two-step prompt: first collect find string, then replacement
ctx.editor.SetReplaceFindTmp("");
ctx.editor.SetReplaceWithTmp("");
ctx.editor.StartPrompt(Editor::PromptKind::ReplaceFind, "Replace: find", "");
ctx.editor.SetStatus("Replace: find: ");
return true;
}
static bool
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;
}
static bool
cmd_open_file_start(const CommandContext &ctx)
{
// Start a generic prompt to read a path
ctx.editor.StartPrompt(Editor::PromptKind::OpenFile, "Open", "");
ctx.editor.SetStatus("Open: ");
return true;
}
// GUI: toggle visual file picker (no-op in terminal; renderer will consume flag)
static bool
cmd_visual_file_picker_toggle(const CommandContext &ctx)
{
// Toggle visibility
bool show = !ctx.editor.FilePickerVisible();
ctx.editor.SetFilePickerVisible(show);
if (show) {
// Initialize directory to current working directory if empty
if (ctx.editor.FilePickerDir().empty()) {
try {
ctx.editor.SetFilePickerDir(std::filesystem::current_path().string());
} catch (...) {
ctx.editor.SetFilePickerDir(".");
}
}
ctx.editor.SetStatus("Open File (visual)");
} else {
ctx.editor.SetStatus("Closed file picker");
}
return true;
}
// GUI: request visual font picker (Qt frontend will consume flag)
static bool
cmd_visual_font_picker_toggle(const CommandContext &ctx)
{
#ifdef KTE_BUILD_GUI
kte::gFontDialogRequested = true;
ctx.editor.SetStatus("Font chooser");
#else
ctx.editor.SetStatus("Font chooser not available in terminal");
#endif
return true;
}
static bool
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", "");
ctx.editor.SetStatus("Goto line: ");
return true;
}
// --- Buffers: switch/next/prev/close ---
static bool
cmd_buffer_switch_start(const CommandContext &ctx)
{
// If only one (or zero) buffer is open, do nothing per spec
if (ctx.editor.BufferCount() <= 1) {
ctx.editor.SetStatus("No other buffers open.");
return true;
}
ctx.editor.StartPrompt(Editor::PromptKind::BufferSwitch, "Buffer", "");
ctx.editor.SetStatus("Buffer: ");
return true;
}
static std::string
buffer_display_name(const Buffer &b)
{
if (!b.Filename().empty())
return b.Filename();
return {"<untitled>"};
}
static std::string
buffer_basename(const Buffer &b)
{
const std::string &p = b.Filename();
if (p.empty())
return {"<untitled>"};
auto pos = p.find_last_of("/\\");
if (pos == std::string::npos)
return p;
return p.substr(pos + 1);
}
static bool
cmd_buffer_next(const CommandContext &ctx)
{
const auto cnt = ctx.editor.BufferCount();
if (cnt <= 1) {
ctx.editor.SetStatus("No other buffers open.");
return true;
}
std::size_t idx = ctx.editor.CurrentBufferIndex();
idx = (idx + 1) % cnt;
ctx.editor.SwitchTo(idx);
const Buffer *b = ctx.editor.CurrentBuffer();
ctx.editor.SetStatus(std::string("Switched: ") + (b ? buffer_display_name(*b) : std::string("")));
return true;
}
static bool
cmd_buffer_prev(const CommandContext &ctx)
{
const auto cnt = ctx.editor.BufferCount();
if (cnt <= 1) {
ctx.editor.SetStatus("No other buffers open.");
return true;
}
std::size_t idx = ctx.editor.CurrentBufferIndex();
idx = (idx + cnt - 1) % cnt;
ctx.editor.SwitchTo(idx);
const Buffer *b = ctx.editor.CurrentBuffer();
ctx.editor.SetStatus(std::string("Switched: ") + (b ? buffer_display_name(*b) : std::string("")));
return true;
}
static bool
cmd_buffer_close(const CommandContext &ctx)
{
if (ctx.editor.BufferCount() == 0)
return true;
std::size_t idx = ctx.editor.CurrentBufferIndex();
Buffer *b = ctx.editor.CurrentBuffer();
std::string name = b ? buffer_display_name(*b) : std::string("");
// If buffer is dirty, prompt to save first (for both named and unnamed buffers)
if (b && b->Dirty()) {
ctx.editor.StartPrompt(Editor::PromptKind::Confirm, "Save", "");
ctx.editor.SetCloseConfirmPending(true);
ctx.editor.SetStatus(std::string("Save changes to ") + name + "? (y/N)");
return true;
}
// Otherwise close immediately
if (b && b->Undo())
b->Undo()->discard_pending();
ctx.editor.CloseBuffer(idx);
if (ctx.editor.BufferCount() == 0) {
// Open a fresh empty buffer
Buffer empty;
ctx.editor.AddBuffer(std::move(empty));
ctx.editor.SwitchTo(0);
}
const Buffer *cur = ctx.editor.CurrentBuffer();
ctx.editor.SetStatus(std::string("Closed: ") + name + std::string(" Now: ")
+ (cur ? buffer_display_name(*cur) : std::string("")));
return true;
}
// Create a new empty, unnamed buffer and switch to it
static bool
cmd_buffer_new(const CommandContext &ctx)
{
// Create an empty buffer and add it to the editor
Buffer empty;
std::size_t idx = ctx.editor.AddBuffer(std::move(empty));
ctx.editor.SwitchTo(idx);
ctx.editor.SetStatus("New buffer");
return true;
}
// --- Editing ---
static bool
cmd_insert_text(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf) {
ctx.editor.SetStatus("No buffer to edit");
return false;
}
// If a prompt is active, edit prompt text
if (ctx.editor.PromptActive()) {
// Special-case: Tab-completion for prompts
if (ctx.arg == "\t") {
auto kind = ctx.editor.CurrentPromptKind();
// Buffer switch prompt supports Tab-completion on buffer names
if (kind == Editor::PromptKind::BufferSwitch) {
// Complete against buffer names (path and basename)
const std::string prefix = ctx.editor.PromptText();
std::vector<std::pair<std::string, std::size_t> > cands; // name, index
const auto &bs = ctx.editor.Buffers();
for (std::size_t i = 0; i < bs.size(); ++i) {
std::string full = buffer_display_name(bs[i]);
std::string base = buffer_basename(bs[i]);
if (full.rfind(prefix, 0) == 0) {
cands.emplace_back(full, i);
}
if (base.rfind(prefix, 0) == 0 && base != full) {
cands.emplace_back(base, i);
}
}
if (cands.empty()) {
// no change
} else if (cands.size() == 1) {
ctx.editor.SetPromptText(cands[0].first);
} else {
// extend to longest common prefix
std::string lcp = cands[0].first;
for (std::size_t i = 1; i < cands.size(); ++i) {
const std::string &s = cands[i].first;
std::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 != ctx.editor.PromptText())
ctx.editor.SetPromptText(lcp);
}
ctx.editor.SetStatus(ctx.editor.PromptLabel() + ": " + ctx.editor.PromptText());
return true;
}
// File path completion for OpenFile/SaveAs/Chdir
if (kind == Editor::PromptKind::OpenFile || kind == Editor::PromptKind::SaveAs
|| kind == Editor::PromptKind::Chdir) {
auto expand_user_path = [](const std::string &in) -> std::string {
if (!in.empty() && in[0] == '~') {
const char *home = std::getenv("HOME");
if (home && in.size() == 1)
return std::string(home);
if (home && (in.size() > 1) && (in[1] == '/' || in[1] == '\\')) {
std::string rest = in.substr(1); // keep leading slash
return std::string(home) + rest;
}
}
return in;
};
std::string text = ctx.editor.PromptText();
// Build a path and split dir + base prefix
std::string expanded = expand_user_path(text);
std::filesystem::path p(expanded);
std::filesystem::path dir;
std::string base;
if (expanded.empty()) {
dir = std::filesystem::current_path();
base.clear();
} else if (std::filesystem::is_directory(p)) {
dir = p;
base.clear();
} else {
dir = p.parent_path();
base = p.filename().string();
if (dir.empty())
dir = std::filesystem::current_path();
}
std::error_code ec;
std::vector<std::filesystem::directory_entry> entries;
std::filesystem::directory_iterator it(dir, ec), end;
for (; !ec && it != end; it.increment(ec)) {
entries.push_back(*it);
}
// Filter by base prefix
std::vector<std::string> cands;
for (const auto &de: entries) {
std::string name = de.path().filename().string();
if (base.empty() || name.rfind(base, 0) == 0) {
std::string candidate = (dir / name).string();
// For dirs, add trailing slash hint
if (de.is_directory(ec))
candidate += "/";
cands.push_back(candidate);
}
}
// If no candidates, keep as-is
if (cands.empty()) {
// no-op
} else if (cands.size() == 1) {
ctx.editor.SetPromptText(cands[0]);
} else {
// Longest common prefix of display strings
auto lcp = cands[0];
for (size_t i = 1; i < cands.size(); ++i) {
const auto &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 != ctx.editor.PromptText()) {
ctx.editor.SetPromptText(lcp);
} else {
// Show some choices in status (trim to avoid spam)
std::string msg = ctx.editor.PromptLabel() + ": ";
size_t shown = 0;
for (const auto &s: cands) {
if (shown >= 10) {
msg += " …";
break;
}
if (shown > 0)
msg += ' ';
msg += std::filesystem::path(s).filename().string();
++shown;
}
ctx.editor.SetStatus(msg);
return true;
}
}
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<std::string> 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") {
#if defined(KTE_BUILD_GUI)
# if !defined(KTE_USE_QT)
std::vector<std::string> cands;
const auto &reg = kte::ThemeRegistry();
for (const auto &t: reg) {
std::string n = t->Name();
if (argprefix.empty() || n.rfind(argprefix, 0) == 0)
cands.push_back(n);
}
# else
// Qt: offer known theme names handled by ApplyQtThemeByName
static const char *qt_themes[] = {
"nord",
"solarized-dark",
"solarized-light",
"gruvbox-dark",
"gruvbox-light",
"eink"
};
std::vector<std::string> cands;
for (const char *t: qt_themes) {
std::string n(t);
if (argprefix.empty() || n.rfind(argprefix, 0) == 0)
cands.push_back(n);
}
# endif
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
}
if (cmd == "font") {
std::vector<std::string> cands;
std::string apfx_lower = argprefix;
std::transform(apfx_lower.begin(), apfx_lower.end(), apfx_lower.begin(),
[](unsigned char c) {
return (char) std::tolower(c);
});
#if defined(KTE_BUILD_GUI) && defined(KTE_USE_QT)
// Qt: complete against system font families
QStringList fams = QFontDatabase::families();
for (const auto &fam: fams) {
std::string n = fam.toStdString();
std::string nlower = n;
std::transform(nlower.begin(), nlower.end(), nlower.begin(),
[](unsigned char c) {
return (char) std::tolower(c);
});
if (apfx_lower.empty() || nlower.rfind(apfx_lower, 0) == 0)
cands.push_back(n);
}
#elif defined(KTE_BUILD_GUI)
// ImGui: complete against embedded font registry
for (const auto &n : kte::Fonts::FontRegistry::Instance().FontNames()) {
if (apfx_lower.empty() || n.rfind(apfx_lower, 0) == 0)
cands.push_back(n);
}
#endif
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;
}
if (cmd == "mode") {
std::vector<std::string> modes = {"code", "writing"};
std::vector<std::string> cands;
for (const auto &m : modes) {
if (argprefix.empty() || m.rfind(argprefix, 0) == 0)
cands.push_back(m);
}
if (cands.size() == 1) {
ctx.editor.SetPromptText(cmd + std::string(" ") + cands[0]);
}
ctx.editor.SetStatus(std::string(": ") + ctx.editor.PromptText());
return true;
}
// 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<int>(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<int>(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
if (ctx.editor.SearchActive()) {
std::string q = ctx.editor.SearchQuery();
q += ctx.arg; // arg already printable text
ctx.editor.SetSearchQuery(q);
// Recompute matches and move cursor to current index
const auto &rows = rows_of(*buf);
std::vector<std::pair<std::size_t, std::size_t> > matches;
if (!q.empty()) {
for (std::size_t y = 0; y < rows.size(); ++y) {
std::string line = static_cast<std::string>(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();
}
}
}
if (matches.empty()) {
ctx.editor.SetSearchMatch(0, 0, 0);
// Restore to origin if available
if (ctx.editor.SearchOriginSet()) {
buf->SetCursor(ctx.editor.SearchOrigX(), ctx.editor.SearchOrigY());
buf->SetOffsets(ctx.editor.SearchOrigRowoffs(), ctx.editor.SearchOrigColoffs());
}
ctx.editor.SetSearchIndex(-1);
ctx.editor.SetStatus("Find: " + q);
} else {
int idx = ctx.editor.SearchIndex();
if (idx < 0 || idx >= static_cast<int>(matches.size()))
idx = 0;
auto [y, x] = matches[static_cast<std::size_t>(idx)];
ctx.editor.SetSearchMatch(y, x, q.size());
buf->SetCursor(x, y);
ensure_cursor_visible(ctx.editor, *buf);
char tmp[64];
snprintf(tmp, sizeof(tmp), "%d/%zu", idx + 1, matches.size());
ctx.editor.SetStatus(std::string("Find: ") + q + " " + tmp);
ctx.editor.SetSearchIndex(idx);
}
return true;
}
// Disallow newlines in InsertText; they should come via Newline
if (ctx.arg.find('\n') != std::string::npos || ctx.arg.find('\r') != std::string::npos) {
ctx.editor.SetStatus("InsertText arg must not contain newlines");
return false;
}
ensure_at_least_one_line(*buf);
std::size_t y = buf->Cury();
std::size_t x = buf->Curx();
int repeat = ctx.count > 0 ? ctx.count : 1;
std::size_t cx = x;
std::size_t cy = y;
// Visual-line mode: broadcast inserts to each selected line at the same column.
if (buf->VisualLineActive()) {
const std::size_t sy = buf->VisualLineStartY();
const std::size_t ey = buf->VisualLineEndY();
const auto &rows = rows_of(*buf);
UndoSystem *u = buf->Undo();
std::uint64_t gid = 0;
if (u)
gid = u->BeginGroup();
(void) gid;
std::string ins;
if (repeat == 1) {
ins = ctx.arg;
} else {
ins.reserve(ctx.arg.size() * static_cast<std::size_t>(repeat));
for (int i = 0; i < repeat; ++i)
ins += ctx.arg;
}
for (std::size_t yy = sy; yy <= ey; ++yy) {
if (yy >= rows.size())
break;
std::size_t xx = x;
if (xx > rows[yy].size())
xx = rows[yy].size();
if (!ins.empty()) {
buf->SetCursor(xx, yy);
if (u)
u->Begin(UndoType::Insert);
buf->insert_text(static_cast<int>(yy), static_cast<int>(xx), std::string_view(ins));
xx += ins.size();
if (u) {
u->Append(std::string_view(ins));
u->commit();
}
}
if (yy == y) {
cx = xx;
cy = yy;
}
}
if (u)
u->EndGroup();
buf->SetDirty(true);
buf->SetCursor(cx, cy);
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
UndoSystem *u = buf->Undo();
if (u) {
// Start/extend a typed-run batch. Do NOT commit here; commit happens on boundaries
// (cursor movement, prompts, undo/redo, etc.) so consecutive InsertText commands coalesce.
buf->SetCursor(x, y);
u->Begin(UndoType::Insert);
}
// Apply edits to the underlying PieceTable through Buffer::insert_text,
// not directly to the legacy rows_ cache. This ensures Save() persists text.
for (int i = 0; i < repeat; ++i) {
buf->insert_text(static_cast<int>(y), static_cast<int>(x), std::string_view(ctx.arg));
if (u)
u->Append(std::string_view(ctx.arg));
x += ctx.arg.size();
}
buf->SetDirty(true);
buf->SetCursor(x, y);
ensure_cursor_visible(ctx.editor, *buf);
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;
}
// Open or refresh the +HELP+ buffer with content from docs/kte.1
static bool
cmd_show_help(CommandContext &ctx)
{
const std::string help_name = "+HELP+";
// Try to locate existing +HELP+ buffer
std::vector<Buffer> &bufs = ctx.editor.Buffers();
std::size_t help_index = static_cast<std::size_t>(-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<char>(std::toupper(static_cast<unsigned char>(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;
}
}
// 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<char>(in)), std::istreambuf_iterator<char>());
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) {
// Clear existing content
b.replace_all_bytes("");
// Parse text and insert rows
std::string line;
line.reserve(128);
int row_idx = 0;
for (char ch: text) {
if (ch == '\n') {
b.insert_row(row_idx++, line);
line.clear();
} else if (ch != '\r') {
line.push_back(ch);
}
}
// Add last line (even if empty)
b.insert_row(row_idx, line);
b.SetDirty(false);
b.SetCursor(0, 0);
b.SetOffsets(0, 0);
b.SetRenderX(0);
};
if (help_index != static_cast<std::size_t>(-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;
}
static bool
cmd_newline(CommandContext &ctx)
{
// If a prompt is active, accept it and perform the associated action
if (ctx.editor.PromptActive()) {
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);
ctx.editor.SetSearchMatch(0, 0, 0);
ctx.editor.ClearSearchOrigin();
ctx.editor.SetStatus(kind == Editor::PromptKind::RegexSearch ? "Regex find done" : "Find done");
Buffer *b = ctx.editor.CurrentBuffer();
if (b)
ensure_cursor_visible(ctx.editor, *b);
} else if (kind == Editor::PromptKind::ReplaceFind) {
// Proceed to replacement text prompt
ctx.editor.SetReplaceFindTmp(value);
// Keep search highlights active using the collected find string
ctx.editor.SetSearchActive(true);
ctx.editor.SetSearchQuery(value);
if (Buffer *b = ctx.editor.CurrentBuffer()) {
auto matches = search_compute_matches(*b, ctx.editor.SearchQuery());
search_apply_match(ctx.editor, *b, matches);
}
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);
if (find.empty()) {
ctx.editor.SetStatus("Replace canceled (empty find)");
// 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;
}
// Save original cursor to restore after operations
std::size_t orig_x = buf->Curx();
std::size_t orig_y = buf->Cury();
std::size_t total = 0;
UndoSystem *u = buf->Undo();
if (u)
u->commit(); // end any pending batch
{
// One undo step for the whole replace-all; EndGroup also commits the
// final node so later typing cannot coalesce into it.
UndoGroupGuard group(u);
// The prompt input is single-line, so the row count is stable. Keep a
// local copy of each line in step with the edits instead of
// re-fetching (and rebuilding) Rows() after every replacement.
const std::size_t nrows = buf->Nrows();
for (std::size_t y = 0; y < nrows && !find.empty(); ++y) {
std::string line = buf->GetLineString(y);
std::size_t pos = 0;
while (true) {
std::size_t p = line.find(find, pos);
if (p == std::string::npos)
break;
// Delete matched segment from the piece table
buf->delete_text(static_cast<int>(y), static_cast<int>(p), find.size());
if (u) {
buf->SetCursor(p, y);
u->Begin(UndoType::Delete);
u->Append(std::string_view(find));
}
// Insert replacement if provided
if (!with.empty()) {
buf->insert_text(static_cast<int>(y), static_cast<int>(p),
std::string_view(with));
if (u) {
buf->SetCursor(p, y);
u->Begin(UndoType::Insert);
u->Append(std::string_view(with));
}
}
line.replace(p, find.size(), with);
// Resume just past the replacement. For an empty replacement that
// is `p` itself (not p+1), which catches adjacent matches, e.g.
// "aaaa" -> "" replacing "aa".
pos = p + with.size();
++total;
}
}
}
buf->SetDirty(true);
// Restore original cursor, clamped: replacements may have shortened the line.
if (orig_y < buf->Nrows())
buf->SetCursor(orig_x, orig_y);
clamp_cursor_to_buffer(*buf);
ensure_cursor_visible(ctx.editor, *buf);
char msg[128];
std::snprintf(msg, sizeof(msg), "Replaced %zu occurrence%s", total, (total == 1 ? "" : "s"));
ctx.editor.SetStatus(msg);
// Clear search-highlighting state after replace completes
ctx.editor.SetSearchActive(false);
ctx.editor.SetSearchQuery("");
ctx.editor.SetSearchMatch(0, 0, 0);
ctx.editor.ClearSearchOrigin();
ctx.editor.SetSearchIndex(-1);
return true;
} else if (kind == Editor::PromptKind::OpenFile) {
// Expand "~" to the user's home directory
auto expand_user_path = [](const std::string &in) -> std::string {
if (!in.empty() && in[0] == '~') {
const char *home = std::getenv("HOME");
if (home && in.size() == 1)
return std::string(home);
if (home && (in.size() > 1) && (in[1] == '/' || in[1] == '\\')) {
std::string rest = in.substr(1);
return std::string(home) + rest;
}
}
return in;
};
value = expand_user_path(value);
if (value.empty()) {
ctx.editor.SetStatus("Open canceled (empty)");
} else {
ctx.editor.RequestOpenFile(value);
const bool opened = ctx.editor.ProcessPendingOpens();
if (ctx.editor.PromptActive()) {
// A recovery confirmation prompt was started.
return true;
}
if (opened) {
// Center the view on the cursor (e.g. if the buffer restored a cursor position)
cmd_center_on_cursor(ctx);
// Close the prompt so subsequent typing edits the buffer, not the prompt
ctx.editor.CancelPrompt();
}
}
} else if (kind == Editor::PromptKind::BufferSwitch) {
// Resolve to a buffer index by exact match against path or basename;
// if multiple partial matches, prefer exact; if none, keep status.
const auto &bs = ctx.editor.Buffers();
std::vector<std::size_t> matches;
for (std::size_t i = 0; i < bs.size(); ++i) {
if (value == buffer_display_name(bs[i]) || value == buffer_basename(bs[i])) {
matches.push_back(i);
}
}
if (matches.empty()) {
// Try prefix match if no exact
for (std::size_t i = 0; i < bs.size(); ++i) {
const std::string full = buffer_display_name(bs[i]);
const std::string base = buffer_basename(bs[i]);
if ((!value.empty() && full.rfind(value, 0) == 0) || (
!value.empty() && base.rfind(value, 0) == 0)) {
matches.push_back(i);
}
}
}
if (matches.empty()) {
ctx.editor.SetStatus("No such buffer: " + value);
} else {
ctx.editor.SwitchTo(matches[0]);
const Buffer *cur = ctx.editor.CurrentBuffer();
ctx.editor.SetStatus(std::string("Switched: ")
+ (cur ? buffer_display_name(*cur) : std::string("")));
}
} else if (kind == Editor::PromptKind::SaveAs) {
if (value.empty()) {
ctx.editor.SetStatus("Save canceled (empty filename)");
} else {
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf) {
ctx.editor.SetStatus("No buffer to save");
} else {
// Expand "~" for save path
auto expand_user_path = [](const std::string &in) -> std::string {
if (!in.empty() && in[0] == '~') {
const char *home = std::getenv("HOME");
if (home && in.size() == 1)
return std::string(home);
if (home && (in.size() > 1) && (
in[1] == '/' || in[1] == '\\')) {
std::string rest = in.substr(1);
return std::string(home) + rest;
}
}
return in;
};
value = expand_user_path(value);
// Ask before overwriting any existing file other than the
// buffer's own (it used to ask only for unnamed buffers).
if (std::filesystem::exists(value) && !is_buffers_own_file(*buf, value)) {
ctx.editor.StartPrompt(Editor::PromptKind::Confirm, "Overwrite", "");
ctx.editor.SetPendingOverwritePath(value);
ctx.editor.SetStatus(
std::string("Overwrite existing file '") + value + "'? (y/N)");
} else {
std::string err;
if (!buf->SaveAs(value, err)) {
ctx.editor.SetStatus(err);
} else {
buf->SetDirty(false);
if (auto *sm = ctx.editor.Swap()) {
sm->NotifyFilenameChanged(*buf);
sm->ResetJournal(*buf);
}
ctx.editor.SetStatus("Saved as " + value);
if (auto *u = buf->Undo())
u->mark_saved();
// Close the prompt on successful save-as
ctx.editor.CancelPrompt();
// If a close-after-save was requested (from closing a dirty, unnamed buffer),
// close the buffer now.
if (ctx.editor.CloseAfterSave()) {
ctx.editor.SetCloseAfterSave(false);
std::size_t idx_close = ctx.editor.CurrentBufferIndex();
std::string name_close = buffer_display_name(*buf);
if (buf->Undo())
buf->Undo()->discard_pending();
ctx.editor.CloseBuffer(idx_close);
if (ctx.editor.BufferCount() == 0) {
Buffer empty;
ctx.editor.AddBuffer(std::move(empty));
ctx.editor.SwitchTo(0);
}
const Buffer *cur = ctx.editor.CurrentBuffer();
ctx.editor.SetStatus(
std::string("Closed: ") + name_close +
std::string(" Now: ")
+ (cur
? buffer_display_name(*cur)
: std::string("")));
}
}
}
}
}
} else if (kind == Editor::PromptKind::Confirm) {
// Confirmation for potentially destructive operations (e.g., overwrite on save-as)
Buffer *buf = ctx.editor.CurrentBuffer();
const std::string target = ctx.editor.PendingOverwritePath();
if (!target.empty() && buf) {
bool yes = false;
if (!value.empty()) {
char c = value[0];
yes = (c == 'y' || c == 'Y');
}
if (yes) {
std::string err;
const bool is_same_target = (buf->Filename() == target) && buf->IsFileBacked();
const bool ok = is_same_target ? buf->Save(err) : buf->SaveAs(target, err);
if (!ok) {
ctx.editor.SetStatus(err);
} else {
buf->SetDirty(false);
if (auto *sm = ctx.editor.Swap()) {
if (!is_same_target)
sm->NotifyFilenameChanged(*buf);
sm->ResetJournal(*buf);
}
ctx.editor.SetStatus(
is_same_target ? ("Saved " + target) : ("Saved as " + target));
if (auto *u = buf->Undo())
u->mark_saved();
// If this overwrite confirm was part of a close-after-save flow, close now.
if (ctx.editor.CloseAfterSave()) {
ctx.editor.SetCloseAfterSave(false);
std::size_t idx_close = ctx.editor.CurrentBufferIndex();
std::string name_close = buffer_display_name(*buf);
if (buf->Undo())
buf->Undo()->discard_pending();
ctx.editor.CloseBuffer(idx_close);
if (ctx.editor.BufferCount() == 0) {
Buffer empty;
ctx.editor.AddBuffer(std::move(empty));
ctx.editor.SwitchTo(0);
}
const Buffer *cur = ctx.editor.CurrentBuffer();
ctx.editor.SetStatus(
std::string("Closed: ") + name_close + std::string(
" Now: ")
+ (cur ? buffer_display_name(*cur) : std::string("")));
}
// Close the prompt after successful confirmation
ctx.editor.CancelPrompt();
}
} else {
ctx.editor.SetStatus("Save canceled");
// Close the prompt after negative confirmation
ctx.editor.CancelPrompt();
}
ctx.editor.ClearPendingOverwritePath();
// Regardless of answer, end any close-after-save pending state for safety.
ctx.editor.SetCloseAfterSave(false);
} else if (ctx.editor.PendingRecoveryPrompt() != Editor::RecoveryPromptKind::None) {
bool yes = false;
if (!value.empty()) {
char c = value[0];
yes = (c == 'y' || c == 'Y');
}
(void) ctx.editor.ResolveRecoveryPrompt(yes);
ctx.editor.CancelPrompt();
// Continue any queued opens (e.g., startup argv files).
ctx.editor.ProcessPendingOpens();
} else if (ctx.editor.CloseConfirmPending() && buf) {
bool yes = false;
if (!value.empty()) {
char c = value[0];
yes = (c == 'y' || c == 'Y');
}
// Prepare close details
std::size_t idx_close = ctx.editor.CurrentBufferIndex();
std::string name_close = buffer_display_name(*buf);
bool proceed_to_close = true;
if (yes) {
std::string err;
if (buf->IsFileBacked()) {
if (!buf->Save(err)) {
ctx.editor.SetStatus(err);
proceed_to_close = false;
} else {
buf->SetDirty(false);
if (auto *sm = ctx.editor.Swap())
sm->ResetJournal(*buf);
if (auto *u = buf->Undo())
u->mark_saved();
}
} else if (!buf->Filename().empty()) {
if (!buf->SaveAs(buf->Filename(), err)) {
ctx.editor.SetStatus(err);
proceed_to_close = false;
} else {
buf->SetDirty(false);
if (auto *sm = ctx.editor.Swap()) {
sm->NotifyFilenameChanged(*buf);
sm->ResetJournal(*buf);
}
if (auto *u = buf->Undo())
u->mark_saved();
}
} else {
// No filename; fall back to Save As flow and set close-after-save
ctx.editor.StartPrompt(Editor::PromptKind::SaveAs, "Save as", "");
ctx.editor.SetCloseAfterSave(true);
ctx.editor.SetStatus("Save as: ");
ctx.editor.SetCloseConfirmPending(false);
return true;
}
}
if (proceed_to_close) {
if (buf->Undo())
buf->Undo()->discard_pending();
ctx.editor.CloseBuffer(idx_close);
if (ctx.editor.BufferCount() == 0) {
Buffer empty;
ctx.editor.AddBuffer(std::move(empty));
ctx.editor.SwitchTo(0);
}
const Buffer *cur = ctx.editor.CurrentBuffer();
ctx.editor.SetStatus(
std::string("Closed: ") + name_close + std::string(" Now: ")
+ (cur ? buffer_display_name(*cur) : std::string("")));
}
ctx.editor.SetCloseConfirmPending(false);
} else {
ctx.editor.SetStatus("Nothing to confirm");
}
} else if (kind == Editor::PromptKind::GotoLine) {
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf) {
ctx.editor.SetStatus("No buffer");
return true;
}
if (auto *u = buf->Undo())
u->commit();
std::size_t nrows = buf->Nrows();
if (nrows == 0) {
buf->SetCursor(0, 0);
ensure_cursor_visible(ctx.editor, *buf);
ctx.editor.SetStatus("Empty buffer");
return true;
}
// Parse 1-based line number; on failure, keep cursor and show status
std::size_t line1 = 0;
try {
if (!value.empty())
line1 = static_cast<std::size_t>(std::stoull(value));
} catch (...) {
line1 = 0;
}
if (line1 == 0) {
ctx.editor.SetStatus("Goto canceled (invalid line)");
return true;
}
std::size_t y = line1 - 1; // convert to 0-based
if (y >= nrows)
y = nrows - 1; // clamp to last line
buf->SetCursor(0, y);
ensure_cursor_visible(ctx.editor, *buf);
ctx.editor.SetStatus("Goto line " + std::to_string(line1));
} else if (kind == Editor::PromptKind::Chdir) {
// Attempt to change the current working directory
if (value.empty()) {
ctx.editor.SetStatus("chdir canceled (empty)");
return true;
}
try {
// Expand "~" for chdir
auto expand_user_path = [](const std::string &in) -> std::string {
if (!in.empty() && in[0] == '~') {
const char *home = std::getenv("HOME");
if (home && in.size() == 1)
return std::string(home);
if (home && (in.size() > 1) && (in[1] == '/' || in[1] == '\\')) {
std::string rest = in.substr(1);
return std::string(home) + rest;
}
}
return in;
};
value = expand_user_path(value);
std::filesystem::path p(value);
std::error_code ec;
// Expand if value is relative: resolve against current_path implicitly
if (!std::filesystem::exists(p, ec)) {
ctx.editor.SetStatus(std::string("chdir: no such path: ") + value);
return true;
}
if (!std::filesystem::is_directory(p, ec)) {
ctx.editor.SetStatus(std::string("chdir: not a directory: ") + value);
return true;
}
std::filesystem::current_path(p);
ctx.editor.SetStatus(std::string("cwd: ") + std::filesystem::current_path().string());
} catch (const std::exception &e) {
ctx.editor.SetStatus(std::string("chdir failed: ") + e.what());
}
} else if (kind == Editor::PromptKind::RegexReplaceFind) {
// Proceed to regex replacement text prompt
ctx.editor.SetReplaceFindTmp(value);
// Keep search highlights active using the collected regex pattern
ctx.editor.SetSearchActive(true);
ctx.editor.SetSearchQuery(value);
if (Buffer *b = ctx.editor.CurrentBuffer()) {
std::string err;
auto rm = search_compute_matches_regex(*b, ctx.editor.SearchQuery(), err);
if (!err.empty()) {
ctx.editor.SetStatus(std::string("Regex: ") + value + " [error: " + err + "]");
}
search_apply_match_regex(ctx.editor, *b, rm);
}
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);
if (patt.empty()) {
ctx.editor.SetStatus("Regex replace canceled (empty pattern)");
ctx.editor.SetSearchActive(false);
ctx.editor.SetSearchQuery("");
ctx.editor.SetSearchMatch(0, 0, 0);
ctx.editor.ClearSearchOrigin();
ctx.editor.SetSearchIndex(-1);
return true;
}
std::regex rx;
try {
rx = std::regex(patt);
} catch (const std::regex_error &e) {
ctx.editor.SetStatus(std::string("Regex error: ") + e.what());
// 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;
}
std::size_t changed = 0;
UndoSystem *ru = buf->Undo();
UndoGroupGuard rguard(ru);
// When the buffer ends with '\n', the last row is the empty position
// after it, not a line; a zero-width pattern like ^ must not match it.
std::size_t nrows = buf->Nrows();
if (nrows > 1 && buf->GetLineString(nrows - 1).empty())
--nrows;
// std::regex recurses per matched character; see RegexGuard.h. The
// caller waits for the worker, so the buffer is never shared.
kte::RunWithLargeStack([&] {
for (std::size_t y = 0; y < nrows; ++y) {
const std::string before = buf->GetLineString(y);
const std::string after = std::regex_replace(before, rx, repl);
if (after != before) {
replace_rows_text(*buf, y, before, after, ru);
// A replacement containing newlines adds rows; skip past them.
const auto added = static_cast<std::size_t>(std::count(after.begin(), after.end(), '\n'));
y += added;
nrows += added;
++changed;
}
}
});
clamp_cursor_to_buffer(*buf);
buf->SetDirty(true);
ctx.editor.SetStatus("Regex replaced in " + std::to_string(changed) + " line(s)");
// Clear search UI state
ctx.editor.SetSearchActive(false);
ctx.editor.SetSearchQuery("");
ctx.editor.SetSearchMatch(0, 0, 0);
ctx.editor.ClearSearchOrigin();
ctx.editor.SetSearchIndex(-1);
if (auto *b = ctx.editor.CurrentBuffer())
ensure_cursor_visible(ctx.editor, *b);
return true;
}
return true;
}
// In search mode, Enter accepts the current match and exits search
if (ctx.editor.SearchActive()) {
ctx.editor.SetSearchActive(false);
ctx.editor.SetSearchMatch(0, 0, 0);
ctx.editor.ClearSearchOrigin();
ctx.editor.SetStatus("Find done");
Buffer *buf = ctx.editor.CurrentBuffer();
if (buf)
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf) {
ctx.editor.SetStatus("No buffer to edit");
return false;
}
ensure_at_least_one_line(*buf);
std::size_t y = buf->Cury();
std::size_t x = buf->Curx();
int repeat = ctx.count > 0 ? ctx.count : 1;
// Visual-line mode: broadcast newline splits across selected lines.
if (buf->VisualLineActive()) {
const std::size_t sy = buf->VisualLineStartY();
const std::size_t ey = buf->VisualLineEndY();
if (buf->Nrows() == 0)
return true;
std::size_t splits_above = 0;
if (sy < y)
splits_above = std::min(ey, y - 1) - sy + 1;
// Every split is recorded, as one undo step. Begin() takes the node's
// position from the cursor, so place the cursor at each split first.
UndoSystem *vu = buf->Undo();
UndoGroupGuard vguard(vu);
auto split_at = [&](std::size_t row, std::size_t col) {
buf->SetCursor(col, row);
if (vu) {
vu->Begin(UndoType::Newline);
vu->commit();
}
buf->split_line(static_cast<int>(row), static_cast<int>(col));
};
// Iterate bottom-up to keep row indices stable while splitting.
const std::size_t nrows = buf->Nrows();
for (std::size_t yy = ey + 1; yy-- > sy;) {
if (yy >= nrows)
continue;
const std::size_t xx = std::min(x, buf->GetLineString(yy).size());
// First split at the cursor column; subsequent splits create blank lines.
split_at(yy, xx);
for (int i = 1; i < repeat; ++i)
split_at(yy + static_cast<std::size_t>(i), 0);
}
buf->SetDirty(true);
// Cursor: end up on the final inserted line for the original cursor line.
// Each selected line above the cursor gained `repeat` lines.
std::size_t new_y = y + static_cast<std::size_t>(repeat);
new_y += splits_above * static_cast<std::size_t>(repeat);
buf->SetCursor(0, new_y);
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
UndoSystem *u = buf->Undo();
if (u && repeat > 1)
u->BeginGroup();
for (int i = 0; i < repeat; ++i) {
// Sync the buffer's cursor to the split point before Begin(), which
// records its node's row/col from the buffer's *current* cursor. Undo
// must reverse this exact split, not wherever the cursor ends up after
// the whole loop finishes.
buf->SetCursor(x, y);
if (u) {
u->Begin(UndoType::Newline);
u->commit();
}
buf->split_line(static_cast<int>(y), static_cast<int>(x));
// Move to start of next line
y += 1;
x = 0;
}
if (u && repeat > 1)
u->EndGroup();
buf->SetCursor(x, y);
buf->SetDirty(true);
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
static bool
cmd_smart_newline(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf) {
ctx.editor.SetStatus("No buffer to edit");
return false;
}
// With a prompt open, Enter accepts the prompt; there is no line to indent,
// and accepting may switch or reallocate buffers, invalidating `buf`.
if (ctx.editor.PromptActive())
return cmd_newline(ctx);
if (buf->IsReadOnly()) {
ctx.editor.SetStatus("Read-only buffer");
return true;
}
// Smart newline behavior: add a newline with the same indentation as the current line.
// Find indentation of current line
std::size_t y = buf->Cury();
std::string line = buf->GetLineString(y);
std::string indent;
for (char c: line) {
if (c == ' ' || c == '\t') {
indent += c;
} else {
break;
}
}
// The newline and its indent form one undo step.
UndoGroupGuard group(buf->Undo());
// Perform standard newline first
if (!cmd_newline(ctx)) {
return false;
}
// Now insert the indentation at the new cursor position
if (!indent.empty()) {
std::size_t new_y = buf->Cury();
std::size_t new_x = buf->Curx();
// Begin() records the cursor position, so it must run before the
// cursor moves past the inserted indent.
UndoSystem *u = buf->Undo();
if (u) {
u->Begin(UndoType::Insert);
u->Append(indent);
}
buf->insert_text(static_cast<int>(new_y), static_cast<int>(new_x), indent);
buf->SetCursor(new_x + indent.size(), new_y);
buf->SetDirty(true);
if (u)
u->commit();
}
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
static bool
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;
}
// In search mode, backspace edits the query
if (ctx.editor.SearchActive()) {
if (!ctx.editor.SearchQuery().empty()) {
std::string q = ctx.editor.SearchQuery();
q.pop_back();
ctx.editor.SetSearchQuery(q);
}
Buffer *buf2 = ctx.editor.CurrentBuffer();
if (buf2) {
auto matches = search_compute_matches(*buf2, ctx.editor.SearchQuery());
search_apply_match(ctx.editor, *buf2, matches);
}
return true;
}
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf) {
ctx.editor.SetStatus("No buffer to edit");
return false;
}
ensure_at_least_one_line(*buf);
std::size_t y = buf->Cury();
std::size_t x = buf->Curx();
UndoSystem *u = buf->Undo();
int repeat = ctx.count > 0 ? ctx.count : 1;
// Visual-line mode: broadcast backspace deletes within each selected line.
// For now, we do NOT join lines when at column 0 (too ambiguous across multiple lines).
if (buf->VisualLineActive()) {
const std::size_t sy = buf->VisualLineStartY();
const std::size_t ey = buf->VisualLineEndY();
const auto &rows = rows_of(*buf);
std::uint64_t gid = 0;
if (u)
gid = u->BeginGroup();
(void) gid;
std::size_t cx = x;
for (std::size_t yy = sy; yy <= ey; ++yy) {
if (yy >= rows.size())
break;
std::size_t xx = x;
if (xx > rows[yy].size())
xx = rows[yy].size();
std::string deleted;
for (int i = 0; i < repeat; ++i) {
if (xx == 0)
break;
const auto &rows_view = rows_of(*buf);
if (yy >= rows_view.size())
break;
const std::string_view lv = line_view(rows_view[yy]);
const std::size_t n = utf8_prev_len(lv, xx);
deleted.insert(0, std::string(lv.substr(xx - n, n)));
buf->delete_text(static_cast<int>(yy), static_cast<int>(xx - n), n);
xx -= n;
}
if (u && !deleted.empty()) {
buf->SetCursor(xx, yy);
u->Begin(UndoType::Delete);
u->Append(std::string_view(deleted));
u->commit();
}
if (yy == y)
cx = xx;
}
if (u)
u->EndGroup();
buf->SetDirty(true);
buf->SetCursor(cx, y);
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
for (int i = 0; i < repeat; ++i) {
// Refresh a read-only view of lines for char capture/lengths
const auto &rows_view = rows_of(*buf);
if (x > 0) {
std::string deleted;
std::size_t n = 1;
if (y < rows_view.size()) {
const std::string_view lv = line_view(rows_view[y]);
if (x > lv.size())
x = lv.size(); // never delete past end-of-line
n = utf8_prev_len(lv, x);
if (x > 0)
deleted.assign(lv.substr(x - n, n));
}
if (deleted.empty()) {
buf->SetCursor(x, y);
continue;
}
buf->delete_text(static_cast<int>(y), static_cast<int>(x - n), n);
x -= n;
buf->SetCursor(x, y);
if (u) {
u->Begin(UndoType::Delete);
u->Append(std::string_view(deleted));
}
} else if (y > 0) {
// Compute previous line length before join
std::size_t prev_len = 0;
if (y - 1 < rows_view.size())
prev_len = rows_view[y - 1].size();
buf->join_lines(static_cast<int>(y - 1));
y = y - 1;
x = prev_len;
buf->SetCursor(x, y);
if (u) {
// Forward action here is a join, not a split: JoinLines has the
// correct (inverted) apply() semantics, unlike Newline.
u->Begin(UndoType::JoinLines);
u->commit();
}
} else {
break;
}
}
buf->SetCursor(x, y);
buf->SetDirty(true);
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
static bool
cmd_delete_char(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf) {
ctx.editor.SetStatus("No buffer to edit");
return false;
}
ensure_at_least_one_line(*buf);
std::size_t y = buf->Cury();
std::size_t x = buf->Curx();
UndoSystem *u = buf->Undo();
int repeat = ctx.count > 0 ? ctx.count : 1;
// Visual-line mode: broadcast delete-char within each selected line.
// For now, we do NOT join lines when at end-of-line.
if (buf->VisualLineActive()) {
const std::size_t sy = buf->VisualLineStartY();
const std::size_t ey = buf->VisualLineEndY();
const auto &rows = rows_of(*buf);
std::uint64_t gid = 0;
if (u)
gid = u->BeginGroup();
(void) gid;
for (std::size_t yy = sy; yy <= ey; ++yy) {
if (yy >= rows.size())
break;
std::size_t xx = x;
if (xx > rows[yy].size())
xx = rows[yy].size();
std::string deleted;
for (int i = 0; i < repeat; ++i) {
const auto &rows_view = rows_of(*buf);
if (yy >= rows_view.size() || xx >= rows_view[yy].size())
break;
const std::string_view lv = line_view(rows_view[yy]);
const std::size_t n = utf8_next_len(lv, xx);
deleted.append(lv.substr(xx, n));
buf->delete_text(static_cast<int>(yy), static_cast<int>(xx), n);
}
if (u && !deleted.empty()) {
buf->SetCursor(xx, yy);
u->Begin(UndoType::Delete);
u->Append(std::string_view(deleted));
u->commit();
}
}
if (u)
u->EndGroup();
buf->SetDirty(true);
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
for (int i = 0; i < repeat; ++i) {
const auto &rows_view = rows_of(*buf);
if (y >= rows_view.size())
break;
if (x < rows_view[y].size()) {
const std::string_view lv = line_view(rows_view[y]);
const std::size_t n = utf8_next_len(lv, x);
const std::string deleted(lv.substr(x, n));
buf->delete_text(static_cast<int>(y), static_cast<int>(x), n);
if (u) {
u->Begin(UndoType::Delete);
u->Append(std::string_view(deleted));
}
} else if (y + 1 < rows_view.size()) {
buf->join_lines(static_cast<int>(y));
if (u) {
// Forward action here is a join, not a split: JoinLines has the
// correct (inverted) apply() semantics, unlike Newline.
u->Begin(UndoType::JoinLines);
u->commit();
}
} else {
break;
}
}
buf->SetDirty(true);
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
// --- Undo/Redo ---
static bool
cmd_undo(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
if (auto *u = buf->Undo()) {
// Ensure pending batch is finalized so it can be undone
u->commit();
int repeat = ctx.count > 0 ? ctx.count : 1;
for (int i = 0; i < repeat; ++i)
u->undo();
// Keep cursor within buffer bounds
ensure_cursor_visible(ctx.editor, *buf);
ctx.editor.SetStatus("Undone");
return true;
}
return false;
}
static bool
cmd_redo(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
if (auto *u = buf->Undo()) {
// Finalize any pending batch before redoing
u->commit();
// With branching undo, a universal-argument count selects an alternate redo branch:
// - no count (or 1): redo the active branch
// - n>1: redo the (n-1)th sibling branch from this point and make it active
if (ctx.count > 1) {
u->redo(ctx.count - 1);
} else {
u->redo();
}
ensure_cursor_visible(ctx.editor, *buf);
ctx.editor.SetStatus("Redone");
return true;
}
return false;
}
static bool
cmd_kill_to_eol(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf) {
ctx.editor.SetStatus("No buffer to edit");
return false;
}
ensure_at_least_one_line(*buf);
std::size_t y = buf->Cury();
std::size_t x = buf->Curx();
int repeat = ctx.count > 0 ? ctx.count : 1;
std::string killed_total;
UndoSystem *u = buf->Undo();
UndoGroupGuard guard(u);
for (int i = 0; i < repeat; ++i) {
const auto &rows_view = rows_of(*buf);
if (y >= rows_view.size())
break;
if (x < rows_view[y].size()) {
// delete from cursor to end of line
std::string seg = static_cast<std::string>(rows_view[y].substr(x));
killed_total += seg;
std::size_t len = rows_view[y].size() - x;
buf->delete_text(static_cast<int>(y), static_cast<int>(x), len);
if (u) {
buf->SetCursor(x, y);
u->Begin(UndoType::Delete);
u->Append(std::string_view(seg));
u->commit();
}
} else if (y + 1 < rows_view.size()) {
// at EOL: delete the newline (join with next line)
killed_total += "\n";
if (u) {
buf->SetCursor(x, y);
u->Begin(UndoType::JoinLines);
u->commit();
}
buf->join_lines(static_cast<int>(y));
} else {
// nothing to delete
break;
}
}
buf->SetDirty(true);
ensure_cursor_visible(ctx.editor, *buf);
if (!killed_total.empty()) {
if (ctx.editor.KillChain())
ctx.editor.KillRingAppend(killed_total);
else
ctx.editor.KillRingPush(killed_total);
ctx.editor.SetKillChain(true);
}
return true;
}
static bool
cmd_kill_line(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf) {
ctx.editor.SetStatus("No buffer to edit");
return false;
}
ensure_at_least_one_line(*buf);
std::size_t y = buf->Cury();
std::size_t x = buf->Curx();
(void) x; // cursor x will be reset to 0
int repeat = ctx.count > 0 ? ctx.count : 1;
std::string killed_total;
UndoSystem *u = buf->Undo();
UndoGroupGuard guard(u);
for (int i = 0; i < repeat; ++i) {
const auto &rows_view = rows_of(*buf);
if (rows_view.empty())
break;
if (rows_view.size() == 1) {
// last remaining line: clear its contents
std::string content = static_cast<std::string>(rows_view[0]);
killed_total += content;
if (!content.empty()) {
buf->delete_text(0, 0, content.size());
if (u) {
buf->SetCursor(0, 0);
u->Begin(UndoType::Delete);
u->Append(std::string_view(content));
u->commit();
}
}
y = 0;
} else if (y + 1 == rows_view.size()) {
// Last row. If it is empty, the buffer ends with '\n' and this row is
// just the position after it: there is no line to kill. Otherwise
// the line has no trailing newline, so kill it together with the
// newline before it (delete_row would leave that newline behind).
std::string content = static_cast<std::string>(rows_view[y]);
if (content.empty())
break;
const std::size_t prev_len = rows_view[y - 1].size();
killed_total += content;
const std::string deleted = "\n" + content;
buf->delete_text(static_cast<int>(y - 1), static_cast<int>(prev_len), deleted.size());
if (u) {
buf->SetCursor(prev_len, y - 1);
u->Begin(UndoType::Delete);
u->Append(std::string_view(deleted));
u->commit();
}
// End of buffer reached: a repeat count must not continue upward.
y -= 1;
break;
} else if (y < rows_view.size()) {
// erase current line; keep y pointing at the next line
std::string content = static_cast<std::string>(rows_view[y]);
killed_total += content;
killed_total += "\n";
if (u) {
buf->SetCursor(0, y);
u->Begin(UndoType::DeleteRow);
u->Append(std::string_view(content));
u->commit();
}
buf->delete_row(static_cast<int>(y));
const auto &rows_after = rows_of(*buf);
if (y >= rows_after.size()) {
// deleted last line; move to previous
y = rows_after.empty() ? 0 : rows_after.size() - 1;
}
} else {
// out of range
const auto &rows2 = rows_of(*buf);
y = rows2.empty() ? 0 : rows2.size() - 1;
}
}
buf->SetCursor(0, y);
buf->SetDirty(true);
ensure_cursor_visible(ctx.editor, *buf);
if (!killed_total.empty()) {
if (ctx.editor.KillChain())
ctx.editor.KillRingAppend(killed_total);
else
ctx.editor.KillRingPush(killed_total);
ctx.editor.SetKillChain(true);
}
return true;
}
static bool
cmd_yank(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf) {
ctx.editor.SetStatus("No buffer to edit");
return false;
}
std::string text = ctx.editor.KillRingHead();
if (text.empty()) {
ctx.editor.SetStatus("Kill ring is empty");
return false;
}
ensure_at_least_one_line(*buf);
int repeat = ctx.count > 0 ? ctx.count : 1;
std::string ins;
if (repeat == 1) {
ins = text;
} else {
ins.reserve(text.size() * static_cast<std::size_t>(repeat));
for (int i = 0; i < repeat; ++i)
ins += text;
}
UndoSystem *u = buf->Undo();
// Visual-line mode: broadcast yank to beginning-of-line on every affected line.
if (buf->VisualLineActive()) {
const std::size_t sy = buf->VisualLineStartY();
const std::size_t ey = buf->VisualLineEndY();
const std::size_t y0 = buf->Cury();
std::uint64_t gid = 0;
if (u)
gid = u->BeginGroup();
(void) gid;
// Iterate from bottom to top so insertions don't invalidate remaining line indices.
for (std::size_t yy = ey + 1; yy-- > sy;) {
buf->SetCursor(0, yy);
if (u)
u->Begin(UndoType::Paste);
insert_text_at_cursor(*buf, ins);
if (u) {
u->Append(std::string_view(ins));
u->commit();
}
}
if (u)
u->EndGroup();
// Keep the point on the primary cursor line (as it was before yank), at the end of the
// inserted text for that line.
std::size_t nl_count = 0;
std::size_t last_nl = std::string::npos;
for (std::size_t i = 0; i < ins.size(); ++i) {
if (ins[i] == '\n') {
++nl_count;
last_nl = i;
}
}
const std::size_t delta_y = nl_count;
const std::size_t delta_x = (last_nl == std::string::npos) ? ins.size() : (ins.size() - last_nl - 1);
const std::size_t above = (y0 >= sy) ? (y0 - sy) : 0;
buf->SetCursor(delta_x, y0 + delta_y + above * nl_count);
} else {
if (u)
u->Begin(UndoType::Paste);
insert_text_at_cursor(*buf, ins);
if (u) {
u->Append(std::string_view(ins));
u->commit();
}
}
// Yank is a paste operation; it should clear the mark/region and any selection highlighting.
buf->ClearMark();
if (buf->VisualLineActive())
buf->VisualLineClear();
ensure_cursor_visible(ctx.editor, *buf);
// Start a new kill chain only from kill commands; yanking should break it
ctx.editor.SetKillChain(false);
return true;
}
// --- Marks/Regions and File boundaries ---
static bool
cmd_move_file_start(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
if (auto *u = buf->Undo())
u->commit();
ensure_at_least_one_line(*buf);
buf->SetCursor(0, 0);
if (buf->VisualLineActive())
buf->VisualLineSetActiveY(buf->Cury());
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
static bool
cmd_move_file_end(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
if (auto *u = buf->Undo())
u->commit();
ensure_at_least_one_line(*buf);
const auto &rows = rows_of(*buf);
std::size_t y = rows.empty() ? 0 : rows.size() - 1;
std::size_t x = rows.empty() ? 0 : rows[y].size();
buf->SetCursor(x, y);
if (buf->VisualLineActive())
buf->VisualLineSetActiveY(buf->Cury());
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
static bool
cmd_toggle_mark(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
if (buf->MarkSet()) {
buf->ClearMark();
ctx.editor.SetStatus("Mark cleared");
} else {
buf->SetMark(buf->Curx(), buf->Cury());
ctx.editor.SetStatus("Mark set");
}
return true;
}
static bool
cmd_visual_line_mode_toggle(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
buf->VisualLineToggle();
ctx.editor.SetStatus(std::string("Visual line: ") + (buf->VisualLineActive() ? "ON" : "OFF"));
return true;
}
static bool
cmd_jump_to_mark(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
if (auto *u = buf->Undo())
u->commit();
if (!buf->MarkSet()) {
ctx.editor.SetStatus("Mark not set");
return false;
}
std::size_t cx = buf->Curx();
std::size_t cy = buf->Cury();
std::size_t mx = buf->MarkCurx();
std::size_t my = buf->MarkCury();
buf->SetCursor(mx, my);
// Marks are not adjusted by edits, so the mark may now be past the end.
clamp_cursor_to_buffer(*buf);
buf->SetMark(cx, cy);
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
static bool
cmd_kill_region(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
ensure_at_least_one_line(*buf);
std::size_t sx, sy, ex, ey;
if (!compute_mark_region(*buf, sx, sy, ex, ey)) {
ctx.editor.SetStatus("No region to kill");
return false;
}
std::string text = extract_region_text(*buf, sx, sy, ex, ey);
{
UndoGroupGuard guard(buf->Undo());
delete_region(*buf, sx, sy, ex, ey, buf->Undo());
}
ensure_cursor_visible(ctx.editor, *buf);
if (!text.empty()) {
if (ctx.editor.KillChain())
ctx.editor.KillRingAppend(text);
else
ctx.editor.KillRingPush(text);
ctx.editor.SetKillChain(true);
}
buf->ClearMark();
return true;
}
static bool
cmd_copy_region(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
ensure_at_least_one_line(*buf);
std::size_t sx, sy, ex, ey;
if (!compute_mark_region(*buf, sx, sy, ex, ey)) {
ctx.editor.SetStatus("No region to copy");
return false;
}
std::string text = extract_region_text(*buf, sx, sy, ex, ey);
if (!text.empty()) {
if (ctx.editor.KillChain())
ctx.editor.KillRingAppend(text);
else
ctx.editor.KillRingPush(text);
ctx.editor.SetKillChain(true);
}
buf->ClearMark();
return true;
}
static bool
cmd_flush_kill_ring(CommandContext &ctx)
{
ctx.editor.KillRingClear();
ctx.editor.SetKillChain(false);
ctx.editor.SetStatus("Kill ring cleared");
return true;
}
// --- Navigation ---
// (helper removed)
static bool
cmd_move_left(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
if (auto *u = buf->Undo())
u->commit();
// If a prompt is active and it's search, go to previous match
if (ctx.editor.PromptActive() &&
(ctx.editor.CurrentPromptKind() == Editor::PromptKind::Search ||
ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexSearch ||
ctx.editor.CurrentPromptKind() == Editor::PromptKind::ReplaceFind)) {
if (ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexSearch) {
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 (!rmatches.empty()) {
int idx = ctx.editor.SearchIndex();
if (idx < 0)
idx = 0;
idx = (idx - 1 + static_cast<int>(rmatches.size())) % static_cast<int>(rmatches.size());
ctx.editor.SetSearchIndex(idx);
search_apply_match_regex(ctx.editor, *buf, rmatches);
} else {
search_apply_match_regex(ctx.editor, *buf, rmatches);
}
} else {
auto matches = search_compute_matches(*buf, ctx.editor.SearchQuery());
if (!matches.empty()) {
int idx = ctx.editor.SearchIndex();
if (idx < 0)
idx = 0;
idx = (idx - 1 + static_cast<int>(matches.size())) % static_cast<int>(matches.size());
ctx.editor.SetSearchIndex(idx);
search_apply_match(ctx.editor, *buf, matches);
} else {
search_apply_match(ctx.editor, *buf, matches);
}
}
return true;
}
if (ctx.editor.SearchActive()) {
auto matches = search_compute_matches(*buf, ctx.editor.SearchQuery());
if (!matches.empty()) {
int idx = ctx.editor.SearchIndex();
if (idx < 0)
idx = 0;
idx = (idx - 1 + static_cast<int>(matches.size())) % static_cast<int>(matches.size());
ctx.editor.SetSearchIndex(idx);
search_apply_match(ctx.editor, *buf, matches);
} else {
search_apply_match(ctx.editor, *buf, matches);
}
return true;
}
ensure_at_least_one_line(*buf);
auto rows = rows_of(*buf);
std::size_t y = buf->Cury();
std::size_t x = buf->Curx();
int repeat = ctx.count > 0 ? ctx.count : 1;
if (rows.empty())
return true;
if (y >= rows.size())
y = rows.size() - 1;
if (x > rows[y].size())
x = rows[y].size();
while (repeat-- > 0) {
if (x > 0) {
x -= utf8_prev_len(line_view(rows[y]), x);
} else if (y > 0) {
--y;
x = rows[y].size();
}
}
buf->SetCursor(x, y);
if (buf->VisualLineActive())
buf->VisualLineSetActiveY(buf->Cury());
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
static bool
cmd_move_right(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
if (auto *u = buf->Undo())
u->commit();
if (ctx.editor.PromptActive() &&
(ctx.editor.CurrentPromptKind() == Editor::PromptKind::Search ||
ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexSearch ||
ctx.editor.CurrentPromptKind() == Editor::PromptKind::ReplaceFind)) {
if (ctx.editor.CurrentPromptKind() == Editor::PromptKind::RegexSearch) {
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 (!rmatches.empty()) {
int idx = ctx.editor.SearchIndex();
if (idx < 0)
idx = 0;
idx = (idx + 1) % static_cast<int>(rmatches.size());
ctx.editor.SetSearchIndex(idx);
search_apply_match_regex(ctx.editor, *buf, rmatches);
} else {
search_apply_match_regex(ctx.editor, *buf, rmatches);
}
} else {
auto matches = search_compute_matches(*buf, ctx.editor.SearchQuery());
if (!matches.empty()) {
int idx = ctx.editor.SearchIndex();
if (idx < 0)
idx = 0;
idx = (idx + 1) % static_cast<int>(matches.size());
ctx.editor.SetSearchIndex(idx);
search_apply_match(ctx.editor, *buf, matches);
} else {
search_apply_match(ctx.editor, *buf, matches);
}
}
return true;
}
if (ctx.editor.SearchActive()) {
auto matches = search_compute_matches(*buf, ctx.editor.SearchQuery());
if (!matches.empty()) {
int idx = ctx.editor.SearchIndex();
if (idx < 0)
idx = 0;
idx = (idx + 1) % static_cast<int>(matches.size());
ctx.editor.SetSearchIndex(idx);
search_apply_match(ctx.editor, *buf, matches);
} else {
search_apply_match(ctx.editor, *buf, matches);
}
return true;
}
ensure_at_least_one_line(*buf);
auto rows = rows_of(*buf);
std::size_t y = buf->Cury();
std::size_t x = buf->Curx();
int repeat = ctx.count > 0 ? ctx.count : 1;
while (repeat-- > 0) {
if (y < rows.size() && x < rows[y].size()) {
x += utf8_next_len(line_view(rows[y]), x);
} else if (y + 1 < rows.size()) {
++y;
x = 0;
}
}
buf->SetCursor(x, y);
if (buf->VisualLineActive())
buf->VisualLineSetActiveY(buf->Cury());
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
static bool
cmd_move_up(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
if (auto *u = buf->Undo())
u->commit();
if ((ctx.editor.PromptActive() && ctx.editor.CurrentPromptKind() == Editor::PromptKind::Search) || ctx.editor.
SearchActive()) {
// Up == previous match
auto matches = search_compute_matches(*buf, ctx.editor.SearchQuery());
if (!matches.empty()) {
int idx = ctx.editor.SearchIndex();
if (idx < 0)
idx = 0;
idx = (idx - 1 + static_cast<int>(matches.size())) % static_cast<int>(matches.size());
ctx.editor.SetSearchIndex(idx);
search_apply_match(ctx.editor, *buf, matches);
} else {
search_apply_match(ctx.editor, *buf, matches);
}
return true;
}
ensure_at_least_one_line(*buf);
auto rows = rows_of(*buf);
std::size_t y = buf->Cury();
std::size_t x = buf->Curx();
int repeat = ctx.count > 0 ? ctx.count : 1;
if (rows.empty())
return true;
if (y >= rows.size())
y = rows.size() - 1;
if (repeat > static_cast<int>(y))
repeat = static_cast<int>(y);
y -= static_cast<std::size_t>(repeat);
if (x > rows[y].size())
x = rows[y].size();
// Keep the column on a character boundary of the new line.
while (x > 0 && x < rows[y].size() && utf8_is_cont(rows[y][x]))
--x;
buf->SetCursor(x, y);
if (buf->VisualLineActive())
buf->VisualLineSetActiveY(buf->Cury());
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
static bool
cmd_move_down(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
if (auto *u = buf->Undo())
u->commit();
if ((ctx.editor.PromptActive() && ctx.editor.CurrentPromptKind() == Editor::PromptKind::Search) || ctx.editor.
SearchActive()) {
// Down == next match
auto matches = search_compute_matches(*buf, ctx.editor.SearchQuery());
if (!matches.empty()) {
int idx = ctx.editor.SearchIndex();
if (idx < 0)
idx = 0;
idx = (idx + 1) % static_cast<int>(matches.size());
ctx.editor.SetSearchIndex(idx);
search_apply_match(ctx.editor, *buf, matches);
} else {
search_apply_match(ctx.editor, *buf, matches);
}
return true;
}
ensure_at_least_one_line(*buf);
auto rows = rows_of(*buf);
std::size_t y = buf->Cury();
std::size_t x = buf->Curx();
int repeat = ctx.count > 0 ? ctx.count : 1;
if (rows.empty())
return true;
if (y >= rows.size())
y = rows.size() - 1;
std::size_t max_down = rows.size() - 1 - y;
if (repeat > static_cast<int>(max_down))
repeat = static_cast<int>(max_down);
y += static_cast<std::size_t>(repeat);
if (x > rows[y].size())
x = rows[y].size();
// Keep the column on a character boundary of the new line.
while (x > 0 && x < rows[y].size() && utf8_is_cont(rows[y][x]))
--x;
buf->SetCursor(x, y);
if (buf->VisualLineActive())
buf->VisualLineSetActiveY(buf->Cury());
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
static bool
cmd_move_home(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
if (auto *u = buf->Undo())
u->commit();
ensure_at_least_one_line(*buf);
std::size_t y = buf->Cury();
buf->SetCursor(0, y);
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
static bool
cmd_move_end(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
if (auto *u = buf->Undo())
u->commit();
ensure_at_least_one_line(*buf);
auto rows = rows_of(*buf);
std::size_t y = buf->Cury();
std::size_t x = (y < rows.size()) ? rows[y].size() : 0;
buf->SetCursor(x, y);
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
static bool
cmd_page_up(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
if (auto *u = buf->Undo())
u->commit();
ensure_at_least_one_line(*buf);
auto rows = rows_of(*buf);
int repeat = ctx.count > 0 ? ctx.count : 1;
std::size_t content_rows = std::max<std::size_t>(1, ctx.editor.ContentRows());
// Base on current top-of-screen (row offset)
std::size_t rowoffs = buf->Rowoffs();
while (repeat-- > 0) {
if (rowoffs >= content_rows)
rowoffs -= content_rows;
else
rowoffs = 0;
}
// Clamp to valid range
if (rows.size() > content_rows) {
std::size_t max_top = rows.size() - content_rows;
if (rowoffs > max_top)
rowoffs = max_top;
} else {
rowoffs = 0;
}
// Move cursor to first visible line, column 0
std::size_t y = rowoffs;
if (y >= rows.size())
y = rows.empty() ? 0 : rows.size() - 1;
buf->SetOffsets(rowoffs, 0);
buf->SetCursor(0, y);
return true;
}
static bool
cmd_page_down(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
if (auto *u = buf->Undo())
u->commit();
ensure_at_least_one_line(*buf);
auto rows = rows_of(*buf);
int repeat = ctx.count > 0 ? ctx.count : 1;
std::size_t content_rows = std::max<std::size_t>(1, ctx.editor.ContentRows());
std::size_t rowoffs = buf->Rowoffs();
// Compute maximum top offset
std::size_t max_top = 0;
if (!rows.empty()) {
if (rows.size() > content_rows)
max_top = rows.size() - content_rows;
else
max_top = 0;
}
while (repeat-- > 0) {
if (rowoffs + content_rows <= max_top)
rowoffs += content_rows;
else
rowoffs = max_top;
}
// Move cursor to first visible line, column 0
std::size_t y = std::min<std::size_t>(rowoffs, rows.empty() ? 0 : rows.size() - 1);
buf->SetOffsets(rowoffs, 0);
buf->SetCursor(0, y);
return true;
}
static bool
cmd_scroll_up(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
if (auto *u = buf->Undo())
u->commit();
ensure_at_least_one_line(*buf);
const auto &rows = rows_of(*buf);
std::size_t content_rows = std::max<std::size_t>(1, ctx.editor.ContentRows());
std::size_t rowoffs = buf->Rowoffs();
// Scroll up by 3 lines (or count if specified), without moving cursor
int scroll_amount = ctx.count > 0 ? ctx.count : 3;
if (rowoffs >= static_cast<std::size_t>(scroll_amount))
rowoffs -= static_cast<std::size_t>(scroll_amount);
else
rowoffs = 0;
buf->SetOffsets(rowoffs, buf->Coloffs());
// If cursor is now below the visible area, move it to the last visible line
std::size_t cury = buf->Cury();
if (cury >= rowoffs + content_rows) {
std::size_t new_y = rowoffs + content_rows - 1;
if (new_y >= rows.size() && !rows.empty())
new_y = rows.size() - 1;
buf->SetCursor(buf->Curx(), new_y);
clamp_cursor_to_buffer(*buf);
}
return true;
}
static bool
cmd_scroll_down(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
if (auto *u = buf->Undo())
u->commit();
ensure_at_least_one_line(*buf);
const auto &rows = rows_of(*buf);
std::size_t content_rows = std::max<std::size_t>(1, ctx.editor.ContentRows());
std::size_t rowoffs = buf->Rowoffs();
// Scroll down by 3 lines (or count if specified), without moving cursor
int scroll_amount = ctx.count > 0 ? ctx.count : 3;
// Compute maximum top offset
std::size_t max_top = 0;
if (!rows.empty() && rows.size() > content_rows)
max_top = rows.size() - content_rows;
rowoffs += static_cast<std::size_t>(scroll_amount);
if (rowoffs > max_top)
rowoffs = max_top;
buf->SetOffsets(rowoffs, buf->Coloffs());
// If cursor is now above the visible area, move it to the first visible line
std::size_t cury = buf->Cury();
if (cury < rowoffs) {
buf->SetCursor(buf->Curx(), rowoffs);
clamp_cursor_to_buffer(*buf);
}
return true;
}
static inline bool
is_word_char(unsigned char c)
{
// Bytes >= 0x80 belong to multibyte UTF-8 characters: count them as word
// characters, so word motion and deletion never stop inside a character
// (and letters outside ASCII are part of words).
return std::isalnum(c) || c == '_' || c >= 0x80;
}
static bool
cmd_word_prev(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
if (auto *u = buf->Undo())
u->commit();
ensure_at_least_one_line(*buf);
auto rows = rows_of(*buf);
std::size_t y = buf->Cury();
std::size_t x = buf->Curx();
int repeat = ctx.count > 0 ? ctx.count : 1;
while (repeat-- > 0) {
if (y >= rows.size()) {
y = rows.empty() ? 0 : rows.size() - 1;
x = rows[y].size();
}
// If at start of line and not first line, move to end of previous line
if (x == 0) {
if (y == 0)
break;
--y;
x = rows[y].size();
}
// Move left one first
if (x > 0)
--x;
// Skip any whitespace leftwards
while (y < rows.size() && (x > 0 || (x == 0 && y > 0))) {
if (x == 0) {
--y;
x = rows[y].size();
if (x == 0)
continue;
}
unsigned char c = x > 0 ? static_cast<unsigned char>(rows[y][x - 1]) : 0;
if (!std::isspace(c))
break;
--x;
}
// Skip word characters leftwards
while (y < rows.size() && (x > 0 || (x == 0 && y > 0))) {
if (x == 0)
break;
unsigned char c = static_cast<unsigned char>(rows[y][x - 1]);
if (!is_word_char(c))
break;
--x;
}
}
buf->SetCursor(x, y);
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
static bool
cmd_word_next(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
if (auto *u = buf->Undo())
u->commit();
ensure_at_least_one_line(*buf);
auto rows = rows_of(*buf);
std::size_t y = buf->Cury();
std::size_t x = buf->Curx();
int repeat = ctx.count > 0 ? ctx.count : 1;
while (repeat-- > 0) {
if (y >= rows.size())
break;
// First, if currently on a word, skip to its end
while (y < rows.size()) {
if (x < rows[y].size() && is_word_char(static_cast<unsigned char>(rows[y][x]))) {
++x;
continue;
}
if (x >= rows[y].size()) {
if (y + 1 >= rows.size())
break;
++y;
x = 0;
continue;
}
break;
}
// Then, skip any non-word characters (including punctuation and whitespace)
while (y < rows.size()) {
if (x < rows[y].size()) {
unsigned char c = static_cast<unsigned char>(rows[y][x]);
if (is_word_char(c))
break;
++x;
continue;
}
if (x >= rows[y].size()) {
if (y + 1 >= rows.size())
break;
++y;
x = 0;
continue;
}
}
}
buf->SetCursor(x, y);
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
static bool
cmd_delete_word_prev(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
if (auto *u = buf->Undo())
u->commit();
ensure_at_least_one_line(*buf);
auto rows = rows_of(*buf);
std::size_t y = buf->Cury();
std::size_t x = buf->Curx();
int repeat = ctx.count > 0 ? ctx.count : 1;
std::string killed_total;
UndoGroupGuard guard(buf->Undo());
for (int i = 0; i < repeat; ++i) {
if (y >= rows.size()) {
y = rows.empty() ? 0 : rows.size() - 1;
x = rows[y].size();
}
std::size_t start_y = y;
std::size_t start_x = x;
// If at start of line and not first line, move to end of previous line
if (x == 0) {
if (y == 0)
break;
--y;
x = rows[y].size();
}
// Move left one first
if (x > 0)
--x;
// Skip any whitespace leftwards
while (y < rows.size() && (x > 0 || (x == 0 && y > 0))) {
if (x == 0) {
--y;
x = rows[y].size();
if (x == 0)
continue;
}
unsigned char c = x > 0 ? static_cast<unsigned char>(rows[y][x - 1]) : 0;
if (!std::isspace(c))
break;
--x;
}
// Skip word characters leftwards
while (y < rows.size() && (x > 0 || (x == 0 && y > 0))) {
if (x == 0)
break;
unsigned char c = static_cast<unsigned char>(rows[y][x - 1]);
if (!is_word_char(c))
break;
--x;
}
// Now delete from (x, y) to (start_x, start_y) using PieceTable
std::string deleted = extract_region_text(*buf, x, y, start_x, start_y);
delete_region(*buf, x, y, start_x, start_y, buf->Undo());
// Prepend to killed_total (since we're deleting backwards)
killed_total = deleted + killed_total;
}
buf->SetCursor(x, y);
buf->SetDirty(true);
ensure_cursor_visible(ctx.editor, *buf);
if (!killed_total.empty()) {
if (ctx.editor.KillChain())
ctx.editor.KillRingPrepend(killed_total);
else
ctx.editor.KillRingPush(killed_total);
ctx.editor.SetKillChain(true);
}
return true;
}
static bool
cmd_delete_word_next(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
if (auto *u = buf->Undo())
u->commit();
ensure_at_least_one_line(*buf);
const auto &rows = rows_of(*buf);
std::size_t y = buf->Cury();
std::size_t x = buf->Curx();
int repeat = ctx.count > 0 ? ctx.count : 1;
std::string killed_total;
UndoGroupGuard guard(buf->Undo());
for (int i = 0; i < repeat; ++i) {
if (y >= rows.size())
break;
std::size_t start_y = y;
std::size_t start_x = x;
// First, if currently on a word, skip to its end
while (y < rows.size()) {
if (x < rows[y].size() && is_word_char(static_cast<unsigned char>(rows[y][x]))) {
++x;
continue;
}
if (x >= rows[y].size()) {
if (y + 1 >= rows.size())
break;
++y;
x = 0;
continue;
}
break;
}
// Then, skip any non-word characters (including punctuation and whitespace)
while (y < rows.size()) {
if (x < rows[y].size()) {
unsigned char c = static_cast<unsigned char>(rows[y][x]);
if (is_word_char(c))
break;
++x;
continue;
}
if (x >= rows[y].size()) {
if (y + 1 >= rows.size())
break;
++y;
x = 0;
continue;
}
}
// Now delete from (start_x, start_y) to (x, y) using PieceTable
std::string deleted = extract_region_text(*buf, start_x, start_y, x, y);
delete_region(*buf, start_x, start_y, x, y, buf->Undo());
y = start_y;
x = start_x;
killed_total += deleted;
}
buf->SetCursor(x, y);
buf->SetDirty(true);
ensure_cursor_visible(ctx.editor, *buf);
if (!killed_total.empty()) {
if (ctx.editor.KillChain())
ctx.editor.KillRingAppend(killed_total);
else
ctx.editor.KillRingPush(killed_total);
ctx.editor.SetKillChain(true);
}
return true;
}
static bool
cmd_indent_region(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
if (!buf->MarkSet()) {
ctx.editor.SetStatus("No mark set");
return false;
}
std::size_t sx, sy, ex, ey;
if (!compute_mark_region(*buf, sx, sy, ex, ey)) {
ctx.editor.SetStatus("No region to indent");
return false;
}
UndoSystem *u = buf->Undo();
UndoGroupGuard guard(u);
for (std::size_t y = sy; y <= ey && y < rows_of(*buf).size(); ++y) {
buf->insert_text(static_cast<int>(y), 0, std::string_view("\t"));
if (u) {
buf->SetCursor(0, y);
u->Begin(UndoType::Insert);
u->Append('\t');
u->commit();
}
}
buf->SetDirty(true);
buf->ClearMark();
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
static bool
cmd_unindent_region(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
if (!buf->MarkSet()) {
ctx.editor.SetStatus("No mark set");
return false;
}
std::size_t sx, sy, ex, ey;
if (!compute_mark_region(*buf, sx, sy, ex, ey)) {
ctx.editor.SetStatus("No region to unindent");
return false;
}
UndoSystem *u = buf->Undo();
UndoGroupGuard guard(u);
for (std::size_t y = sy; y <= ey && y < rows_of(*buf).size(); ++y) {
const auto &rows_view = rows_of(*buf);
if (y >= rows_view.size())
break;
const std::string line = static_cast<std::string>(rows_view[y]);
if (!line.empty()) {
if (line[0] == '\t') {
buf->delete_text(static_cast<int>(y), 0, 1);
if (u) {
buf->SetCursor(0, y);
u->Begin(UndoType::Delete);
u->Append('\t');
u->commit();
}
} else if (line[0] == ' ') {
std::size_t spaces = 0;
while (spaces < line.size() && spaces < 8 && line[spaces] == ' ') {
++spaces;
}
if (spaces > 0) {
buf->delete_text(static_cast<int>(y), 0, spaces);
if (u) {
buf->SetCursor(0, y);
u->Begin(UndoType::Delete);
u->Append(line.substr(0, spaces));
u->commit();
}
}
}
}
}
buf->SetDirty(true);
buf->ClearMark();
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
static bool
cmd_reflow_paragraph(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
// Reflow performs a multi-edit transformation; make it a single standalone undo/redo step.
UndoGroupGuard guard(buf->Undo());
if (auto *u = buf->Undo())
u->commit();
ensure_at_least_one_line(*buf);
auto rows = rows_of(*buf);
std::size_t y = buf->Cury();
// Treat a universal-argument count of 1 as "no width specified".
// Editor::UArgGet() returns 1 when no explicit count was provided.
int width = ctx.count > 1 ? ctx.count : 72;
// A blank line separates paragraphs; expanding from it would merge the
// paragraphs above and below.
if (y >= rows.size() || rows[y].empty())
return true;
std::size_t para_start = y;
while (para_start > 0 && !rows[para_start - 1].empty())
--para_start;
std::size_t para_end = y;
while (para_end + 1 < rows.size() && !rows[para_end + 1].empty())
++para_end;
if (para_start > para_end)
return false;
auto is_space = [](char c) {
return c == ' ' || c == '\t';
};
auto leading_ws = [&](const std::string &s) {
std::size_t i = 0;
while (i < s.size() && is_space(s[i]))
++i;
return s.substr(0, i);
};
auto starts_with = [](const std::string &s, const std::string &pfx) {
return s.size() >= pfx.size() && std::equal(pfx.begin(), pfx.end(), s.begin());
};
auto is_bullet_line = [&](const std::string &s, std::string &indent_out, char &marker_out,
std::size_t &after_prefix_idx) -> bool {
indent_out = leading_ws(s);
std::size_t i = indent_out.size();
if (i + 1 < s.size()) {
char m = s[i];
if ((m == '-' || m == '+' || m == '*') && s[i + 1] == ' ') {
marker_out = m;
after_prefix_idx = i + 2; // after marker + space
return true;
}
}
return false;
};
auto is_numbered_line = [&](const std::string &s,
std::string &indent_out,
std::string &marker_out,
std::size_t &after_prefix_idx) -> bool {
indent_out = leading_ws(s);
std::size_t i = indent_out.size();
if (i >= s.size() || !std::isdigit(static_cast<unsigned char>(s[i])))
return false;
std::size_t j = i;
while (j < s.size() && std::isdigit(static_cast<unsigned char>(s[j])))
++j;
if (j >= s.size())
return false;
char delim = s[j];
if (!(delim == '.' || delim == ')'))
return false;
if (j + 1 >= s.size() || s[j + 1] != ' ')
return false;
marker_out = s.substr(i, (j - i) + 1); // e.g. "1." or "10)"
after_prefix_idx = j + 2; // after delimiter + space
return true;
};
auto normalize_spaces = [](const std::string &in) {
std::string out;
out.reserve(in.size());
bool in_space = false;
for (char c: in) {
char cc = (c == '\t') ? ' ' : c;
if (cc == ' ') {
if (!in_space) {
out.push_back(' ');
in_space = true;
}
} else {
out.push_back(cc);
in_space = false;
}
}
// trim leading/trailing spaces
// leading
std::size_t start = 0;
while (start < out.size() && out[start] == ' ')
++start;
// trailing
std::size_t end = out.size();
while (end > start && out[end - 1] == ' ')
--end;
return out.substr(start, end - start);
};
auto wrap_with_prefixes = [&](const std::string &content,
const std::string &first_prefix,
const std::string &cont_prefix,
int w,
std::vector<std::string> &dst) {
// Tokenize by spaces
std::vector<std::string> words;
std::size_t pos = 0;
while (pos < content.size()) {
while (pos < content.size() && content[pos] == ' ')
++pos;
if (pos >= content.size())
break;
std::size_t ws = pos;
while (pos < content.size() && content[pos] != ' ')
++pos;
words.emplace_back(content.substr(ws, pos - ws));
}
std::string line = first_prefix;
std::size_t cur_len = line.size();
bool first_word_on_line = true;
auto flush_line = [&]() {
dst.emplace_back(line);
line = cont_prefix;
cur_len = line.size();
first_word_on_line = true;
};
if (words.empty()) {
// Still emit a line with just the prefix (e.g., empty bullet)
dst.emplace_back(line);
return;
}
for (std::size_t i = 0; i < words.size(); ++i) {
const std::string &wrd = words[i];
std::size_t needed = wrd.size() + (first_word_on_line ? 0 : 1);
if (static_cast<int>(cur_len + needed) > w) {
// wrap
flush_line();
}
if (!first_word_on_line) {
line.push_back(' ');
++cur_len;
}
line += wrd;
cur_len += wrd.size();
first_word_on_line = false;
}
if (!line.empty())
dst.emplace_back(line);
};
std::vector<std::string> new_lines;
// Determine if this region looks like a list: any line starting with bullet or number
bool region_has_list = false;
for (std::size_t i = para_start; i <= para_end; ++i) {
std::string s = static_cast<std::string>(rows[i]);
std::string indent;
char marker;
std::size_t idx;
if (is_bullet_line(s, indent, marker, idx)) {
region_has_list = true;
break;
}
std::string nmarker;
if (is_numbered_line(s, indent, nmarker, idx)) {
region_has_list = true;
break;
}
}
if (region_has_list) {
// Parse as list items; support hanging indent continuations
for (std::size_t i = para_start; i <= para_end; ++i) {
std::string s = static_cast<std::string>(rows[i]);
std::string indent;
char marker = 0;
std::size_t after_idx = 0;
if (is_bullet_line(s, indent, marker, after_idx)) {
std::string first_prefix = indent + std::string(1, marker) + " ";
std::string cont_prefix = indent + " ";
std::string content = s.substr(after_idx);
// consume continuation lines that are part of this bullet item
std::size_t j = i + 1;
while (j <= para_end) {
std::string ns = static_cast<std::string>(rows[j]);
// stop if next bullet at same indentation or different structure
std::string nindent;
char nmarker;
std::size_t nidx;
if (is_bullet_line(ns, nindent, nmarker, nidx)) {
break; // next item
}
std::string nnmarker;
if (is_numbered_line(ns, nindent, nnmarker, nidx)) {
break; // next item
}
// Now check if it's a continuation line
if (starts_with(ns, indent + " ")) {
content += ' ';
content += ns.substr(indent.size() + 2);
++j;
continue;
}
// Not a continuation and not a bullet: stop (treat as separate paragraph chunk)
break;
}
content = normalize_spaces(content);
wrap_with_prefixes(content, first_prefix, cont_prefix, width, new_lines);
i = j - 1; // advance
} else {
std::string nmarker;
if (is_numbered_line(s, indent, nmarker, after_idx)) {
std::string first_prefix = indent + nmarker + " ";
std::string cont_prefix = indent + std::string(nmarker.size() + 1, ' ');
std::string content = s.substr(after_idx);
// consume continuation lines that are part of this numbered item
std::size_t j = i + 1;
while (j <= para_end) {
std::string ns = static_cast<std::string>(rows[j]);
if (starts_with(ns, cont_prefix)) {
content += ' ';
content += ns.substr(cont_prefix.size());
++j;
continue;
}
// stop if next item
std::string nindent2;
char bmarker;
std::size_t nidx;
if (is_bullet_line(ns, nindent2, bmarker, nidx))
break;
std::string nnmarker;
if (is_numbered_line(ns, nindent2, nnmarker, nidx))
break;
break;
}
content = normalize_spaces(content);
wrap_with_prefixes(content, first_prefix, cont_prefix, width, new_lines);
i = j - 1;
} else {
// A non-bullet line within a list region; treat as its own wrapped paragraph preserving its indent
std::string base_indent = leading_ws(s);
std::string content = s.substr(base_indent.size());
std::size_t j = i + 1;
while (j <= para_end) {
std::string ns = static_cast<std::string>(rows[j]);
std::string nindent = leading_ws(ns);
std::string tmp_indent;
char tmp_marker;
std::size_t tmp_idx;
if (is_bullet_line(ns, tmp_indent, tmp_marker, tmp_idx)) {
break; // next bullet starts
}
std::string tmp_nmarker;
if (is_numbered_line(ns, tmp_indent, tmp_nmarker, tmp_idx)) {
break; // next numbered starts
}
if (nindent.size() >= base_indent.size()) {
content += ' ';
content += ns.substr(base_indent.size());
++j;
} else {
break;
}
}
content = normalize_spaces(content);
wrap_with_prefixes(content, base_indent, base_indent, width, new_lines);
i = j - 1;
}
}
}
} else {
// Normal paragraph: preserve indentation of first line
std::string s0 = static_cast<std::string>(rows[para_start]);
std::string pfx = leading_ws(s0);
std::string content;
for (std::size_t i = para_start; i <= para_end; ++i) {
std::string si = static_cast<std::string>(rows[i]);
// strip the same prefix length if present
if (si.size() >= pfx.size() && starts_with(si, pfx))
si.erase(0, pfx.size());
if (!content.empty())
content.push_back(' ');
content += si;
}
content = normalize_spaces(content);
wrap_with_prefixes(content, pfx, pfx, width, new_lines);
}
if (new_lines.empty())
new_lines.push_back("");
// Replace the paragraph's text in place (see replace_rows_text).
std::string old_text;
for (std::size_t i = para_start; i <= para_end; ++i) {
if (i > para_start)
old_text.push_back('\n');
old_text += static_cast<std::string>(rows[i]);
}
std::string new_text;
for (std::size_t i = 0; i < new_lines.size(); ++i) {
if (i > 0)
new_text.push_back('\n');
new_text += new_lines[i];
}
if (new_text != old_text)
replace_rows_text(*buf, para_start, old_text, new_text, buf->Undo());
// Place cursor at the end of the paragraph
std::size_t new_last_y = para_start + (new_lines.empty() ? 0 : new_lines.size() - 1);
std::size_t new_last_x = new_lines.empty() ? 0 : new_lines.back().size();
buf->SetCursor(new_last_x, new_last_y);
buf->SetDirty(true);
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
static bool
cmd_reload_buffer(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
// Remember the current cursor position so we can attempt to restore it
const std::size_t old_x = buf->Curx();
const std::size_t old_y = buf->Cury();
const std::string &filename = buf->Filename();
if (filename.empty()) {
ctx.editor.SetStatus("Cannot reload unnamed buffer");
return false;
}
std::string err;
if (!buf->OpenFromFile(filename, err)) {
ctx.editor.SetStatus(std::string("Reload failed: ") + err);
return false;
}
// The journal recorded edits against the discarded content; restart it
// from the reloaded file, or crash recovery would replay those edits.
if (auto *sm = ctx.editor.Swap())
sm->ResetJournal(*buf);
// Try to restore the cursor to its previous position if still valid; otherwise clamp
{
auto rows = rows_of(*buf);
const std::size_t nrows = rows.size();
if (nrows == 0) {
buf->SetCursor(0, 0);
} else {
const std::size_t new_y = old_y < nrows ? old_y : (nrows - 1);
const std::size_t line_len = rows[new_y].size();
const std::size_t new_x = old_x < line_len ? old_x : line_len;
buf->SetCursor(new_x, new_y);
}
}
ctx.editor.SetStatus(std::string("Reloaded ") + filename);
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
static bool
cmd_mark_all_and_jump_end(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
ensure_at_least_one_line(*buf);
buf->SetMark(0, 0);
auto rows = rows_of(*buf);
std::size_t last_y = rows.empty() ? 0 : rows.size() - 1;
std::size_t last_x = last_y < rows.size() ? rows[last_y].size() : 0;
buf->SetCursor(last_x, last_y);
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
std::vector<Command> &
CommandRegistry::storage_()
{
static std::vector<Command> cmds;
return cmds;
}
void
CommandRegistry::Register(const Command &cmd)
{
auto &v = storage_();
// Replace existing with same id or name
auto it = std::find_if(v.begin(), v.end(), [&](const Command &c) {
return c.id == cmd.id || c.name == cmd.name;
});
if (it != v.end()) {
*it = cmd;
} else {
v.push_back(cmd);
}
}
const Command *
CommandRegistry::FindById(CommandId id)
{
auto &v = storage_();
auto it = std::find_if(v.begin(), v.end(), [&](const Command &c) {
return c.id == id;
});
return it == v.end() ? nullptr : &*it;
}
const Command *
CommandRegistry::FindByName(const std::string &name)
{
auto &v = storage_();
auto it = std::find_if(v.begin(), v.end(), [&](const Command &c) {
return c.name == name;
});
return it == v.end() ? nullptr : &*it;
}
const std::vector<Command> &
CommandRegistry::All()
{
return storage_();
}
void
InstallDefaultCommands()
{
CommandRegistry::Register({CommandId::Save, "save", "Save current buffer", cmd_save});
CommandRegistry::Register({CommandId::SaveAs, "save-as", "Save current buffer as...", cmd_save_as});
CommandRegistry::Register({CommandId::Quit, "quit", "Quit editor (request)", cmd_quit});
CommandRegistry::Register({CommandId::QuitNow, "quit-now", "Quit editor immediately", cmd_quit_now});
CommandRegistry::Register({CommandId::SaveAndQuit, "save-quit", "Save and quit (request)", cmd_save_and_quit});
CommandRegistry::Register({CommandId::Refresh, "refresh", "Force redraw", cmd_refresh});
CommandRegistry::Register(
{CommandId::KPrefix, "k-prefix", "Entering k-command prefix (show hint)", cmd_kprefix, false, false});
CommandRegistry::Register({
CommandId::UnknownKCommand, "unknown-k", "Unknown k-command (status)",
cmd_unknown_kcommand, false, false
});
CommandRegistry::Register({
CommandId::UnknownEscCommand, "unknown-esc", "Unknown ESC command (status)",
cmd_unknown_esc_command, false, false
});
CommandRegistry::Register({
CommandId::FindStart, "find-start", "Begin incremental search", cmd_find_start, false, false
});
CommandRegistry::Register({
CommandId::RegexFindStart, "regex-find-start", "Begin regex search", cmd_regex_find_start, false, false
});
CommandRegistry::Register({
CommandId::RegexpReplace, "regex-replace", "Begin regex search & replace", cmd_regex_replace_start,
false, false
});
CommandRegistry::Register({
CommandId::SearchReplace, "search-replace", "Begin search & replace", cmd_search_replace_start, false,
false
});
CommandRegistry::Register({
CommandId::OpenFileStart, "open-file-start", "Begin open-file prompt", cmd_open_file_start, false, false
});
// Buffers
CommandRegistry::Register({
CommandId::BufferSwitchStart, "buffer-switch-start", "Begin buffer switch prompt",
cmd_buffer_switch_start, false, false
});
CommandRegistry::Register({CommandId::BufferNew, "buffer-new", "Open new empty buffer", cmd_buffer_new});
CommandRegistry::Register({CommandId::BufferNext, "buffer-next", "Switch to next buffer", cmd_buffer_next});
CommandRegistry::Register({CommandId::BufferPrev, "buffer-prev", "Switch to previous buffer", cmd_buffer_prev});
CommandRegistry::Register({
CommandId::BufferClose, "buffer-close", "Close current buffer", cmd_buffer_close, false, false
});
// Editing
CommandRegistry::Register({
CommandId::InsertText, "insert", "Insert text at cursor (no newlines)", cmd_insert_text, false, true
});
CommandRegistry::Register({CommandId::Newline, "newline", "Insert newline at cursor", cmd_newline});
CommandRegistry::Register({
CommandId::SmartNewline, "smart-newline", "Insert newline with auto-indent", cmd_smart_newline
});
CommandRegistry::Register({CommandId::Backspace, "backspace", "Delete char before cursor", cmd_backspace});
CommandRegistry::Register({CommandId::DeleteChar, "delete-char", "Delete char at cursor", cmd_delete_char});
CommandRegistry::Register({CommandId::KillToEOL, "kill-to-eol", "Delete to end of line", cmd_kill_to_eol});
CommandRegistry::Register({CommandId::KillLine, "kill-line", "Delete entire line", cmd_kill_line});
CommandRegistry::Register({CommandId::Yank, "yank", "Yank from kill ring", cmd_yank});
// Marks/regions and file boundaries
CommandRegistry::Register({
CommandId::MoveFileStart, "file-start", "Move to beginning of file", cmd_move_file_start
});
CommandRegistry::Register({CommandId::MoveFileEnd, "file-end", "Move to end of file", cmd_move_file_end});
CommandRegistry::Register({CommandId::ToggleMark, "toggle-mark", "Toggle mark at cursor", cmd_toggle_mark});
CommandRegistry::Register({
CommandId::VisualLineModeToggle, "visual-line-toggle", "Toggle visual-line (multicursor) mode",
cmd_visual_line_mode_toggle, false, false
});
CommandRegistry::Register({
CommandId::JumpToMark, "jump-to-mark", "Jump to mark (swap mark)", cmd_jump_to_mark
});
CommandRegistry::Register({CommandId::KillRegion, "kill-region", "Kill region to kill ring", cmd_kill_region});
CommandRegistry::Register({CommandId::CopyRegion, "copy-region", "Copy region to kill ring", cmd_copy_region});
CommandRegistry::Register({
CommandId::FlushKillRing, "flush-kill-ring", "Flush kill ring", cmd_flush_kill_ring
});
// Navigation
CommandRegistry::Register({CommandId::MoveLeft, "left", "Move cursor left", cmd_move_left});
CommandRegistry::Register({CommandId::MoveRight, "right", "Move cursor right", cmd_move_right});
CommandRegistry::Register({CommandId::MoveUp, "up", "Move cursor up", cmd_move_up});
CommandRegistry::Register({CommandId::MoveDown, "down", "Move cursor down", cmd_move_down});
CommandRegistry::Register({CommandId::MoveHome, "home", "Move to beginning of line", cmd_move_home});
CommandRegistry::Register({CommandId::MoveEnd, "end", "Move to end of line", cmd_move_end});
CommandRegistry::Register({CommandId::PageUp, "page-up", "Page up", cmd_page_up});
CommandRegistry::Register({CommandId::PageDown, "page-down", "Page down", cmd_page_down});
CommandRegistry::Register({CommandId::ScrollUp, "scroll-up", "Scroll viewport up", cmd_scroll_up});
CommandRegistry::Register({CommandId::ScrollDown, "scroll-down", "Scroll viewport down", cmd_scroll_down});
CommandRegistry::Register({CommandId::WordPrev, "word-prev", "Move to previous word", cmd_word_prev});
CommandRegistry::Register({CommandId::WordNext, "word-next", "Move to next word", cmd_word_next});
CommandRegistry::Register({
CommandId::DeleteWordPrev, "delete-word-prev", "Delete previous word", cmd_delete_word_prev
});
CommandRegistry::Register({
CommandId::DeleteWordNext, "delete-word-next", "Delete next word", cmd_delete_word_next
});
CommandRegistry::Register({
CommandId::MoveCursorTo, "move-cursor-to", "Move cursor to y:x", cmd_move_cursor_to, false, false
});
// Direct navigation by line number
CommandRegistry::Register({
CommandId::JumpToLine, "goto-line", "Prompt for line and jump", cmd_jump_to_line_start, false, false
});
// Undo/Redo
CommandRegistry::Register({CommandId::Undo, "undo", "Undo last edit", cmd_undo, false, true});
CommandRegistry::Register({CommandId::Redo, "redo", "Redo edit", cmd_redo, false, true});
// 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, false
});
// Font by name (public)
CommandRegistry::Register({
CommandId::FontSetByName, "font", "Set GUI font by name", cmd_font_set_by_name, true, false
});
// Font size (public)
CommandRegistry::Register({
CommandId::FontSetSize, "font-size", "Set GUI font size (pixels)", cmd_font_set_size, true, false
});
// Background light/dark (public)
CommandRegistry::Register({
CommandId::BackgroundSet, "background", "Set GUI background light|dark", cmd_background_set, true, false
});
// Generic command prompt (C-k ;)
CommandRegistry::Register({
CommandId::CommandPromptStart, "command-prompt-start", "Start generic command prompt",
cmd_command_prompt_start, false, false
});
// Buffer operations
CommandRegistry::Register({
CommandId::ReloadBuffer, "reload-buffer", "Reload buffer from disk", cmd_reload_buffer, false, false
});
// Help
CommandRegistry::Register({
CommandId::ShowHelp, "help", "+HELP+ buffer with manual text", cmd_show_help, false, false
});
CommandRegistry::Register({
CommandId::MarkAllAndJumpEnd, "mark-all-jump-end", "Set mark at beginning and jump to end",
cmd_mark_all_and_jump_end
});
// GUI
CommandRegistry::Register({
CommandId::VisualFilePickerToggle, "file-picker-toggle", "Toggle visual file picker",
cmd_visual_file_picker_toggle, false, false
});
CommandRegistry::Register({
CommandId::VisualFontPickerToggle, "font-picker-toggle", "Show visual font picker",
cmd_visual_font_picker_toggle, false, false
});
// Working directory
CommandRegistry::Register({
CommandId::ShowWorkingDirectory, "show-working-directory", "Show current working directory",
cmd_show_working_directory, false, false
});
CommandRegistry::Register({
CommandId::ChangeWorkingDirectory, "change-working-directory", "Change current working directory",
cmd_change_working_directory_start, false, false
});
// UI helpers
CommandRegistry::Register(
{CommandId::UArgStatus, "uarg-status", "Update universal-arg status", cmd_uarg_status, false, false});
// Syntax highlighting (public commands)
CommandRegistry::Register({CommandId::Syntax, "syntax", "Syntax: on|off|reload", cmd_syntax, true});
CommandRegistry::Register({CommandId::SetOption, "set", "Set option: key=value", cmd_set_option, true});
// Viewport control
CommandRegistry::Register({
CommandId::CenterOnCursor, "center-on-cursor", "Center viewport on current line", cmd_center_on_cursor,
false, false
});
// GUI: new window
CommandRegistry::Register({
CommandId::NewWindow, "new-window", "Open a new editor window (GUI only)", cmd_new_window,
false, false
});
// Edit mode toggle (public)
CommandRegistry::Register({
CommandId::ToggleEditMode, "mode", "Toggle or set edit mode: code|writing",
cmd_toggle_edit_mode, true, false
});
}
bool
Execute(Editor &ed, CommandId id, const std::string &arg, int count)
{
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) {
ed.SetQuitConfirmPending(false);
}
// Reset kill chain unless this is a kill-like command (so consecutive kills append)
if (id != CommandId::KillToEOL && id != CommandId::KillLine && id != CommandId::KillRegion && id !=
CommandId::CopyRegion && id != CommandId::DeleteWordPrev && id != CommandId::DeleteWordNext) {
ed.SetKillChain(false);
}
// While a prompt is open, keys edit the prompt; only commands that know
// about prompts (or only change the view) may run. Anything else would
// edit the buffer behind the prompt (skipping the read-only check below)
// or switch/close buffers under a pending confirmation, which then acts
// on the wrong buffer.
if (ed.PromptActive() && !allowed_during_prompt(id)) {
ed.SetStatus("Finish or cancel the prompt first (C-g)");
return true;
}
// 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
}
}
// Source repeat count from editor-level universal argument per new design.
int final_count = 0;
if (cmd->repeatable) {
final_count = ed.UArgGet(); // returns 1 if no active uarg
} else {
// Special-case non-repeatables that should NOT consume/clear uarg:
// - KPrefix: keeps uarg for the following k-suffix command.
// - UnknownKCommand / UnknownEscCommand: user mistyped; keep uarg for next try.
if (id != CommandId::KPrefix && id != CommandId::UnknownKCommand && id !=
CommandId::UnknownEscCommand) {
ed.UArgClear();
}
final_count = 0;
}
CommandContext ctx{ed, arg, final_count};
return cmd->handler ? cmd->handler(ctx) : false;
}
bool
Execute(Editor &ed, const std::string &name, const std::string &arg, int count)
{
const Command *cmd = CommandRegistry::FindByName(name);
if (!cmd)
return false;
if (!ed.PromptActive()) {
Buffer *b = ed.CurrentBuffer();
if (b && b->IsReadOnly() && is_mutating_command(cmd->id)) {
ed.SetStatus("Read-only buffer");
return true;
}
}
CommandContext ctx{ed, arg, count};
return cmd->handler ? cmd->handler(ctx) : false;
}