- Recovery works when another named buffer is current. On that path
OpenFile's NotifyFilenameChanged deleted the journal (old path ==
new path) that was about to be replayed; the swap was lost and the
file opened without the edits (pre-existing).
- After recovering from a journal with a torn final record, the torn
bytes are truncated before the session appends to it (ReplayFile now
reports the valid prefix length). A journal that recovers nothing is
removed instead of appended to.
- Reload restarts the journal; recovery used to replay the edits the
reload discarded.
- Opening a file that is already open switches to its buffer (in
OpenFile and before the recovery check). Two buffers for one file
interleaved records in one journal, and closing either unlinked it.
- Swap file names escape '!' and '%', so distinct paths no longer
share a journal ('/x/a!b' vs '/x/a/b'). Swap files for paths that
contain those characters get new names.
- The gap flag is cleared when the journal restarts, and a gap that
cannot be closed (buffer too large to checkpoint) is reported once.
- crc32's table is initialised thread-safely; writer-thread error
reports identify the buffer by journal path instead of racing on
Buffer::Filename(); Close() no longer retries on EINTR (the fd is
already released and may have been reused).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rcVQiiWyTzoNrsvLSaAfB
5598 lines
163 KiB
C++
5598 lines
163 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 "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) {
|
|
len = 1;
|
|
width = (rc == 0) ? 0 : 1;
|
|
return;
|
|
}
|
|
len = rc;
|
|
if (wch == L'\t') {
|
|
width = tabw - (rx % tabw);
|
|
} else {
|
|
const int w = wcwidth(wch);
|
|
width = (w < 0) ? 1 : static_cast<std::size_t>(w);
|
|
}
|
|
}
|
|
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
|
|
// 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());
|
|
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;
|
|
}
|
|
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;
|
|
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 ® = 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 ® = 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 ® = 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 ® = 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 ® = 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 ® = 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);
|
|
// If this is a first-time save (unnamed/non-file-backed) and the
|
|
// target exists, ask for confirmation before overwriting.
|
|
if (!buf->IsFileBacked() && std::filesystem::exists(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;
|
|
} |