Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b0b5b55dce | |||
| 422b27b1ba | |||
| 9485d2aa24 | |||
| 8a6b7851d5 | |||
| 8ec0d6ac41 | |||
| 337b585ba0 | |||
| 95a588b0df | |||
| 199d7a20f7 | |||
| 44827fe53f | |||
| 2a6ff2a862 | |||
| 895e4ccb1e | |||
| 15b350bfaa | |||
| cc8df36bdf | |||
| 1c0f04f076 | |||
| ac0eadc345 | |||
| f3bdced3d4 | |||
| 2551388420 |
@@ -1,6 +1,6 @@
|
|||||||
# Project Guidelines
|
# Project Guidelines
|
||||||
|
|
||||||
kte is Kyle's Text Editor — a simple, fast text editor written in C++17.
|
kte is Kyle's Text Editor — a simple, fast text editor written in C++20.
|
||||||
It
|
It
|
||||||
replaces the earlier C implementation, ke (see the ke manual in
|
replaces the earlier C implementation, ke (see the ke manual in
|
||||||
`docs/ke.md`). The
|
`docs/ke.md`). The
|
||||||
@@ -43,7 +43,7 @@ The file `docs/ke.md` contains the canonical reference for keybindings.
|
|||||||
|
|
||||||
## Contributing/Development Notes
|
## Contributing/Development Notes
|
||||||
|
|
||||||
- C++ standard: C++17.
|
- C++ standard: C++20.
|
||||||
- Keep dependencies minimal.
|
- Keep dependencies minimal.
|
||||||
- Prefer small, focused changes that preserve ke’s UX unless explicitly
|
- Prefer small, focused changes that preserve ke’s UX unless explicitly
|
||||||
changing
|
changing
|
||||||
@@ -55,3 +55,4 @@ The file `docs/ke.md` contains the canonical reference for keybindings.
|
|||||||
for now).
|
for now).
|
||||||
- Inspiration: kilo, WordStar/VDE, emacs, `mg(1)`.
|
- Inspiration: kilo, WordStar/VDE, emacs, `mg(1)`.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
282
Buffer.cc
282
Buffer.cc
@@ -7,7 +7,15 @@
|
|||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
|
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
|
||||||
#include "Buffer.h"
|
#include "Buffer.h"
|
||||||
|
#include "SwapRecorder.h"
|
||||||
#include "UndoSystem.h"
|
#include "UndoSystem.h"
|
||||||
#include "UndoTree.h"
|
#include "UndoTree.h"
|
||||||
// For reconstructing highlighter state on copies
|
// For reconstructing highlighter state on copies
|
||||||
@@ -23,6 +31,159 @@ Buffer::Buffer()
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Buffer::stat_identity(const std::string &path, FileIdentity &out)
|
||||||
|
{
|
||||||
|
struct stat st{};
|
||||||
|
if (::stat(path.c_str(), &st) != 0) {
|
||||||
|
out.valid = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
out.valid = true;
|
||||||
|
// Use nanosecond timestamp when available.
|
||||||
|
std::uint64_t ns = 0;
|
||||||
|
#if defined(__APPLE__)
|
||||||
|
ns = static_cast<std::uint64_t>(st.st_mtimespec.tv_sec) * 1000000000ull
|
||||||
|
+ static_cast<std::uint64_t>(st.st_mtimespec.tv_nsec);
|
||||||
|
#else
|
||||||
|
ns = static_cast<std::uint64_t>(st.st_mtim.tv_sec) * 1000000000ull
|
||||||
|
+ static_cast<std::uint64_t>(st.st_mtim.tv_nsec);
|
||||||
|
#endif
|
||||||
|
out.mtime_ns = ns;
|
||||||
|
out.size = static_cast<std::uint64_t>(st.st_size);
|
||||||
|
out.dev = static_cast<std::uint64_t>(st.st_dev);
|
||||||
|
out.ino = static_cast<std::uint64_t>(st.st_ino);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Buffer::current_disk_identity(FileIdentity &out) const
|
||||||
|
{
|
||||||
|
if (!is_file_backed_ || filename_.empty()) {
|
||||||
|
out.valid = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return stat_identity(filename_, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Buffer::ExternallyModifiedOnDisk() const
|
||||||
|
{
|
||||||
|
if (!is_file_backed_ || filename_.empty())
|
||||||
|
return false;
|
||||||
|
FileIdentity now{};
|
||||||
|
if (!current_disk_identity(now)) {
|
||||||
|
// If the file vanished, treat as modified when we previously had an identity.
|
||||||
|
return on_disk_identity_.valid;
|
||||||
|
}
|
||||||
|
if (!on_disk_identity_.valid)
|
||||||
|
return false;
|
||||||
|
return now.mtime_ns != on_disk_identity_.mtime_ns
|
||||||
|
|| now.size != on_disk_identity_.size
|
||||||
|
|| now.dev != on_disk_identity_.dev
|
||||||
|
|| now.ino != on_disk_identity_.ino;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
Buffer::RefreshOnDiskIdentity()
|
||||||
|
{
|
||||||
|
FileIdentity id{};
|
||||||
|
if (current_disk_identity(id))
|
||||||
|
on_disk_identity_ = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static bool
|
||||||
|
write_all_fd(int fd, const char *data, std::size_t len, std::string &err)
|
||||||
|
{
|
||||||
|
std::size_t off = 0;
|
||||||
|
while (off < len) {
|
||||||
|
ssize_t n = ::write(fd, data + off, len - off);
|
||||||
|
if (n < 0) {
|
||||||
|
if (errno == EINTR)
|
||||||
|
continue;
|
||||||
|
err = std::string("Write failed: ") + std::strerror(errno);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
off += static_cast<std::size_t>(n);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static void
|
||||||
|
best_effort_fsync_dir(const std::string &path)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
std::filesystem::path p(path);
|
||||||
|
std::filesystem::path dir = p.parent_path();
|
||||||
|
if (dir.empty())
|
||||||
|
return;
|
||||||
|
int dfd = ::open(dir.c_str(), O_RDONLY);
|
||||||
|
if (dfd < 0)
|
||||||
|
return;
|
||||||
|
(void) ::fsync(dfd);
|
||||||
|
(void) ::close(dfd);
|
||||||
|
} catch (...) {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static bool
|
||||||
|
atomic_write_file(const std::string &path, const char *data, std::size_t len, std::string &err)
|
||||||
|
{
|
||||||
|
// Create a temp file in the same directory so rename() is atomic.
|
||||||
|
std::filesystem::path p(path);
|
||||||
|
std::filesystem::path dir = p.parent_path();
|
||||||
|
std::string base = p.filename().string();
|
||||||
|
std::filesystem::path tmpl = dir / ("." + base + ".kte.tmp.XXXXXX");
|
||||||
|
std::string tmpl_s = tmpl.string();
|
||||||
|
|
||||||
|
// mkstemp requires a mutable buffer.
|
||||||
|
std::vector<char> buf(tmpl_s.begin(), tmpl_s.end());
|
||||||
|
buf.push_back('\0');
|
||||||
|
int fd = ::mkstemp(buf.data());
|
||||||
|
if (fd < 0) {
|
||||||
|
err = std::string("Failed to create temp file for save: ") + std::strerror(errno);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::string tmp_path(buf.data());
|
||||||
|
|
||||||
|
// If the destination exists, carry over its permissions.
|
||||||
|
struct stat dst_st{};
|
||||||
|
if (::stat(path.c_str(), &dst_st) == 0) {
|
||||||
|
(void) ::fchmod(fd, dst_st.st_mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ok = write_all_fd(fd, data, len, err);
|
||||||
|
if (ok) {
|
||||||
|
if (::fsync(fd) != 0) {
|
||||||
|
err = std::string("fsync failed: ") + std::strerror(errno);
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(void) ::close(fd);
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
if (::rename(tmp_path.c_str(), path.c_str()) != 0) {
|
||||||
|
err = std::string("rename failed: ") + std::strerror(errno);
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ok) {
|
||||||
|
(void) ::unlink(tmp_path.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
best_effort_fsync_dir(path);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Buffer::Buffer(const std::string &path)
|
Buffer::Buffer(const std::string &path)
|
||||||
{
|
{
|
||||||
std::string err;
|
std::string err;
|
||||||
@@ -270,6 +431,7 @@ Buffer::OpenFromFile(const std::string &path, std::string &err)
|
|||||||
filename_ = norm;
|
filename_ = norm;
|
||||||
is_file_backed_ = true;
|
is_file_backed_ = true;
|
||||||
dirty_ = false;
|
dirty_ = false;
|
||||||
|
RefreshOnDiskIdentity();
|
||||||
|
|
||||||
// Reset/initialize undo system for this loaded file
|
// Reset/initialize undo system for this loaded file
|
||||||
if (!undo_tree_)
|
if (!undo_tree_)
|
||||||
@@ -296,22 +458,16 @@ Buffer::Save(std::string &err) const
|
|||||||
err = "Buffer is not file-backed; use SaveAs()";
|
err = "Buffer is not file-backed; use SaveAs()";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
std::ofstream out(filename_, std::ios::out | std::ios::binary | std::ios::trunc);
|
const std::size_t sz = content_.Size();
|
||||||
if (!out) {
|
const char *data = sz ? content_.Data() : nullptr;
|
||||||
err = "Failed to open for write: " + filename_ + ". Error: " + std::string(std::strerror(errno));
|
if (sz && !data) {
|
||||||
|
err = "Internal error: buffer materialization failed";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Stream the content directly from the piece table to avoid relying on
|
if (!atomic_write_file(filename_, data ? data : "", sz, err))
|
||||||
// full materialization, which may yield an empty pointer when size > 0.
|
|
||||||
if (content_.Size() > 0) {
|
|
||||||
content_.WriteToStream(out);
|
|
||||||
}
|
|
||||||
// Ensure data hits the OS buffers
|
|
||||||
out.flush();
|
|
||||||
if (!out.good()) {
|
|
||||||
err = "Write error: " + filename_ + ". Error: " + std::string(std::strerror(errno));
|
|
||||||
return false;
|
return false;
|
||||||
}
|
// Update observed on-disk identity after a successful save.
|
||||||
|
const_cast<Buffer *>(this)->RefreshOnDiskIdentity();
|
||||||
// Note: const method cannot change dirty_. Intentionally const to allow UI code
|
// Note: const method cannot change dirty_. Intentionally const to allow UI code
|
||||||
// to decide when to flip dirty flag after successful save.
|
// to decide when to flip dirty flag after successful save.
|
||||||
return true;
|
return true;
|
||||||
@@ -340,26 +496,19 @@ Buffer::SaveAs(const std::string &path, std::string &err)
|
|||||||
out_path = path;
|
out_path = path;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write to the given path
|
const std::size_t sz = content_.Size();
|
||||||
std::ofstream out(out_path, std::ios::out | std::ios::binary | std::ios::trunc);
|
const char *data = sz ? content_.Data() : nullptr;
|
||||||
if (!out) {
|
if (sz && !data) {
|
||||||
err = "Failed to open for write: " + out_path + ". Error: " + std::string(std::strerror(errno));
|
err = "Internal error: buffer materialization failed";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Stream content without forcing full materialization
|
if (!atomic_write_file(out_path, data ? data : "", sz, err))
|
||||||
if (content_.Size() > 0) {
|
|
||||||
content_.WriteToStream(out);
|
|
||||||
}
|
|
||||||
// Ensure data hits the OS buffers
|
|
||||||
out.flush();
|
|
||||||
if (!out.good()) {
|
|
||||||
err = "Write error: " + out_path + ". Error: " + std::string(std::strerror(errno));
|
|
||||||
return false;
|
return false;
|
||||||
}
|
|
||||||
|
|
||||||
filename_ = out_path;
|
filename_ = out_path;
|
||||||
is_file_backed_ = true;
|
is_file_backed_ = true;
|
||||||
dirty_ = false;
|
dirty_ = false;
|
||||||
|
RefreshOnDiskIdentity();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,6 +539,8 @@ Buffer::insert_text(int row, int col, std::string_view text)
|
|||||||
if (!text.empty()) {
|
if (!text.empty()) {
|
||||||
content_.Insert(off, text.data(), text.size());
|
content_.Insert(off, text.data(), text.size());
|
||||||
rows_cache_dirty_ = true;
|
rows_cache_dirty_ = true;
|
||||||
|
if (swap_rec_)
|
||||||
|
swap_rec_->OnInsert(row, col, text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,6 +585,21 @@ Buffer::content_LineCount_() const
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#if defined(KTE_TESTS)
|
||||||
|
std::string
|
||||||
|
Buffer::BytesForTests() const
|
||||||
|
{
|
||||||
|
const std::size_t sz = content_.Size();
|
||||||
|
if (sz == 0)
|
||||||
|
return std::string();
|
||||||
|
const char *data = content_.Data();
|
||||||
|
if (!data)
|
||||||
|
return std::string();
|
||||||
|
return std::string(data, data + sz);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
Buffer::delete_text(int row, int col, std::size_t len)
|
Buffer::delete_text(int row, int col, std::size_t len)
|
||||||
{
|
{
|
||||||
@@ -443,6 +609,7 @@ Buffer::delete_text(int row, int col, std::size_t len)
|
|||||||
row = 0;
|
row = 0;
|
||||||
if (col < 0)
|
if (col < 0)
|
||||||
col = 0;
|
col = 0;
|
||||||
|
|
||||||
const std::size_t start = content_.LineColToByteOffset(static_cast<std::size_t>(row),
|
const std::size_t start = content_.LineColToByteOffset(static_cast<std::size_t>(row),
|
||||||
static_cast<std::size_t>(col));
|
static_cast<std::size_t>(col));
|
||||||
std::size_t r = static_cast<std::size_t>(row);
|
std::size_t r = static_cast<std::size_t>(row);
|
||||||
@@ -462,16 +629,19 @@ Buffer::delete_text(int row, int col, std::size_t len)
|
|||||||
break;
|
break;
|
||||||
// Consume newline between lines as one char, if there is a next line
|
// Consume newline between lines as one char, if there is a next line
|
||||||
if (r + 1 < lc) {
|
if (r + 1 < lc) {
|
||||||
if (remaining > 0) {
|
remaining -= 1; // the newline
|
||||||
remaining -= 1; // the newline
|
r += 1;
|
||||||
r += 1;
|
c = 0;
|
||||||
c = 0;
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// At last line and still remaining: delete to EOF
|
// At last line and still remaining: delete to EOF
|
||||||
std::size_t total = content_.Size();
|
const std::size_t total = content_.Size();
|
||||||
content_.Delete(start, total - start);
|
const std::size_t actual = (total > start) ? (total - start) : 0;
|
||||||
|
if (actual == 0)
|
||||||
|
return;
|
||||||
|
content_.Delete(start, actual);
|
||||||
rows_cache_dirty_ = true;
|
rows_cache_dirty_ = true;
|
||||||
|
if (swap_rec_)
|
||||||
|
swap_rec_->OnDelete(row, col, actual);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -479,8 +649,11 @@ Buffer::delete_text(int row, int col, std::size_t len)
|
|||||||
// Compute end offset at (r,c)
|
// Compute end offset at (r,c)
|
||||||
std::size_t end = content_.LineColToByteOffset(r, c);
|
std::size_t end = content_.LineColToByteOffset(r, c);
|
||||||
if (end > start) {
|
if (end > start) {
|
||||||
content_.Delete(start, end - start);
|
const std::size_t actual = end - start;
|
||||||
|
content_.Delete(start, actual);
|
||||||
rows_cache_dirty_ = true;
|
rows_cache_dirty_ = true;
|
||||||
|
if (swap_rec_)
|
||||||
|
swap_rec_->OnDelete(row, col, actual);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,15 +661,18 @@ Buffer::delete_text(int row, int col, std::size_t len)
|
|||||||
void
|
void
|
||||||
Buffer::split_line(int row, const int col)
|
Buffer::split_line(int row, const int col)
|
||||||
{
|
{
|
||||||
|
int c = col;
|
||||||
if (row < 0)
|
if (row < 0)
|
||||||
row = 0;
|
row = 0;
|
||||||
if (col < 0)
|
if (c < 0)
|
||||||
row = 0;
|
c = 0;
|
||||||
const std::size_t off = content_.LineColToByteOffset(static_cast<std::size_t>(row),
|
const std::size_t off = content_.LineColToByteOffset(static_cast<std::size_t>(row),
|
||||||
static_cast<std::size_t>(col));
|
static_cast<std::size_t>(c));
|
||||||
const char nl = '\n';
|
const char nl = '\n';
|
||||||
content_.Insert(off, &nl, 1);
|
content_.Insert(off, &nl, 1);
|
||||||
rows_cache_dirty_ = true;
|
rows_cache_dirty_ = true;
|
||||||
|
if (swap_rec_)
|
||||||
|
swap_rec_->OnInsert(row, c, std::string_view("\n", 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -508,11 +684,14 @@ Buffer::join_lines(int row)
|
|||||||
std::size_t r = static_cast<std::size_t>(row);
|
std::size_t r = static_cast<std::size_t>(row);
|
||||||
if (r + 1 >= content_.LineCount())
|
if (r + 1 >= content_.LineCount())
|
||||||
return;
|
return;
|
||||||
|
const int col = static_cast<int>(content_.GetLine(r).size());
|
||||||
// Delete the newline between line r and r+1
|
// Delete the newline between line r and r+1
|
||||||
std::size_t end_of_line = content_.LineColToByteOffset(r, std::numeric_limits<std::size_t>::max());
|
std::size_t end_of_line = content_.LineColToByteOffset(r, std::numeric_limits<std::size_t>::max());
|
||||||
// end_of_line now equals line end (clamped before newline). The newline should be exactly at this position.
|
// end_of_line now equals line end (clamped before newline). The newline should be exactly at this position.
|
||||||
content_.Delete(end_of_line, 1);
|
content_.Delete(end_of_line, 1);
|
||||||
rows_cache_dirty_ = true;
|
rows_cache_dirty_ = true;
|
||||||
|
if (swap_rec_)
|
||||||
|
swap_rec_->OnDelete(row, col, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -527,6 +706,12 @@ Buffer::insert_row(int row, const std::string_view text)
|
|||||||
const char nl = '\n';
|
const char nl = '\n';
|
||||||
content_.Insert(off + text.size(), &nl, 1);
|
content_.Insert(off + text.size(), &nl, 1);
|
||||||
rows_cache_dirty_ = true;
|
rows_cache_dirty_ = true;
|
||||||
|
if (swap_rec_) {
|
||||||
|
// Avoid allocation: emit the row text insertion (if any) and the newline insertion.
|
||||||
|
if (!text.empty())
|
||||||
|
swap_rec_->OnInsert(row, 0, text);
|
||||||
|
swap_rec_->OnInsert(row, static_cast<int>(text.size()), std::string_view("\n", 1));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -541,9 +726,24 @@ Buffer::delete_row(int row)
|
|||||||
auto range = content_.GetLineRange(r); // [start,end)
|
auto range = content_.GetLineRange(r); // [start,end)
|
||||||
// If not last line, ensure we include the separating newline by using end as-is (which points to next line start)
|
// If not last line, ensure we include the separating newline by using end as-is (which points to next line start)
|
||||||
// If last line, end may equal total_size_. We still delete [start,end) which removes the last line content.
|
// If last line, end may equal total_size_. We still delete [start,end) which removes the last line content.
|
||||||
std::size_t start = range.first;
|
const std::size_t start = range.first;
|
||||||
std::size_t end = range.second;
|
const std::size_t end = range.second;
|
||||||
content_.Delete(start, end - start);
|
const std::size_t actual = (end > start) ? (end - start) : 0;
|
||||||
|
if (actual == 0)
|
||||||
|
return;
|
||||||
|
content_.Delete(start, actual);
|
||||||
|
rows_cache_dirty_ = true;
|
||||||
|
if (swap_rec_)
|
||||||
|
swap_rec_->OnDelete(row, 0, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
Buffer::replace_all_bytes(const std::string_view bytes)
|
||||||
|
{
|
||||||
|
content_.Clear();
|
||||||
|
if (!bytes.empty())
|
||||||
|
content_.Append(bytes.data(), bytes.size());
|
||||||
rows_cache_dirty_ = true;
|
rows_cache_dirty_ = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -560,4 +760,4 @@ const UndoSystem *
|
|||||||
Buffer::Undo() const
|
Buffer::Undo() const
|
||||||
{
|
{
|
||||||
return undo_sys_.get();
|
return undo_sys_.get();
|
||||||
}
|
}
|
||||||
|
|||||||
148
Buffer.h
148
Buffer.h
@@ -1,5 +1,37 @@
|
|||||||
/*
|
/*
|
||||||
* Buffer.h - editor buffer representing an open document
|
* Buffer.h - editor buffer representing an open document
|
||||||
|
*
|
||||||
|
* Buffer is the central document model in kte. Each Buffer represents one open file
|
||||||
|
* or scratch document and manages:
|
||||||
|
*
|
||||||
|
* - Content storage: Uses PieceTable for efficient text operations
|
||||||
|
* - Cursor state: Current position (curx_, cury_), rendered column (rx_)
|
||||||
|
* - Viewport: Scroll offsets (rowoffs_, coloffs_) for display
|
||||||
|
* - File backing: Optional association with a file on disk
|
||||||
|
* - Undo/Redo: Integrated UndoSystem for operation history
|
||||||
|
* - Syntax highlighting: Optional HighlighterEngine for language-aware coloring
|
||||||
|
* - Swap/crash recovery: Integration with SwapRecorder for journaling
|
||||||
|
* - Dirty tracking: Modification state for save prompts
|
||||||
|
*
|
||||||
|
* Key concepts:
|
||||||
|
*
|
||||||
|
* 1. Cursor coordinates:
|
||||||
|
* - (curx_, cury_): Logical character position in the document
|
||||||
|
* - rx_: Rendered column accounting for tab expansion
|
||||||
|
*
|
||||||
|
* 2. File backing:
|
||||||
|
* - Buffers can be file-backed (associated with a path) or scratch (unnamed)
|
||||||
|
* - File identity tracking detects external modifications
|
||||||
|
*
|
||||||
|
* 3. Legacy Line wrapper:
|
||||||
|
* - Buffer::Line provides a string-like interface for legacy command code
|
||||||
|
* - New code should prefer direct PieceTable operations
|
||||||
|
* - See DEVELOPER_GUIDE.md for migration guidance
|
||||||
|
*
|
||||||
|
* 4. Content access:
|
||||||
|
* - Rows(): Materialized line cache (legacy, being phased out)
|
||||||
|
* - GetLineView(): Zero-copy line access via string_view (preferred)
|
||||||
|
* - Direct PieceTable access for new editing operations
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
@@ -42,6 +74,14 @@ public:
|
|||||||
bool Save(std::string &err) const; // saves to existing filename; returns false if not file-backed
|
bool Save(std::string &err) const; // saves to existing filename; returns false if not file-backed
|
||||||
bool SaveAs(const std::string &path, std::string &err); // saves to path and makes buffer file-backed
|
bool SaveAs(const std::string &path, std::string &err); // saves to path and makes buffer file-backed
|
||||||
|
|
||||||
|
// External modification detection.
|
||||||
|
// Returns true if the file on disk differs from the last observed identity recorded
|
||||||
|
// on open/save.
|
||||||
|
[[nodiscard]] bool ExternallyModifiedOnDisk() const;
|
||||||
|
|
||||||
|
// Refresh the stored on-disk identity to match current stat (used after open/save).
|
||||||
|
void RefreshOnDiskIdentity();
|
||||||
|
|
||||||
// Accessors
|
// Accessors
|
||||||
[[nodiscard]] std::size_t Curx() const
|
[[nodiscard]] std::size_t Curx() const
|
||||||
{
|
{
|
||||||
@@ -370,6 +410,71 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Visual-line selection support (multicursor/visual mode)
|
||||||
|
void VisualLineClear()
|
||||||
|
{
|
||||||
|
visual_line_active_ = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void VisualLineStart()
|
||||||
|
{
|
||||||
|
visual_line_active_ = true;
|
||||||
|
visual_line_anchor_y_ = cury_;
|
||||||
|
visual_line_active_y_ = cury_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void VisualLineToggle()
|
||||||
|
{
|
||||||
|
if (visual_line_active_)
|
||||||
|
VisualLineClear();
|
||||||
|
else
|
||||||
|
VisualLineStart();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] bool VisualLineActive() const
|
||||||
|
{
|
||||||
|
return visual_line_active_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void VisualLineSetActiveY(std::size_t y)
|
||||||
|
{
|
||||||
|
visual_line_active_y_ = y;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] std::size_t VisualLineStartY() const
|
||||||
|
{
|
||||||
|
return visual_line_anchor_y_ < visual_line_active_y_ ? visual_line_anchor_y_ : visual_line_active_y_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] std::size_t VisualLineEndY() const
|
||||||
|
{
|
||||||
|
return visual_line_anchor_y_ < visual_line_active_y_ ? visual_line_active_y_ : visual_line_anchor_y_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// In visual-line (multi-cursor) mode, the UI should highlight only the per-line
|
||||||
|
// cursor "spot" (Curx clamped to each line length), not the entire line.
|
||||||
|
[[nodiscard]] bool VisualLineSpotSelected(std::size_t y, std::size_t sx) const
|
||||||
|
{
|
||||||
|
if (!visual_line_active_)
|
||||||
|
return false;
|
||||||
|
if (y < VisualLineStartY() || y > VisualLineEndY())
|
||||||
|
return false;
|
||||||
|
std::string_view ln = GetLineView(y);
|
||||||
|
// `GetLineView()` returns the raw range, which may include a trailing '\n'.
|
||||||
|
if (!ln.empty() && ln.back() == '\n')
|
||||||
|
ln.remove_suffix(1);
|
||||||
|
const std::size_t spot = std::min(Curx(), ln.size());
|
||||||
|
return sx == spot;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
[[nodiscard]] std::string AsString() const;
|
[[nodiscard]] std::string AsString() const;
|
||||||
|
|
||||||
// Syntax highlighting integration (per-buffer)
|
// Syntax highlighting integration (per-buffer)
|
||||||
@@ -429,6 +534,12 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[[nodiscard]] kte::SwapRecorder *SwapRecorder() const
|
||||||
|
{
|
||||||
|
return swap_rec_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Raw, low-level editing APIs used by UndoSystem apply().
|
// Raw, low-level editing APIs used by UndoSystem apply().
|
||||||
// These must NOT trigger undo recording. They also do not move the cursor.
|
// These must NOT trigger undo recording. They also do not move the cursor.
|
||||||
void insert_text(int row, int col, std::string_view text);
|
void insert_text(int row, int col, std::string_view text);
|
||||||
@@ -443,12 +554,36 @@ public:
|
|||||||
|
|
||||||
void delete_row(int row);
|
void delete_row(int row);
|
||||||
|
|
||||||
|
// Replace the entire buffer content with raw bytes.
|
||||||
|
// Intended for crash recovery (swap replay) and test harnesses.
|
||||||
|
// This does not trigger swap or undo recording.
|
||||||
|
void replace_all_bytes(std::string_view bytes);
|
||||||
|
|
||||||
// Undo system accessors (created per-buffer)
|
// Undo system accessors (created per-buffer)
|
||||||
[[nodiscard]] UndoSystem *Undo();
|
[[nodiscard]] UndoSystem *Undo();
|
||||||
|
|
||||||
[[nodiscard]] const UndoSystem *Undo() const;
|
[[nodiscard]] const UndoSystem *Undo() const;
|
||||||
|
|
||||||
|
#if defined(KTE_TESTS)
|
||||||
|
// Test-only: return the raw buffer bytes (including newlines) as a string.
|
||||||
|
[[nodiscard]] std::string BytesForTests() const;
|
||||||
|
#endif
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
struct FileIdentity {
|
||||||
|
bool valid = false;
|
||||||
|
std::uint64_t mtime_ns = 0;
|
||||||
|
std::uint64_t size = 0;
|
||||||
|
std::uint64_t dev = 0;
|
||||||
|
std::uint64_t ino = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
[[nodiscard]] static bool stat_identity(const std::string &path, FileIdentity &out);
|
||||||
|
|
||||||
|
[[nodiscard]] bool current_disk_identity(FileIdentity &out) const;
|
||||||
|
|
||||||
|
mutable FileIdentity on_disk_identity_{};
|
||||||
|
|
||||||
// State mirroring original C struct (without undo_tree)
|
// State mirroring original C struct (without undo_tree)
|
||||||
std::size_t curx_ = 0, cury_ = 0; // cursor position in characters
|
std::size_t curx_ = 0, cury_ = 0; // cursor position in characters
|
||||||
std::size_t rx_ = 0; // render x (tabs expanded)
|
std::size_t rx_ = 0; // render x (tabs expanded)
|
||||||
@@ -466,11 +601,14 @@ private:
|
|||||||
std::size_t content_LineCount_() const;
|
std::size_t content_LineCount_() const;
|
||||||
|
|
||||||
std::string filename_;
|
std::string filename_;
|
||||||
bool is_file_backed_ = false;
|
bool is_file_backed_ = false;
|
||||||
bool dirty_ = false;
|
bool dirty_ = false;
|
||||||
bool read_only_ = false;
|
bool read_only_ = false;
|
||||||
bool mark_set_ = false;
|
bool mark_set_ = false;
|
||||||
std::size_t mark_curx_ = 0, mark_cury_ = 0;
|
std::size_t mark_curx_ = 0, mark_cury_ = 0;
|
||||||
|
bool visual_line_active_ = false;
|
||||||
|
std::size_t visual_line_anchor_y_ = 0;
|
||||||
|
std::size_t visual_line_active_y_ = 0;
|
||||||
|
|
||||||
// Per-buffer undo state
|
// Per-buffer undo state
|
||||||
std::unique_ptr<struct UndoTree> undo_tree_;
|
std::unique_ptr<struct UndoTree> undo_tree_;
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ project(kte)
|
|||||||
include(GNUInstallDirs)
|
include(GNUInstallDirs)
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD 20)
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
set(KTE_VERSION "1.5.9")
|
set(KTE_VERSION "1.6.6")
|
||||||
|
|
||||||
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
|
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
|
||||||
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
|
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
|
||||||
set(BUILD_GUI ON CACHE BOOL "Enable building the graphical version.")
|
set(BUILD_GUI ON CACHE BOOL "Enable building the graphical version.")
|
||||||
set(KTE_USE_QT OFF CACHE BOOL "Build the QT frontend instead of ImGui.")
|
set(KTE_USE_QT OFF CACHE BOOL "Build the QT frontend instead of ImGui.")
|
||||||
set(BUILD_TESTS OFF CACHE BOOL "Enable building test programs.")
|
set(BUILD_TESTS ON CACHE BOOL "Enable building test programs.")
|
||||||
set(KTE_FONT_SIZE "18.0" CACHE STRING "Default font size for GUI")
|
set(KTE_FONT_SIZE "18.0" CACHE STRING "Default font size for GUI")
|
||||||
option(KTE_UNDO_DEBUG "Enable undo instrumentation logs" OFF)
|
option(KTE_UNDO_DEBUG "Enable undo instrumentation logs" OFF)
|
||||||
option(KTE_ENABLE_TREESITTER "Enable optional Tree-sitter highlighter adapter" OFF)
|
option(KTE_ENABLE_TREESITTER "Enable optional Tree-sitter highlighter adapter" OFF)
|
||||||
@@ -68,11 +68,19 @@ if (BUILD_GUI)
|
|||||||
endif ()
|
endif ()
|
||||||
|
|
||||||
# NCurses for terminal mode
|
# NCurses for terminal mode
|
||||||
set(CURSES_NEED_NCURSES)
|
set(CURSES_NEED_NCURSES TRUE)
|
||||||
set(CURSES_NEED_WIDE)
|
set(CURSES_NEED_WIDE TRUE)
|
||||||
find_package(Curses REQUIRED)
|
find_package(Curses REQUIRED)
|
||||||
include_directories(${CURSES_INCLUDE_DIR})
|
include_directories(${CURSES_INCLUDE_DIR})
|
||||||
|
|
||||||
|
# On Alpine Linux, CMake's FindCurses looks in wrong paths
|
||||||
|
# Manually find the correct ncurses library
|
||||||
|
if (EXISTS "/etc/alpine-release")
|
||||||
|
find_library(NCURSESW_LIB NAMES ncursesw PATHS /usr/lib /lib REQUIRED)
|
||||||
|
set(CURSES_LIBRARIES ${NCURSESW_LIB})
|
||||||
|
message(STATUS "Alpine Linux detected, using ncurses at: ${NCURSESW_LIB}")
|
||||||
|
endif ()
|
||||||
|
|
||||||
set(SYNTAX_SOURCES
|
set(SYNTAX_SOURCES
|
||||||
syntax/GoHighlighter.cc
|
syntax/GoHighlighter.cc
|
||||||
syntax/CppHighlighter.cc
|
syntax/CppHighlighter.cc
|
||||||
@@ -298,13 +306,38 @@ if (BUILD_TESTS)
|
|||||||
add_executable(kte_tests
|
add_executable(kte_tests
|
||||||
tests/TestRunner.cc
|
tests/TestRunner.cc
|
||||||
tests/Test.h
|
tests/Test.h
|
||||||
|
tests/TestHarness.h
|
||||||
|
tests/test_daily_driver_harness.cc
|
||||||
|
tests/test_daily_workflows.cc
|
||||||
tests/test_buffer_io.cc
|
tests/test_buffer_io.cc
|
||||||
|
tests/test_buffer_rows.cc
|
||||||
|
tests/test_command_semantics.cc
|
||||||
|
tests/test_kkeymap.cc
|
||||||
|
tests/test_swap_recorder.cc
|
||||||
|
tests/test_swap_writer.cc
|
||||||
|
tests/test_swap_replay.cc
|
||||||
|
tests/test_swap_recovery_prompt.cc
|
||||||
|
tests/test_swap_cleanup.cc
|
||||||
|
tests/test_swap_git_editor.cc
|
||||||
tests/test_piece_table.cc
|
tests/test_piece_table.cc
|
||||||
tests/test_search.cc
|
tests/test_search.cc
|
||||||
|
tests/test_search_replace_flow.cc
|
||||||
|
tests/test_reflow_paragraph.cc
|
||||||
|
tests/test_reflow_indented_bullets.cc
|
||||||
|
tests/test_undo.cc
|
||||||
|
tests/test_visual_line_mode.cc
|
||||||
|
tests/test_benchmarks.cc
|
||||||
|
tests/test_migration_coverage.cc
|
||||||
|
|
||||||
# minimal engine sources required by Buffer
|
# minimal engine sources required by Buffer
|
||||||
PieceTable.cc
|
PieceTable.cc
|
||||||
Buffer.cc
|
Buffer.cc
|
||||||
|
Editor.cc
|
||||||
|
Command.cc
|
||||||
|
HelpText.cc
|
||||||
|
Swap.cc
|
||||||
|
KKeymap.cc
|
||||||
|
SwapRecorder.h
|
||||||
OptimizedSearch.cc
|
OptimizedSearch.cc
|
||||||
UndoNode.cc
|
UndoNode.cc
|
||||||
UndoTree.cc
|
UndoTree.cc
|
||||||
@@ -312,6 +345,9 @@ if (BUILD_TESTS)
|
|||||||
${SYNTAX_SOURCES}
|
${SYNTAX_SOURCES}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Allow test-only introspection hooks (guarded in headers) without affecting production builds.
|
||||||
|
target_compile_definitions(kte_tests PRIVATE KTE_TESTS=1)
|
||||||
|
|
||||||
# Allow tests to include project headers like "Buffer.h"
|
# Allow tests to include project headers like "Buffer.h"
|
||||||
target_include_directories(kte_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
target_include_directories(kte_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||||
|
|
||||||
|
|||||||
587
Command.cc
587
Command.cc
@@ -618,6 +618,8 @@ cmd_save(CommandContext &ctx)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
buf->SetDirty(false);
|
buf->SetDirty(false);
|
||||||
|
if (auto *sm = ctx.editor.Swap())
|
||||||
|
sm->ResetJournal(*buf);
|
||||||
ctx.editor.SetStatus("Saved " + buf->Filename());
|
ctx.editor.SetStatus("Saved " + buf->Filename());
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -627,11 +629,22 @@ cmd_save(CommandContext &ctx)
|
|||||||
ctx.editor.SetStatus("Save as: ");
|
ctx.editor.SetStatus("Save as: ");
|
||||||
return true;
|
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)) {
|
if (!buf->Save(err)) {
|
||||||
ctx.editor.SetStatus(err);
|
ctx.editor.SetStatus(err);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
buf->SetDirty(false);
|
buf->SetDirty(false);
|
||||||
|
if (auto *sm = ctx.editor.Swap())
|
||||||
|
sm->ResetJournal(*buf);
|
||||||
ctx.editor.SetStatus("Saved " + buf->Filename());
|
ctx.editor.SetStatus("Saved " + buf->Filename());
|
||||||
if (auto *u = buf->Undo())
|
if (auto *u = buf->Undo())
|
||||||
u->mark_saved();
|
u->mark_saved();
|
||||||
@@ -686,6 +699,10 @@ cmd_save_as(CommandContext &ctx)
|
|||||||
ctx.editor.SetStatus(err);
|
ctx.editor.SetStatus(err);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (auto *sm = ctx.editor.Swap()) {
|
||||||
|
sm->NotifyFilenameChanged(*buf);
|
||||||
|
sm->ResetJournal(*buf);
|
||||||
|
}
|
||||||
ctx.editor.SetStatus("Saved as " + ctx.arg);
|
ctx.editor.SetStatus("Saved as " + ctx.arg);
|
||||||
if (auto *u = buf->Undo())
|
if (auto *u = buf->Undo())
|
||||||
u->mark_saved();
|
u->mark_saved();
|
||||||
@@ -761,6 +778,15 @@ cmd_quit_now(CommandContext &ctx)
|
|||||||
static bool
|
static bool
|
||||||
cmd_refresh(CommandContext &ctx)
|
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 a generic prompt is active, cancel it
|
||||||
if (ctx.editor.PromptActive()) {
|
if (ctx.editor.PromptActive()) {
|
||||||
// If also in search mode, restore state
|
// If also in search mode, restore state
|
||||||
@@ -780,6 +806,7 @@ cmd_refresh(CommandContext &ctx)
|
|||||||
ctx.editor.SetCloseConfirmPending(false);
|
ctx.editor.SetCloseConfirmPending(false);
|
||||||
ctx.editor.SetCloseAfterSave(false);
|
ctx.editor.SetCloseAfterSave(false);
|
||||||
ctx.editor.ClearPendingOverwritePath();
|
ctx.editor.ClearPendingOverwritePath();
|
||||||
|
ctx.editor.CancelRecoveryPrompt();
|
||||||
ctx.editor.CancelPrompt();
|
ctx.editor.CancelPrompt();
|
||||||
ctx.editor.SetStatus("Canceled");
|
ctx.editor.SetStatus("Canceled");
|
||||||
return true;
|
return true;
|
||||||
@@ -799,6 +826,14 @@ cmd_refresh(CommandContext &ctx)
|
|||||||
ctx.editor.SetStatus("Find canceled");
|
ctx.editor.SetStatus("Find canceled");
|
||||||
return true;
|
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
|
// Otherwise just a hint; renderer will redraw
|
||||||
ctx.editor.SetStatus("");
|
ctx.editor.SetStatus("");
|
||||||
return true;
|
return true;
|
||||||
@@ -1074,33 +1109,34 @@ cmd_theme_set_by_name(const CommandContext &ctx)
|
|||||||
static bool
|
static bool
|
||||||
cmd_theme_set_by_name(CommandContext &ctx)
|
cmd_theme_set_by_name(CommandContext &ctx)
|
||||||
{
|
{
|
||||||
|
|
||||||
# if defined(KTE_BUILD_GUI) && defined(KTE_USE_QT)
|
# if defined(KTE_BUILD_GUI) && defined(KTE_USE_QT)
|
||||||
// Qt GUI build: schedule theme change for frontend
|
// Qt GUI build: schedule theme change for frontend
|
||||||
std::string name = ctx.arg;
|
std::string name = ctx.arg;
|
||||||
// trim spaces
|
// trim spaces
|
||||||
auto ltrim = [](std::string &s) {
|
auto ltrim = [](std::string &s) {
|
||||||
s.erase(s.begin(), std::find_if(s.begin(), s.end(), [](unsigned char ch) {
|
s.erase(s.begin(), std::find_if(s.begin(), s.end(), [](unsigned char ch) {
|
||||||
return !std::isspace(ch);
|
return !std::isspace(ch);
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
auto rtrim = [](std::string &s) {
|
auto rtrim = [](std::string &s) {
|
||||||
s.erase(std::find_if(s.rbegin(), s.rend(), [](unsigned char ch) {
|
s.erase(std::find_if(s.rbegin(), s.rend(), [](unsigned char ch) {
|
||||||
return !std::isspace(ch);
|
return !std::isspace(ch);
|
||||||
}).base(), s.end());
|
}).base(), s.end());
|
||||||
};
|
};
|
||||||
ltrim(name);
|
ltrim (name);
|
||||||
rtrim(name);
|
rtrim (name);
|
||||||
if (name.empty()) {
|
if (name.empty()) {
|
||||||
ctx.editor.SetStatus("theme: provide a name (e.g., nord, solarized-dark, gruvbox-light, eink)");
|
ctx.editor.SetStatus("theme: provide a name (e.g., nord, solarized-dark, gruvbox-light, eink)");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
kte::gThemeChangeRequest = name;
|
kte::gThemeChangeRequest= name;
|
||||||
kte::gThemeChangePending = true;
|
kte::gThemeChangePending=true;
|
||||||
ctx.editor.SetStatus(std::string("Theme requested: ") + name);
|
ctx.editor.SetStatus (std::string("Theme requested: ") + name);
|
||||||
return true;
|
return true;
|
||||||
# else
|
# else
|
||||||
(void) ctx;
|
(void) ctx;
|
||||||
// No-op in terminal build
|
// No-op in terminal build
|
||||||
return true;
|
return true;
|
||||||
# endif
|
# endif
|
||||||
}
|
}
|
||||||
@@ -1968,30 +2004,77 @@ cmd_insert_text(CommandContext &ctx)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
ensure_at_least_one_line(*buf);
|
ensure_at_least_one_line(*buf);
|
||||||
std::size_t y = buf->Cury();
|
std::size_t y = buf->Cury();
|
||||||
std::size_t x = buf->Curx();
|
std::size_t x = buf->Curx();
|
||||||
std::size_t ins_y = y;
|
int repeat = ctx.count > 0 ? ctx.count : 1;
|
||||||
std::size_t ins_x = x; // remember insertion start for undo positioning
|
std::size_t cx = x;
|
||||||
int repeat = ctx.count > 0 ? ctx.count : 1;
|
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 = buf->Rows();
|
||||||
|
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,
|
// Apply edits to the underlying PieceTable through Buffer::insert_text,
|
||||||
// not directly to the legacy rows_ cache. This ensures Save() persists text.
|
// not directly to the legacy rows_ cache. This ensures Save() persists text.
|
||||||
for (int i = 0; i < repeat; ++i) {
|
for (int i = 0; i < repeat; ++i) {
|
||||||
buf->insert_text(static_cast<int>(y), static_cast<int>(x), std::string_view(ctx.arg));
|
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();
|
x += ctx.arg.size();
|
||||||
}
|
}
|
||||||
buf->SetDirty(true);
|
buf->SetDirty(true);
|
||||||
// Record undo for this contiguous insert at the original insertion point
|
|
||||||
if (auto *u = buf->Undo()) {
|
|
||||||
// Position cursor at insertion start for the undo record
|
|
||||||
buf->SetCursor(ins_x, ins_y);
|
|
||||||
u->Begin(UndoType::Insert);
|
|
||||||
for (int i = 0; i < repeat; ++i) {
|
|
||||||
u->Append(std::string_view(ctx.arg));
|
|
||||||
}
|
|
||||||
// Finalize this contiguous insert as a single undoable action
|
|
||||||
u->commit();
|
|
||||||
}
|
|
||||||
buf->SetCursor(x, y);
|
buf->SetCursor(x, y);
|
||||||
ensure_cursor_visible(ctx.editor, *buf);
|
ensure_cursor_visible(ctx.editor, *buf);
|
||||||
return true;
|
return true;
|
||||||
@@ -2385,7 +2468,6 @@ cmd_newline(CommandContext &ctx)
|
|||||||
ctx.editor.SetSearchIndex(-1);
|
ctx.editor.SetSearchIndex(-1);
|
||||||
return true;
|
return true;
|
||||||
} else if (kind == Editor::PromptKind::OpenFile) {
|
} else if (kind == Editor::PromptKind::OpenFile) {
|
||||||
std::string err;
|
|
||||||
// Expand "~" to the user's home directory
|
// Expand "~" to the user's home directory
|
||||||
auto expand_user_path = [](const std::string &in) -> std::string {
|
auto expand_user_path = [](const std::string &in) -> std::string {
|
||||||
if (!in.empty() && in[0] == '~') {
|
if (!in.empty() && in[0] == '~') {
|
||||||
@@ -2402,14 +2484,19 @@ cmd_newline(CommandContext &ctx)
|
|||||||
value = expand_user_path(value);
|
value = expand_user_path(value);
|
||||||
if (value.empty()) {
|
if (value.empty()) {
|
||||||
ctx.editor.SetStatus("Open canceled (empty)");
|
ctx.editor.SetStatus("Open canceled (empty)");
|
||||||
} else if (!ctx.editor.OpenFile(value, err)) {
|
|
||||||
ctx.editor.SetStatus(err.empty() ? std::string("Failed to open ") + value : err);
|
|
||||||
} else {
|
} else {
|
||||||
ctx.editor.SetStatus(std::string("Opened ") + value);
|
ctx.editor.RequestOpenFile(value);
|
||||||
// Center the view on the cursor (e.g. if the buffer restored a cursor position)
|
const bool opened = ctx.editor.ProcessPendingOpens();
|
||||||
cmd_center_on_cursor(ctx);
|
if (ctx.editor.PromptActive()) {
|
||||||
// Close the prompt so subsequent typing edits the buffer, not the prompt
|
// A recovery confirmation prompt was started.
|
||||||
ctx.editor.CancelPrompt();
|
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) {
|
} else if (kind == Editor::PromptKind::BufferSwitch) {
|
||||||
// Resolve to a buffer index by exact match against path or basename;
|
// Resolve to a buffer index by exact match against path or basename;
|
||||||
@@ -2519,11 +2606,19 @@ cmd_newline(CommandContext &ctx)
|
|||||||
}
|
}
|
||||||
if (yes) {
|
if (yes) {
|
||||||
std::string err;
|
std::string err;
|
||||||
if (!buf->SaveAs(target, 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);
|
ctx.editor.SetStatus(err);
|
||||||
} else {
|
} else {
|
||||||
buf->SetDirty(false);
|
buf->SetDirty(false);
|
||||||
ctx.editor.SetStatus("Saved as " + target);
|
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())
|
if (auto *u = buf->Undo())
|
||||||
u->mark_saved();
|
u->mark_saved();
|
||||||
// If this overwrite confirm was part of a close-after-save flow, close now.
|
// If this overwrite confirm was part of a close-after-save flow, close now.
|
||||||
@@ -2556,6 +2651,16 @@ cmd_newline(CommandContext &ctx)
|
|||||||
ctx.editor.ClearPendingOverwritePath();
|
ctx.editor.ClearPendingOverwritePath();
|
||||||
// Regardless of answer, end any close-after-save pending state for safety.
|
// Regardless of answer, end any close-after-save pending state for safety.
|
||||||
ctx.editor.SetCloseAfterSave(false);
|
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) {
|
} else if (ctx.editor.CloseConfirmPending() && buf) {
|
||||||
bool yes = false;
|
bool yes = false;
|
||||||
if (!value.empty()) {
|
if (!value.empty()) {
|
||||||
@@ -2574,6 +2679,8 @@ cmd_newline(CommandContext &ctx)
|
|||||||
proceed_to_close = false;
|
proceed_to_close = false;
|
||||||
} else {
|
} else {
|
||||||
buf->SetDirty(false);
|
buf->SetDirty(false);
|
||||||
|
if (auto *sm = ctx.editor.Swap())
|
||||||
|
sm->ResetJournal(*buf);
|
||||||
if (auto *u = buf->Undo())
|
if (auto *u = buf->Undo())
|
||||||
u->mark_saved();
|
u->mark_saved();
|
||||||
}
|
}
|
||||||
@@ -2583,6 +2690,10 @@ cmd_newline(CommandContext &ctx)
|
|||||||
proceed_to_close = false;
|
proceed_to_close = false;
|
||||||
} else {
|
} else {
|
||||||
buf->SetDirty(false);
|
buf->SetDirty(false);
|
||||||
|
if (auto *sm = ctx.editor.Swap()) {
|
||||||
|
sm->NotifyFilenameChanged(*buf);
|
||||||
|
sm->ResetJournal(*buf);
|
||||||
|
}
|
||||||
if (auto *u = buf->Undo())
|
if (auto *u = buf->Undo())
|
||||||
u->mark_saved();
|
u->mark_saved();
|
||||||
}
|
}
|
||||||
@@ -2619,6 +2730,8 @@ cmd_newline(CommandContext &ctx)
|
|||||||
ctx.editor.SetStatus("No buffer");
|
ctx.editor.SetStatus("No buffer");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (auto *u = buf->Undo())
|
||||||
|
u->commit();
|
||||||
std::size_t nrows = buf->Nrows();
|
std::size_t nrows = buf->Nrows();
|
||||||
if (nrows == 0) {
|
if (nrows == 0) {
|
||||||
buf->SetCursor(0, 0);
|
buf->SetCursor(0, 0);
|
||||||
@@ -2784,6 +2897,41 @@ cmd_newline(CommandContext &ctx)
|
|||||||
std::size_t y = buf->Cury();
|
std::size_t y = buf->Cury();
|
||||||
std::size_t x = buf->Curx();
|
std::size_t x = buf->Curx();
|
||||||
int repeat = ctx.count > 0 ? ctx.count : 1;
|
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();
|
||||||
|
const auto &rows = buf->Rows();
|
||||||
|
if (rows.empty())
|
||||||
|
return true;
|
||||||
|
std::size_t splits_above = 0;
|
||||||
|
if (sy < y)
|
||||||
|
splits_above = std::min(ey, y - 1) - sy + 1;
|
||||||
|
|
||||||
|
// Iterate bottom-up to keep row indices stable while splitting.
|
||||||
|
for (std::size_t yy = ey + 1; yy-- > sy;) {
|
||||||
|
const auto &rows_view = buf->Rows();
|
||||||
|
if (yy >= rows_view.size())
|
||||||
|
continue;
|
||||||
|
std::size_t xx = x;
|
||||||
|
if (xx > rows_view[yy].size())
|
||||||
|
xx = rows_view[yy].size();
|
||||||
|
// First split at the cursor column; subsequent splits create blank lines.
|
||||||
|
buf->split_line(static_cast<int>(yy), static_cast<int>(xx));
|
||||||
|
for (int i = 1; i < repeat; ++i) {
|
||||||
|
buf->split_line(static_cast<int>(yy + i), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buf->SetDirty(true);
|
||||||
|
// Cursor: end up on the final inserted line for the original cursor line.
|
||||||
|
std::size_t new_y = y + static_cast<std::size_t>(repeat);
|
||||||
|
new_y += splits_above;
|
||||||
|
buf->SetCursor(0, new_y);
|
||||||
|
ensure_cursor_visible(ctx.editor, *buf);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
for (int i = 0; i < repeat; ++i) {
|
for (int i = 0; i < repeat; ++i) {
|
||||||
buf->split_line(static_cast<int>(y), static_cast<int>(x));
|
buf->split_line(static_cast<int>(y), static_cast<int>(x));
|
||||||
// Move to start of next line
|
// Move to start of next line
|
||||||
@@ -2858,6 +3006,50 @@ cmd_backspace(CommandContext &ctx)
|
|||||||
std::size_t x = buf->Curx();
|
std::size_t x = buf->Curx();
|
||||||
UndoSystem *u = buf->Undo();
|
UndoSystem *u = buf->Undo();
|
||||||
int repeat = ctx.count > 0 ? ctx.count : 1;
|
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 = buf->Rows();
|
||||||
|
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 = buf->Rows();
|
||||||
|
if (yy < rows_view.size() && (xx - 1) < rows_view[yy].size())
|
||||||
|
deleted.insert(deleted.begin(), rows_view[yy][xx - 1]);
|
||||||
|
buf->delete_text(static_cast<int>(yy), static_cast<int>(xx - 1), 1);
|
||||||
|
--xx;
|
||||||
|
}
|
||||||
|
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) {
|
for (int i = 0; i < repeat; ++i) {
|
||||||
// Refresh a read-only view of lines for char capture/lengths
|
// Refresh a read-only view of lines for char capture/lengths
|
||||||
const auto &rows_view = buf->Rows();
|
const auto &rows_view = buf->Rows();
|
||||||
@@ -2910,6 +3102,44 @@ cmd_delete_char(CommandContext &ctx)
|
|||||||
std::size_t x = buf->Curx();
|
std::size_t x = buf->Curx();
|
||||||
UndoSystem *u = buf->Undo();
|
UndoSystem *u = buf->Undo();
|
||||||
int repeat = ctx.count > 0 ? ctx.count : 1;
|
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 = buf->Rows();
|
||||||
|
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 = buf->Rows();
|
||||||
|
if (yy >= rows_view.size() || xx >= rows_view[yy].size())
|
||||||
|
break;
|
||||||
|
deleted.push_back(rows_view[yy][xx]);
|
||||||
|
buf->delete_text(static_cast<int>(yy), static_cast<int>(xx), 1);
|
||||||
|
}
|
||||||
|
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) {
|
for (int i = 0; i < repeat; ++i) {
|
||||||
const auto &rows_view = buf->Rows();
|
const auto &rows_view = buf->Rows();
|
||||||
if (y >= rows_view.size())
|
if (y >= rows_view.size())
|
||||||
@@ -2947,7 +3177,9 @@ cmd_undo(CommandContext &ctx)
|
|||||||
if (auto *u = buf->Undo()) {
|
if (auto *u = buf->Undo()) {
|
||||||
// Ensure pending batch is finalized so it can be undone
|
// Ensure pending batch is finalized so it can be undone
|
||||||
u->commit();
|
u->commit();
|
||||||
u->undo();
|
int repeat = ctx.count > 0 ? ctx.count : 1;
|
||||||
|
for (int i = 0; i < repeat; ++i)
|
||||||
|
u->undo();
|
||||||
// Keep cursor within buffer bounds
|
// Keep cursor within buffer bounds
|
||||||
ensure_cursor_visible(ctx.editor, *buf);
|
ensure_cursor_visible(ctx.editor, *buf);
|
||||||
ctx.editor.SetStatus("Undone");
|
ctx.editor.SetStatus("Undone");
|
||||||
@@ -2966,7 +3198,14 @@ cmd_redo(CommandContext &ctx)
|
|||||||
if (auto *u = buf->Undo()) {
|
if (auto *u = buf->Undo()) {
|
||||||
// Finalize any pending batch before redoing
|
// Finalize any pending batch before redoing
|
||||||
u->commit();
|
u->commit();
|
||||||
u->redo();
|
// 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);
|
ensure_cursor_visible(ctx.editor, *buf);
|
||||||
ctx.editor.SetStatus("Redone");
|
ctx.editor.SetStatus("Redone");
|
||||||
return true;
|
return true;
|
||||||
@@ -3088,9 +3327,68 @@ cmd_yank(CommandContext &ctx)
|
|||||||
}
|
}
|
||||||
ensure_at_least_one_line(*buf);
|
ensure_at_least_one_line(*buf);
|
||||||
int repeat = ctx.count > 0 ? ctx.count : 1;
|
int repeat = ctx.count > 0 ? ctx.count : 1;
|
||||||
for (int i = 0; i < repeat; ++i) {
|
std::string ins;
|
||||||
insert_text_at_cursor(*buf, text);
|
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);
|
ensure_cursor_visible(ctx.editor, *buf);
|
||||||
// Start a new kill chain only from kill commands; yanking should break it
|
// Start a new kill chain only from kill commands; yanking should break it
|
||||||
ctx.editor.SetKillChain(false);
|
ctx.editor.SetKillChain(false);
|
||||||
@@ -3105,8 +3403,12 @@ cmd_move_file_start(CommandContext &ctx)
|
|||||||
Buffer *buf = ctx.editor.CurrentBuffer();
|
Buffer *buf = ctx.editor.CurrentBuffer();
|
||||||
if (!buf)
|
if (!buf)
|
||||||
return false;
|
return false;
|
||||||
|
if (auto *u = buf->Undo())
|
||||||
|
u->commit();
|
||||||
ensure_at_least_one_line(*buf);
|
ensure_at_least_one_line(*buf);
|
||||||
buf->SetCursor(0, 0);
|
buf->SetCursor(0, 0);
|
||||||
|
if (buf->VisualLineActive())
|
||||||
|
buf->VisualLineSetActiveY(buf->Cury());
|
||||||
ensure_cursor_visible(ctx.editor, *buf);
|
ensure_cursor_visible(ctx.editor, *buf);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -3118,11 +3420,15 @@ cmd_move_file_end(CommandContext &ctx)
|
|||||||
Buffer *buf = ctx.editor.CurrentBuffer();
|
Buffer *buf = ctx.editor.CurrentBuffer();
|
||||||
if (!buf)
|
if (!buf)
|
||||||
return false;
|
return false;
|
||||||
|
if (auto *u = buf->Undo())
|
||||||
|
u->commit();
|
||||||
ensure_at_least_one_line(*buf);
|
ensure_at_least_one_line(*buf);
|
||||||
const auto &rows = buf->Rows();
|
const auto &rows = buf->Rows();
|
||||||
std::size_t y = rows.empty() ? 0 : rows.size() - 1;
|
std::size_t y = rows.empty() ? 0 : rows.size() - 1;
|
||||||
std::size_t x = rows.empty() ? 0 : rows[y].size();
|
std::size_t x = rows.empty() ? 0 : rows[y].size();
|
||||||
buf->SetCursor(x, y);
|
buf->SetCursor(x, y);
|
||||||
|
if (buf->VisualLineActive())
|
||||||
|
buf->VisualLineSetActiveY(buf->Cury());
|
||||||
ensure_cursor_visible(ctx.editor, *buf);
|
ensure_cursor_visible(ctx.editor, *buf);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -3145,12 +3451,26 @@ cmd_toggle_mark(CommandContext &ctx)
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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
|
static bool
|
||||||
cmd_jump_to_mark(CommandContext &ctx)
|
cmd_jump_to_mark(CommandContext &ctx)
|
||||||
{
|
{
|
||||||
Buffer *buf = ctx.editor.CurrentBuffer();
|
Buffer *buf = ctx.editor.CurrentBuffer();
|
||||||
if (!buf)
|
if (!buf)
|
||||||
return false;
|
return false;
|
||||||
|
if (auto *u = buf->Undo())
|
||||||
|
u->commit();
|
||||||
if (!buf->MarkSet()) {
|
if (!buf->MarkSet()) {
|
||||||
ctx.editor.SetStatus("Mark not set");
|
ctx.editor.SetStatus("Mark not set");
|
||||||
return false;
|
return false;
|
||||||
@@ -3306,6 +3626,8 @@ cmd_move_left(CommandContext &ctx)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
buf->SetCursor(x, y);
|
buf->SetCursor(x, y);
|
||||||
|
if (buf->VisualLineActive())
|
||||||
|
buf->VisualLineSetActiveY(buf->Cury());
|
||||||
ensure_cursor_visible(ctx.editor, *buf);
|
ensure_cursor_visible(ctx.editor, *buf);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -3383,6 +3705,8 @@ cmd_move_right(CommandContext &ctx)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
buf->SetCursor(x, y);
|
buf->SetCursor(x, y);
|
||||||
|
if (buf->VisualLineActive())
|
||||||
|
buf->VisualLineSetActiveY(buf->Cury());
|
||||||
ensure_cursor_visible(ctx.editor, *buf);
|
ensure_cursor_visible(ctx.editor, *buf);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -3423,6 +3747,8 @@ cmd_move_up(CommandContext &ctx)
|
|||||||
if (x > rows[y].size())
|
if (x > rows[y].size())
|
||||||
x = rows[y].size();
|
x = rows[y].size();
|
||||||
buf->SetCursor(x, y);
|
buf->SetCursor(x, y);
|
||||||
|
if (buf->VisualLineActive())
|
||||||
|
buf->VisualLineSetActiveY(buf->Cury());
|
||||||
ensure_cursor_visible(ctx.editor, *buf);
|
ensure_cursor_visible(ctx.editor, *buf);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -3464,6 +3790,8 @@ cmd_move_down(CommandContext &ctx)
|
|||||||
if (x > rows[y].size())
|
if (x > rows[y].size())
|
||||||
x = rows[y].size();
|
x = rows[y].size();
|
||||||
buf->SetCursor(x, y);
|
buf->SetCursor(x, y);
|
||||||
|
if (buf->VisualLineActive())
|
||||||
|
buf->VisualLineSetActiveY(buf->Cury());
|
||||||
ensure_cursor_visible(ctx.editor, *buf);
|
ensure_cursor_visible(ctx.editor, *buf);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -3584,6 +3912,8 @@ cmd_scroll_up(CommandContext &ctx)
|
|||||||
Buffer *buf = ctx.editor.CurrentBuffer();
|
Buffer *buf = ctx.editor.CurrentBuffer();
|
||||||
if (!buf)
|
if (!buf)
|
||||||
return false;
|
return false;
|
||||||
|
if (auto *u = buf->Undo())
|
||||||
|
u->commit();
|
||||||
ensure_at_least_one_line(*buf);
|
ensure_at_least_one_line(*buf);
|
||||||
const auto &rows = buf->Rows();
|
const auto &rows = buf->Rows();
|
||||||
std::size_t content_rows = std::max<std::size_t>(1, ctx.editor.ContentRows());
|
std::size_t content_rows = std::max<std::size_t>(1, ctx.editor.ContentRows());
|
||||||
@@ -3617,6 +3947,8 @@ cmd_scroll_down(CommandContext &ctx)
|
|||||||
Buffer *buf = ctx.editor.CurrentBuffer();
|
Buffer *buf = ctx.editor.CurrentBuffer();
|
||||||
if (!buf)
|
if (!buf)
|
||||||
return false;
|
return false;
|
||||||
|
if (auto *u = buf->Undo())
|
||||||
|
u->commit();
|
||||||
ensure_at_least_one_line(*buf);
|
ensure_at_least_one_line(*buf);
|
||||||
const auto &rows = buf->Rows();
|
const auto &rows = buf->Rows();
|
||||||
std::size_t content_rows = std::max<std::size_t>(1, ctx.editor.ContentRows());
|
std::size_t content_rows = std::max<std::size_t>(1, ctx.editor.ContentRows());
|
||||||
@@ -3981,6 +4313,27 @@ cmd_reflow_paragraph(CommandContext &ctx)
|
|||||||
Buffer *buf = ctx.editor.CurrentBuffer();
|
Buffer *buf = ctx.editor.CurrentBuffer();
|
||||||
if (!buf)
|
if (!buf)
|
||||||
return false;
|
return false;
|
||||||
|
struct GroupGuard {
|
||||||
|
UndoSystem *u;
|
||||||
|
|
||||||
|
|
||||||
|
explicit GroupGuard(UndoSystem *u_) : u(u_)
|
||||||
|
{
|
||||||
|
if (u)
|
||||||
|
u->BeginGroup();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
~GroupGuard()
|
||||||
|
{
|
||||||
|
if (u)
|
||||||
|
u->EndGroup();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Reflow performs a multi-edit transformation; make it a single standalone undo/redo step.
|
||||||
|
GroupGuard guard(buf->Undo());
|
||||||
|
if (auto *u = buf->Undo())
|
||||||
|
u->commit();
|
||||||
ensure_at_least_one_line(*buf);
|
ensure_at_least_one_line(*buf);
|
||||||
auto &rows = buf->Rows();
|
auto &rows = buf->Rows();
|
||||||
std::size_t y = buf->Cury();
|
std::size_t y = buf->Cury();
|
||||||
@@ -4026,6 +4379,29 @@ cmd_reflow_paragraph(CommandContext &ctx)
|
|||||||
return false;
|
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) {
|
auto normalize_spaces = [](const std::string &in) {
|
||||||
std::string out;
|
std::string out;
|
||||||
out.reserve(in.size());
|
out.reserve(in.size());
|
||||||
@@ -4107,20 +4483,25 @@ cmd_reflow_paragraph(CommandContext &ctx)
|
|||||||
|
|
||||||
std::vector<std::string> new_lines;
|
std::vector<std::string> new_lines;
|
||||||
|
|
||||||
// Determine if this region looks like a list: any line starting with bullet
|
// Determine if this region looks like a list: any line starting with bullet or number
|
||||||
bool region_has_bullet = false;
|
bool region_has_list = false;
|
||||||
for (std::size_t i = para_start; i <= para_end; ++i) {
|
for (std::size_t i = para_start; i <= para_end; ++i) {
|
||||||
std::string s = static_cast<std::string>(rows[i]);
|
std::string s = static_cast<std::string>(rows[i]);
|
||||||
std::string indent;
|
std::string indent;
|
||||||
char marker;
|
char marker;
|
||||||
std::size_t idx;
|
std::size_t idx;
|
||||||
if (is_bullet_line(s, indent, marker, idx)) {
|
if (is_bullet_line(s, indent, marker, idx)) {
|
||||||
region_has_bullet = true;
|
region_has_list = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
std::string nmarker;
|
||||||
|
if (is_numbered_line(s, indent, nmarker, idx)) {
|
||||||
|
region_has_list = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (region_has_bullet) {
|
if (region_has_list) {
|
||||||
// Parse as list items; support hanging indent continuations
|
// Parse as list items; support hanging indent continuations
|
||||||
for (std::size_t i = para_start; i <= para_end; ++i) {
|
for (std::size_t i = para_start; i <= para_end; ++i) {
|
||||||
std::string s = static_cast<std::string>(rows[i]);
|
std::string s = static_cast<std::string>(rows[i]);
|
||||||
@@ -4135,12 +4516,6 @@ cmd_reflow_paragraph(CommandContext &ctx)
|
|||||||
std::size_t j = i + 1;
|
std::size_t j = i + 1;
|
||||||
while (j <= para_end) {
|
while (j <= para_end) {
|
||||||
std::string ns = static_cast<std::string>(rows[j]);
|
std::string ns = static_cast<std::string>(rows[j]);
|
||||||
if (starts_with(ns, indent + " ")) {
|
|
||||||
content += ' ';
|
|
||||||
content += ns.substr(indent.size() + 2);
|
|
||||||
++j;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// stop if next bullet at same indentation or different structure
|
// stop if next bullet at same indentation or different structure
|
||||||
std::string nindent;
|
std::string nindent;
|
||||||
char nmarker;
|
char nmarker;
|
||||||
@@ -4148,6 +4523,17 @@ cmd_reflow_paragraph(CommandContext &ctx)
|
|||||||
if (is_bullet_line(ns, nindent, nmarker, nidx)) {
|
if (is_bullet_line(ns, nindent, nmarker, nidx)) {
|
||||||
break; // next item
|
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)
|
// Not a continuation and not a bullet: stop (treat as separate paragraph chunk)
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -4155,30 +4541,65 @@ cmd_reflow_paragraph(CommandContext &ctx)
|
|||||||
wrap_with_prefixes(content, first_prefix, cont_prefix, width, new_lines);
|
wrap_with_prefixes(content, first_prefix, cont_prefix, width, new_lines);
|
||||||
i = j - 1; // advance
|
i = j - 1; // advance
|
||||||
} else {
|
} else {
|
||||||
// A non-bullet line within a list region; treat as its own wrapped paragraph preserving its indent
|
std::string nmarker;
|
||||||
std::string base_indent = leading_ws(s);
|
if (is_numbered_line(s, indent, nmarker, after_idx)) {
|
||||||
std::string content = s.substr(base_indent.size());
|
std::string first_prefix = indent + nmarker + " ";
|
||||||
std::size_t j = i + 1;
|
std::string cont_prefix = indent + std::string(nmarker.size() + 1, ' ');
|
||||||
while (j <= para_end) {
|
std::string content = s.substr(after_idx);
|
||||||
std::string ns = static_cast<std::string>(rows[j]);
|
// consume continuation lines that are part of this numbered item
|
||||||
std::string nindent = leading_ws(ns);
|
std::size_t j = i + 1;
|
||||||
std::string tmp_indent;
|
while (j <= para_end) {
|
||||||
char tmp_marker;
|
std::string ns = static_cast<std::string>(rows[j]);
|
||||||
std::size_t tmp_idx;
|
if (starts_with(ns, cont_prefix)) {
|
||||||
if (is_bullet_line(ns, tmp_indent, tmp_marker, tmp_idx)) {
|
content += ' ';
|
||||||
break; // next bullet starts
|
content += ns.substr(cont_prefix.size());
|
||||||
}
|
++j;
|
||||||
if (nindent.size() >= base_indent.size()) {
|
continue;
|
||||||
content += ' ';
|
}
|
||||||
content += ns.substr(base_indent.size());
|
// stop if next item
|
||||||
++j;
|
std::string nindent2;
|
||||||
} else {
|
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;
|
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;
|
||||||
}
|
}
|
||||||
content = normalize_spaces(content);
|
|
||||||
wrap_with_prefixes(content, base_indent, base_indent, width, new_lines);
|
|
||||||
i = j - 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -4396,6 +4817,10 @@ InstallDefaultCommands()
|
|||||||
});
|
});
|
||||||
CommandRegistry::Register({CommandId::MoveFileEnd, "file-end", "Move to end of file", cmd_move_file_end});
|
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::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({
|
CommandRegistry::Register({
|
||||||
CommandId::JumpToMark, "jump-to-mark", "Jump to mark (swap mark)", cmd_jump_to_mark
|
CommandId::JumpToMark, "jump-to-mark", "Jump to mark (swap mark)", cmd_jump_to_mark
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ enum class CommandId {
|
|||||||
MoveFileStart, // move to beginning of file
|
MoveFileStart, // move to beginning of file
|
||||||
MoveFileEnd, // move to end of file
|
MoveFileEnd, // move to end of file
|
||||||
ToggleMark, // toggle mark at cursor
|
ToggleMark, // toggle mark at cursor
|
||||||
|
VisualLineModeToggle, // toggle visual-line (multicursor) mode (C-k /)
|
||||||
JumpToMark, // jump to mark, set mark to previous cursor
|
JumpToMark, // jump to mark, set mark to previous cursor
|
||||||
KillRegion, // kill region between mark and cursor (to kill ring)
|
KillRegion, // kill region between mark and cursor (to kill ring)
|
||||||
CopyRegion, // copy region to kill ring (Alt-w)
|
CopyRegion, // copy region to kill ring (Alt-w)
|
||||||
@@ -163,4 +164,4 @@ void InstallDefaultCommands();
|
|||||||
// Returns true if the command executed successfully.
|
// Returns true if the command executed successfully.
|
||||||
bool Execute(Editor &ed, CommandId id, const std::string &arg = std::string(), int count = 0);
|
bool Execute(Editor &ed, CommandId id, const std::string &arg = std::string(), int count = 0);
|
||||||
|
|
||||||
bool Execute(Editor &ed, const std::string &name, const std::string &arg = std::string(), int count = 0);
|
bool Execute(Editor &ed, const std::string &name, const std::string &arg = std::string(), int count = 0);
|
||||||
|
|||||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Minimal Dockerfile for building and testing kte on Linux
|
||||||
|
# This container provides a build environment with all dependencies.
|
||||||
|
# Mount the source tree at /kte when running the container.
|
||||||
|
FROM alpine:3.19
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
g++ \
|
||||||
|
cmake \
|
||||||
|
make \
|
||||||
|
ncurses-dev \
|
||||||
|
sdl2-dev \
|
||||||
|
mesa-dev \
|
||||||
|
freetype-dev \
|
||||||
|
libx11-dev \
|
||||||
|
libxext-dev
|
||||||
|
|
||||||
|
# Set working directory where source will be mounted
|
||||||
|
WORKDIR /kte
|
||||||
|
|
||||||
|
# Default command: build and run tests
|
||||||
|
# Add DirectFB include path for SDL2 compatibility on Alpine
|
||||||
|
CMD ["sh", "-c", "cmake -B build -DBUILD_GUI=ON -DBUILD_TESTS=ON -DCMAKE_CXX_FLAGS='-I/usr/include/directfb' && cmake --build build --target kte && cmake --build build --target kge && cmake --build build --target kte_tests && ./build/kte_tests"]
|
||||||
264
Editor.cc
264
Editor.cc
@@ -1,6 +1,7 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <utility>
|
#include <cstdio>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
#include "Editor.h"
|
#include "Editor.h"
|
||||||
#include "syntax/HighlighterRegistry.h"
|
#include "syntax/HighlighterRegistry.h"
|
||||||
@@ -8,6 +9,41 @@
|
|||||||
#include "syntax/NullHighlighter.h"
|
#include "syntax/NullHighlighter.h"
|
||||||
|
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
static std::string
|
||||||
|
buffer_bytes_via_views(const Buffer &b)
|
||||||
|
{
|
||||||
|
const std::size_t nrows = b.Nrows();
|
||||||
|
std::string out;
|
||||||
|
for (std::size_t i = 0; i < nrows; i++) {
|
||||||
|
auto v = b.GetLineView(i);
|
||||||
|
out.append(v.data(), v.size());
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static void
|
||||||
|
apply_pending_line(Editor &ed, const std::size_t line1)
|
||||||
|
{
|
||||||
|
if (line1 == 0)
|
||||||
|
return;
|
||||||
|
Buffer *b = ed.CurrentBuffer();
|
||||||
|
if (!b)
|
||||||
|
return;
|
||||||
|
const std::size_t nrows = b->Nrows();
|
||||||
|
std::size_t line = line1 > 0 ? line1 - 1 : 0; // 1-based to 0-based
|
||||||
|
if (nrows > 0) {
|
||||||
|
if (line >= nrows)
|
||||||
|
line = nrows - 1;
|
||||||
|
} else {
|
||||||
|
line = 0;
|
||||||
|
}
|
||||||
|
b->SetCursor(0, line);
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
|
||||||
Editor::Editor()
|
Editor::Editor()
|
||||||
{
|
{
|
||||||
swap_ = std::make_unique<kte::SwapManager>();
|
swap_ = std::make_unique<kte::SwapManager>();
|
||||||
@@ -128,8 +164,8 @@ Editor::AddBuffer(const Buffer &buf)
|
|||||||
buffers_.push_back(buf);
|
buffers_.push_back(buf);
|
||||||
// Attach swap recorder
|
// Attach swap recorder
|
||||||
if (swap_) {
|
if (swap_) {
|
||||||
buffers_.back().SetSwapRecorder(swap_.get());
|
|
||||||
swap_->Attach(&buffers_.back());
|
swap_->Attach(&buffers_.back());
|
||||||
|
buffers_.back().SetSwapRecorder(swap_->RecorderFor(&buffers_.back()));
|
||||||
}
|
}
|
||||||
if (buffers_.size() == 1) {
|
if (buffers_.size() == 1) {
|
||||||
curbuf_ = 0;
|
curbuf_ = 0;
|
||||||
@@ -143,8 +179,8 @@ Editor::AddBuffer(Buffer &&buf)
|
|||||||
{
|
{
|
||||||
buffers_.push_back(std::move(buf));
|
buffers_.push_back(std::move(buf));
|
||||||
if (swap_) {
|
if (swap_) {
|
||||||
buffers_.back().SetSwapRecorder(swap_.get());
|
|
||||||
swap_->Attach(&buffers_.back());
|
swap_->Attach(&buffers_.back());
|
||||||
|
buffers_.back().SetSwapRecorder(swap_->RecorderFor(&buffers_.back()));
|
||||||
}
|
}
|
||||||
if (buffers_.size() == 1) {
|
if (buffers_.size() == 1) {
|
||||||
curbuf_ = 0;
|
curbuf_ = 0;
|
||||||
@@ -162,25 +198,24 @@ Editor::OpenFile(const std::string &path, std::string &err)
|
|||||||
Buffer &cur = buffers_[curbuf_];
|
Buffer &cur = buffers_[curbuf_];
|
||||||
const bool unnamed = cur.Filename().empty() && !cur.IsFileBacked();
|
const bool unnamed = cur.Filename().empty() && !cur.IsFileBacked();
|
||||||
const bool clean = !cur.Dirty();
|
const bool clean = !cur.Dirty();
|
||||||
const auto &rows = cur.Rows();
|
const std::size_t nrows = cur.Nrows();
|
||||||
const bool rows_empty = rows.empty();
|
const bool rows_empty = (nrows == 0);
|
||||||
const bool single_empty_line = (!rows.empty() && rows.size() == 1 && rows[0].size() == 0);
|
const bool single_empty_line = (nrows == 1 && cur.GetLineView(0).size() == 0);
|
||||||
if (unnamed && clean && (rows_empty || single_empty_line)) {
|
if (unnamed && clean && (rows_empty || single_empty_line)) {
|
||||||
bool ok = cur.OpenFromFile(path, err);
|
bool ok = cur.OpenFromFile(path, err);
|
||||||
if (!ok)
|
if (!ok)
|
||||||
return false;
|
return false;
|
||||||
// Ensure swap recorder is attached for this buffer
|
// Ensure swap recorder is attached for this buffer
|
||||||
if (swap_) {
|
if (swap_) {
|
||||||
cur.SetSwapRecorder(swap_.get());
|
|
||||||
swap_->Attach(&cur);
|
swap_->Attach(&cur);
|
||||||
|
cur.SetSwapRecorder(swap_->RecorderFor(&cur));
|
||||||
swap_->NotifyFilenameChanged(cur);
|
swap_->NotifyFilenameChanged(cur);
|
||||||
}
|
}
|
||||||
// Setup highlighting using registry (extension + shebang)
|
// Setup highlighting using registry (extension + shebang)
|
||||||
cur.EnsureHighlighter();
|
cur.EnsureHighlighter();
|
||||||
std::string first = "";
|
std::string first = "";
|
||||||
const auto &rows = cur.Rows();
|
if (cur.Nrows() > 0)
|
||||||
if (!rows.empty())
|
first = cur.GetLineString(0);
|
||||||
first = static_cast<std::string>(rows[0]);
|
|
||||||
std::string ft = kte::HighlighterRegistry::DetectForPath(path, first);
|
std::string ft = kte::HighlighterRegistry::DetectForPath(path, first);
|
||||||
if (!ft.empty()) {
|
if (!ft.empty()) {
|
||||||
cur.SetFiletype(ft);
|
cur.SetFiletype(ft);
|
||||||
@@ -197,30 +232,23 @@ Editor::OpenFile(const std::string &path, std::string &err)
|
|||||||
eng->InvalidateFrom(0);
|
eng->InvalidateFrom(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Defensive: ensure any active prompt is closed after a successful open
|
// Defensive: ensure any active prompt is closed after a successful open
|
||||||
CancelPrompt();
|
CancelPrompt();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Buffer b;
|
Buffer b;
|
||||||
if (!b.OpenFromFile(path, err)) {
|
if (!b.OpenFromFile(path, err)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (swap_) {
|
// NOTE: swap recorder/attach must happen after the buffer is stored in its
|
||||||
b.SetSwapRecorder(swap_.get());
|
// final location (vector) because swap manager keys off Buffer*.
|
||||||
// path is known, notify
|
|
||||||
swap_->Attach(&b);
|
|
||||||
swap_->NotifyFilenameChanged(b);
|
|
||||||
}
|
|
||||||
// Initialize syntax highlighting by extension + shebang via registry (v2)
|
// Initialize syntax highlighting by extension + shebang via registry (v2)
|
||||||
b.EnsureHighlighter();
|
b.EnsureHighlighter();
|
||||||
std::string first = "";
|
std::string first = "";
|
||||||
{
|
if (b.Nrows() > 0)
|
||||||
const auto &rows = b.Rows();
|
first = b.GetLineString(0);
|
||||||
if (!rows.empty())
|
|
||||||
first = static_cast<std::string>(rows[0]);
|
|
||||||
}
|
|
||||||
std::string ft = kte::HighlighterRegistry::DetectForPath(path, first);
|
std::string ft = kte::HighlighterRegistry::DetectForPath(path, first);
|
||||||
if (!ft.empty()) {
|
if (!ft.empty()) {
|
||||||
b.SetFiletype(ft);
|
b.SetFiletype(ft);
|
||||||
@@ -239,10 +267,179 @@ Editor::OpenFile(const std::string &path, std::string &err)
|
|||||||
}
|
}
|
||||||
// Add as a new buffer and switch to it
|
// Add as a new buffer and switch to it
|
||||||
std::size_t idx = AddBuffer(std::move(b));
|
std::size_t idx = AddBuffer(std::move(b));
|
||||||
SwitchTo(idx);
|
if (swap_) {
|
||||||
// Defensive: ensure any active prompt is closed after a successful open
|
swap_->NotifyFilenameChanged(buffers_[idx]);
|
||||||
CancelPrompt();
|
}
|
||||||
return true;
|
SwitchTo(idx);
|
||||||
|
// Defensive: ensure any active prompt is closed after a successful open
|
||||||
|
CancelPrompt();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
Editor::RequestOpenFile(const std::string &path, const std::size_t line1)
|
||||||
|
{
|
||||||
|
PendingOpen p;
|
||||||
|
p.path = path;
|
||||||
|
p.line1 = line1;
|
||||||
|
pending_open_.push_back(std::move(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Editor::HasPendingOpens() const
|
||||||
|
{
|
||||||
|
return !pending_open_.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Editor::RecoveryPromptKind
|
||||||
|
Editor::PendingRecoveryPrompt() const
|
||||||
|
{
|
||||||
|
return pending_recovery_prompt_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
Editor::CancelRecoveryPrompt()
|
||||||
|
{
|
||||||
|
pending_recovery_prompt_ = RecoveryPromptKind::None;
|
||||||
|
pending_recovery_open_ = PendingOpen{};
|
||||||
|
pending_recovery_swap_path_.clear();
|
||||||
|
pending_recovery_replay_err_.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Editor::ResolveRecoveryPrompt(const bool yes)
|
||||||
|
{
|
||||||
|
const RecoveryPromptKind kind = pending_recovery_prompt_;
|
||||||
|
if (kind == RecoveryPromptKind::None)
|
||||||
|
return false;
|
||||||
|
const PendingOpen req = pending_recovery_open_;
|
||||||
|
const std::string swp = pending_recovery_swap_path_;
|
||||||
|
const std::string rerr_s = pending_recovery_replay_err_;
|
||||||
|
CancelRecoveryPrompt();
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
if (kind == RecoveryPromptKind::RecoverOrDiscard) {
|
||||||
|
if (yes) {
|
||||||
|
if (!OpenFile(req.path, err)) {
|
||||||
|
SetStatus(err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Buffer *b = CurrentBuffer();
|
||||||
|
if (!b) {
|
||||||
|
SetStatus("Recovery failed: no buffer");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
std::string rerr;
|
||||||
|
if (!kte::SwapManager::ReplayFile(*b, swp, rerr)) {
|
||||||
|
SetStatus("Swap recovery failed: " + rerr);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
b->SetDirty(true);
|
||||||
|
apply_pending_line(*this, req.line1);
|
||||||
|
SetStatus("Recovered " + req.path);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Discard: best-effort delete swap, then open clean.
|
||||||
|
(void) std::remove(swp.c_str());
|
||||||
|
if (!OpenFile(req.path, err)) {
|
||||||
|
SetStatus(err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
apply_pending_line(*this, req.line1);
|
||||||
|
SetStatus("Opened " + req.path);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (kind == RecoveryPromptKind::DeleteCorruptSwap) {
|
||||||
|
if (yes) {
|
||||||
|
(void) std::remove(swp.c_str());
|
||||||
|
}
|
||||||
|
if (!OpenFile(req.path, err)) {
|
||||||
|
SetStatus(err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
apply_pending_line(*this, req.line1);
|
||||||
|
// Include a short hint that the swap was corrupt.
|
||||||
|
if (!rerr_s.empty()) {
|
||||||
|
SetStatus("Opened " + req.path + " (swap unreadable)");
|
||||||
|
} else {
|
||||||
|
SetStatus("Opened " + req.path);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
bool
|
||||||
|
Editor::ProcessPendingOpens()
|
||||||
|
{
|
||||||
|
if (PromptActive())
|
||||||
|
return false;
|
||||||
|
if (pending_recovery_prompt_ != RecoveryPromptKind::None)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
bool opened_any = false;
|
||||||
|
while (!pending_open_.empty()) {
|
||||||
|
PendingOpen req = std::move(pending_open_.front());
|
||||||
|
pending_open_.pop_front();
|
||||||
|
if (req.path.empty())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
std::string swp = kte::SwapManager::ComputeSwapPathForFilename(req.path);
|
||||||
|
bool swp_exists = false;
|
||||||
|
try {
|
||||||
|
swp_exists = !swp.empty() && std::filesystem::exists(std::filesystem::path(swp));
|
||||||
|
} catch (...) {
|
||||||
|
swp_exists = false;
|
||||||
|
}
|
||||||
|
if (swp_exists) {
|
||||||
|
Buffer tmp;
|
||||||
|
std::string oerr;
|
||||||
|
if (tmp.OpenFromFile(req.path, oerr)) {
|
||||||
|
const std::string orig = buffer_bytes_via_views(tmp);
|
||||||
|
std::string rerr;
|
||||||
|
if (kte::SwapManager::ReplayFile(tmp, swp, rerr)) {
|
||||||
|
const std::string rec = buffer_bytes_via_views(tmp);
|
||||||
|
if (rec != orig) {
|
||||||
|
pending_recovery_prompt_ = RecoveryPromptKind::RecoverOrDiscard;
|
||||||
|
pending_recovery_open_ = req;
|
||||||
|
pending_recovery_swap_path_ = swp;
|
||||||
|
StartPrompt(PromptKind::Confirm, "Recover", "");
|
||||||
|
SetStatus("Recover swap edits for " + req.path + "? (y/N, C-g cancel)");
|
||||||
|
return opened_any;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pending_recovery_prompt_ = RecoveryPromptKind::DeleteCorruptSwap;
|
||||||
|
pending_recovery_open_ = req;
|
||||||
|
pending_recovery_swap_path_ = swp;
|
||||||
|
pending_recovery_replay_err_ = rerr;
|
||||||
|
StartPrompt(PromptKind::Confirm, "Swap", "");
|
||||||
|
SetStatus(
|
||||||
|
"Swap file unreadable for " + req.path +
|
||||||
|
". Delete it? (y/N, C-g cancel)");
|
||||||
|
return opened_any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string err;
|
||||||
|
if (!OpenFile(req.path, err)) {
|
||||||
|
SetStatus(err);
|
||||||
|
opened_any = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
apply_pending_line(*this, req.line1);
|
||||||
|
SetStatus("Opened " + req.path);
|
||||||
|
opened_any = true;
|
||||||
|
// Open at most one per call; frontends can call us again next frame.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return opened_any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -284,6 +481,13 @@ Editor::CloseBuffer(std::size_t index)
|
|||||||
if (index >= buffers_.size()) {
|
if (index >= buffers_.size()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (swap_) {
|
||||||
|
// Always remove swap file when closing a buffer on normal exit.
|
||||||
|
// Swap files are for crash recovery; on clean close, we don't need them.
|
||||||
|
// This prevents stale swap files from accumulating (e.g., when used as git editor).
|
||||||
|
swap_->Detach(&buffers_[index], true);
|
||||||
|
buffers_[index].SetSwapRecorder(nullptr);
|
||||||
|
}
|
||||||
buffers_.erase(buffers_.begin() + static_cast<std::ptrdiff_t>(index));
|
buffers_.erase(buffers_.begin() + static_cast<std::ptrdiff_t>(index));
|
||||||
if (buffers_.empty()) {
|
if (buffers_.empty()) {
|
||||||
curbuf_ = 0;
|
curbuf_ = 0;
|
||||||
|
|||||||
74
Editor.h
74
Editor.h
@@ -1,9 +1,47 @@
|
|||||||
/*
|
/*
|
||||||
* Editor.h - top-level editor state and buffer management
|
* Editor.h - top-level editor state and buffer management
|
||||||
|
*
|
||||||
|
* Editor is the top-level coordinator in kte. It manages:
|
||||||
|
*
|
||||||
|
* - Buffer collection: Multiple open documents (buffers_), current buffer selection
|
||||||
|
* - UI state: Dimensions, status messages, prompts, search state
|
||||||
|
* - Kill ring: Shared clipboard for cut/copy/paste operations across buffers
|
||||||
|
* - Universal argument: Repeat count mechanism (C-u)
|
||||||
|
* - Mode flags: Editor modes (normal, k-command, search, prompt, etc.)
|
||||||
|
* - Swap/crash recovery: SwapManager integration for journaling
|
||||||
|
* - File operations: Opening files, managing pending opens, recovery prompts
|
||||||
|
*
|
||||||
|
* Key responsibilities:
|
||||||
|
*
|
||||||
|
* 1. Buffer lifecycle:
|
||||||
|
* - AddBuffer(): Add new buffers to the collection
|
||||||
|
* - OpenFile(): Load files into buffers
|
||||||
|
* - SwitchTo(): Change active buffer
|
||||||
|
* - CloseBuffer(): Remove buffers with dirty checks
|
||||||
|
*
|
||||||
|
* 2. UI coordination:
|
||||||
|
* - SetDimensions(): Terminal/window size for viewport calculations
|
||||||
|
* - SetStatus(): Status line messages with timestamps
|
||||||
|
* - Prompt system: Multi-step prompts for file open, buffer switch, etc.
|
||||||
|
* - Search state: Active search, query, match position, origin tracking
|
||||||
|
*
|
||||||
|
* 3. Shared editor state:
|
||||||
|
* - Kill ring: Circular buffer of killed text (max 60 entries)
|
||||||
|
* - Universal argument: C-u digit collection for command repetition
|
||||||
|
* - Mode tracking: Current input mode (normal, k-command, ESC, prompt)
|
||||||
|
*
|
||||||
|
* 4. Integration points:
|
||||||
|
* - Commands operate on Editor and current Buffer
|
||||||
|
* - Frontend (Terminal/GUI) queries Editor for rendering
|
||||||
|
* - SwapManager journals all buffer modifications
|
||||||
|
*
|
||||||
|
* Design note: Editor owns the buffer collection but doesn't directly edit content.
|
||||||
|
* Commands modify buffers through Buffer's API, and Editor coordinates the UI state.
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <ctime>
|
#include <ctime>
|
||||||
|
#include <deque>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -497,6 +535,30 @@ public:
|
|||||||
|
|
||||||
bool OpenFile(const std::string &path, std::string &err);
|
bool OpenFile(const std::string &path, std::string &err);
|
||||||
|
|
||||||
|
// Request that a file be opened. The request is processed by calling
|
||||||
|
// ProcessPendingOpens() (typically once per frontend frame).
|
||||||
|
void RequestOpenFile(const std::string &path, std::size_t line1 = 0);
|
||||||
|
|
||||||
|
// If no modal prompt is active, process queued open requests.
|
||||||
|
// Returns true if a file was opened during this call.
|
||||||
|
bool ProcessPendingOpens();
|
||||||
|
|
||||||
|
[[nodiscard]] bool HasPendingOpens() const;
|
||||||
|
|
||||||
|
// Swap recovery confirmation state. When non-None, a `PromptKind::Confirm`
|
||||||
|
// prompt is active and the user's answer should be routed to ResolveRecoveryPrompt().
|
||||||
|
enum class RecoveryPromptKind {
|
||||||
|
None = 0,
|
||||||
|
RecoverOrDiscard, // y = recover swap, else discard swap and open clean
|
||||||
|
DeleteCorruptSwap // y = delete corrupt swap, else keep it
|
||||||
|
};
|
||||||
|
|
||||||
|
[[nodiscard]] RecoveryPromptKind PendingRecoveryPrompt() const;
|
||||||
|
|
||||||
|
bool ResolveRecoveryPrompt(bool yes);
|
||||||
|
|
||||||
|
void CancelRecoveryPrompt();
|
||||||
|
|
||||||
// Buffer switching/closing
|
// Buffer switching/closing
|
||||||
bool SwitchTo(std::size_t index);
|
bool SwitchTo(std::size_t index);
|
||||||
|
|
||||||
@@ -550,6 +612,11 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
struct PendingOpen {
|
||||||
|
std::string path;
|
||||||
|
std::size_t line1{0}; // 1-based; 0 = none
|
||||||
|
};
|
||||||
|
|
||||||
std::size_t rows_ = 0, cols_ = 0;
|
std::size_t rows_ = 0, cols_ = 0;
|
||||||
int mode_ = 0;
|
int mode_ = 0;
|
||||||
int kill_ = 0; // KILL CHAIN
|
int kill_ = 0; // KILL CHAIN
|
||||||
@@ -593,6 +660,13 @@ private:
|
|||||||
std::string prompt_text_;
|
std::string prompt_text_;
|
||||||
std::string pending_overwrite_path_;
|
std::string pending_overwrite_path_;
|
||||||
|
|
||||||
|
// Deferred open + swap recovery prompt state
|
||||||
|
std::deque<PendingOpen> pending_open_;
|
||||||
|
RecoveryPromptKind pending_recovery_prompt_ = RecoveryPromptKind::None;
|
||||||
|
PendingOpen pending_recovery_open_{};
|
||||||
|
std::string pending_recovery_swap_path_;
|
||||||
|
std::string pending_recovery_replay_err_;
|
||||||
|
|
||||||
// GUI-only state (safe no-op in terminal builds)
|
// GUI-only state (safe no-op in terminal builds)
|
||||||
bool file_picker_visible_ = false;
|
bool file_picker_visible_ = false;
|
||||||
std::string file_picker_dir_;
|
std::string file_picker_dir_;
|
||||||
|
|||||||
@@ -19,4 +19,4 @@ public:
|
|||||||
|
|
||||||
// Shutdown/cleanup
|
// Shutdown/cleanup
|
||||||
virtual void Shutdown() = 0;
|
virtual void Shutdown() = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -127,4 +127,4 @@ GUIConfig::LoadFromFile(const std::string &path)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,4 +30,4 @@ public:
|
|||||||
|
|
||||||
// Load from explicit path. Returns true if file existed and was parsed.
|
// Load from explicit path. Returns true if file existed and was parsed.
|
||||||
bool LoadFromFile(const std::string &path);
|
bool LoadFromFile(const std::string &path);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -942,4 +942,4 @@ SyntaxInk(const TokenKind k)
|
|||||||
}
|
}
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
|
|
||||||
#endif // KTE_USE_QT
|
#endif // KTE_USE_QT
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ HelpText::Text()
|
|||||||
" C-k ' Toggle read-only\n"
|
" C-k ' Toggle read-only\n"
|
||||||
" C-k - Unindent region (mark required)\n"
|
" C-k - Unindent region (mark required)\n"
|
||||||
" C-k = Indent region (mark required)\n"
|
" C-k = Indent region (mark required)\n"
|
||||||
|
" C-k / Toggle visual line mode\n"
|
||||||
" C-k ; Command prompt (:\\ )\n"
|
" C-k ; Command prompt (:\\ )\n"
|
||||||
|
" C-k SPACE Toggle mark\n"
|
||||||
" C-k C-d Kill entire line\n"
|
" C-k C-d Kill entire line\n"
|
||||||
" C-k C-q Quit now (no confirm)\n"
|
" C-k C-q Quit now (no confirm)\n"
|
||||||
" C-k C-x Save and quit\n"
|
" C-k C-x Save and quit\n"
|
||||||
@@ -31,11 +33,12 @@ HelpText::Text()
|
|||||||
" C-k c Close current buffer\n"
|
" C-k c Close current buffer\n"
|
||||||
" C-k d Kill to end of line\n"
|
" C-k d Kill to end of line\n"
|
||||||
" C-k e Open file (prompt)\n"
|
" C-k e Open file (prompt)\n"
|
||||||
" C-k i New empty buffer\n"
|
|
||||||
" C-k f Flush kill ring\n"
|
" C-k f Flush kill ring\n"
|
||||||
" C-k g Jump to line\n"
|
" C-k g Jump to line\n"
|
||||||
" C-k h Show this help\n"
|
" C-k h Show this help\n"
|
||||||
|
" C-k i New empty buffer\n"
|
||||||
" C-k j Jump to mark\n"
|
" C-k j Jump to mark\n"
|
||||||
|
" C-k k Center viewport on cursor\n"
|
||||||
" C-k l Reload buffer from disk\n"
|
" C-k l Reload buffer from disk\n"
|
||||||
" C-k n Previous buffer\n"
|
" C-k n Previous buffer\n"
|
||||||
" C-k o Change working directory (prompt)\n"
|
" C-k o Change working directory (prompt)\n"
|
||||||
|
|||||||
@@ -10,4 +10,4 @@ public:
|
|||||||
// Project maintainers can customize the returned string below
|
// Project maintainers can customize the returned string below
|
||||||
// (in HelpText.cc) without touching the help command logic.
|
// (in HelpText.cc) without touching the help command logic.
|
||||||
static std::string Text();
|
static std::string Text();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,4 +34,4 @@ struct LineHighlight {
|
|||||||
std::vector<HighlightSpan> spans;
|
std::vector<HighlightSpan> spans;
|
||||||
std::uint64_t version{0}; // buffer version used for this line
|
std::uint64_t version{0}; // buffer version used for this line
|
||||||
};
|
};
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
|
|||||||
@@ -298,6 +298,9 @@ GUIFrontend::Step(Editor &ed, bool &running)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Allow deferred opens (including swap recovery prompts) to run.
|
||||||
|
ed.ProcessPendingOpens();
|
||||||
|
|
||||||
// Execute pending mapped inputs (drain queue) AFTER dimensions are updated
|
// Execute pending mapped inputs (drain queue) AFTER dimensions are updated
|
||||||
for (;;) {
|
for (;;) {
|
||||||
MappedInput mi;
|
MappedInput mi;
|
||||||
@@ -388,4 +391,4 @@ GUIFrontend::LoadGuiFont_(const char * /*path*/, const float size_px)
|
|||||||
|
|
||||||
io.Fonts->Build();
|
io.Fonts->Build();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,4 +33,4 @@ private:
|
|||||||
SDL_GLContext gl_ctx_ = nullptr;
|
SDL_GLContext gl_ctx_ = nullptr;
|
||||||
int width_ = 1280;
|
int width_ = 1280;
|
||||||
int height_ = 800;
|
int height_ = 800;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -158,17 +158,17 @@ map_key(const SDL_Keycode key,
|
|||||||
ascii_key = static_cast<int>(key);
|
ascii_key = static_cast<int>(key);
|
||||||
}
|
}
|
||||||
bool ctrl2 = (mod & KMOD_CTRL) != 0;
|
bool ctrl2 = (mod & KMOD_CTRL) != 0;
|
||||||
// If user typed a literal 'C' (uppercase) or '^' as a control qualifier, keep k-prefix active
|
// If user typed a literal 'C' (uppercase) or '^' as a control qualifier, keep k-prefix active
|
||||||
// Do NOT treat lowercase 'c' as a qualifier; 'c' is a valid k-command (BufferClose).
|
// Do NOT treat lowercase 'c' as a qualifier; 'c' is a valid k-command (BufferClose).
|
||||||
if (ascii_key == 'C' || ascii_key == '^') {
|
if (ascii_key == 'C' || ascii_key == '^') {
|
||||||
k_ctrl_pending = true;
|
k_ctrl_pending = true;
|
||||||
// Keep waiting for the next suffix; show status and suppress ensuing TEXTINPUT
|
// Keep waiting for the next suffix; show status and suppress ensuing TEXTINPUT
|
||||||
if (ed)
|
if (ed)
|
||||||
ed->SetStatus("C-k C _");
|
ed->SetStatus("C-k C _");
|
||||||
suppress_textinput_once = true;
|
suppress_textinput_once = true;
|
||||||
out.hasCommand = false;
|
out.hasCommand = false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Otherwise, consume the k-prefix now for the actual suffix
|
// Otherwise, consume the k-prefix now for the actual suffix
|
||||||
k_prefix = false;
|
k_prefix = false;
|
||||||
if (ascii_key != 0) {
|
if (ascii_key != 0) {
|
||||||
@@ -298,7 +298,7 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
|||||||
// High-resolution trackpads can deliver fractional wheel deltas. Accumulate
|
// High-resolution trackpads can deliver fractional wheel deltas. Accumulate
|
||||||
// precise values and emit one scroll step per whole unit.
|
// precise values and emit one scroll step per whole unit.
|
||||||
float dy = 0.0f;
|
float dy = 0.0f;
|
||||||
#if SDL_VERSION_ATLEAST(2,0,18)
|
#if SDL_VERSION_ATLEAST(2, 0, 18)
|
||||||
dy = e.wheel.preciseY;
|
dy = e.wheel.preciseY;
|
||||||
#else
|
#else
|
||||||
dy = static_cast<float>(e.wheel.y);
|
dy = static_cast<float>(e.wheel.y);
|
||||||
@@ -308,7 +308,7 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
|||||||
dy = -dy;
|
dy = -dy;
|
||||||
#endif
|
#endif
|
||||||
if (dy != 0.0f) {
|
if (dy != 0.0f) {
|
||||||
wheel_accum_y_ += dy;
|
wheel_accum_y_ += dy;
|
||||||
float abs_accum = wheel_accum_y_ >= 0.0f ? wheel_accum_y_ : -wheel_accum_y_;
|
float abs_accum = wheel_accum_y_ >= 0.0f ? wheel_accum_y_ : -wheel_accum_y_;
|
||||||
int steps = static_cast<int>(abs_accum);
|
int steps = static_cast<int>(abs_accum);
|
||||||
if (steps > 0) {
|
if (steps > 0) {
|
||||||
@@ -439,14 +439,12 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If editor universal argument is active, consume digit TEXTINPUT
|
// If editor universal argument is active, consume digit TEXTINPUT
|
||||||
if (ed_ &&ed_
|
if (ed_ && ed_
|
||||||
|
|
||||||
|
|
||||||
|
->
|
||||||
->
|
UArg() != 0
|
||||||
UArg() != 0
|
) {
|
||||||
)
|
|
||||||
{
|
|
||||||
const char *txt = e.text.text;
|
const char *txt = e.text.text;
|
||||||
if (txt && *txt) {
|
if (txt && *txt) {
|
||||||
unsigned char c0 = static_cast<unsigned char>(txt[0]);
|
unsigned char c0 = static_cast<unsigned char>(txt[0]);
|
||||||
@@ -473,16 +471,16 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
|||||||
ascii_key = static_cast<int>(c0);
|
ascii_key = static_cast<int>(c0);
|
||||||
}
|
}
|
||||||
if (ascii_key != 0) {
|
if (ascii_key != 0) {
|
||||||
// Qualifier via TEXTINPUT: uppercase 'C' or '^' only
|
// Qualifier via TEXTINPUT: uppercase 'C' or '^' only
|
||||||
if (ascii_key == 'C' || ascii_key == '^') {
|
if (ascii_key == 'C' || ascii_key == '^') {
|
||||||
k_ctrl_pending_ = true;
|
k_ctrl_pending_ = true;
|
||||||
if (ed_)
|
if (ed_)
|
||||||
ed_->SetStatus("C-k C _");
|
ed_->SetStatus("C-k C _");
|
||||||
// Keep k-prefix active; do not emit a command
|
// Keep k-prefix active; do not emit a command
|
||||||
k_prefix_ = true;
|
k_prefix_ = true;
|
||||||
produced = true;
|
produced = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Map via k-prefix table; do not pass Ctrl for TEXTINPUT case
|
// Map via k-prefix table; do not pass Ctrl for TEXTINPUT case
|
||||||
CommandId id;
|
CommandId id;
|
||||||
bool pass_ctrl = k_ctrl_pending_;
|
bool pass_ctrl = k_ctrl_pending_;
|
||||||
@@ -608,4 +606,4 @@ ImGuiInputHandler::Poll(MappedInput &out)
|
|||||||
out = q_.front();
|
out = q_.front();
|
||||||
q_.pop();
|
q_.pop();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,4 +46,4 @@ private:
|
|||||||
// command per whole step and keep the fractional remainder.
|
// command per whole step and keep the fractional remainder.
|
||||||
float wheel_accum_y_ = 0.0f;
|
float wheel_accum_y_ = 0.0f;
|
||||||
float wheel_accum_x_ = 0.0f; // reserved for future horizontal scrolling
|
float wheel_accum_x_ = 0.0f; // reserved for future horizontal scrolling
|
||||||
};
|
};
|
||||||
|
|||||||
192
ImGuiRenderer.cc
192
ImGuiRenderer.cc
@@ -150,73 +150,84 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
// Cache current horizontal offset in rendered columns for click handling
|
// Cache current horizontal offset in rendered columns for click handling
|
||||||
const std::size_t coloffs_now = buf->Coloffs();
|
const std::size_t coloffs_now = buf->Coloffs();
|
||||||
|
|
||||||
// Handle mouse click before rendering to avoid dependent on drawn items
|
// Mark selection state (mark -> cursor), in source coordinates
|
||||||
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
bool sel_active = false;
|
||||||
|
std::size_t sel_sy = 0, sel_sx = 0, sel_ey = 0, sel_ex = 0;
|
||||||
|
if (buf->MarkSet()) {
|
||||||
|
sel_sy = buf->MarkCury();
|
||||||
|
sel_sx = buf->MarkCurx();
|
||||||
|
sel_ey = buf->Cury();
|
||||||
|
sel_ex = buf->Curx();
|
||||||
|
if (sel_sy > sel_ey || (sel_sy == sel_ey && sel_sx > sel_ex)) {
|
||||||
|
std::swap(sel_sy, sel_ey);
|
||||||
|
std::swap(sel_sx, sel_ex);
|
||||||
|
}
|
||||||
|
sel_active = !(sel_sy == sel_ey && sel_sx == sel_ex);
|
||||||
|
}
|
||||||
|
// Visual-line selection: full-line highlight range
|
||||||
|
const bool vsel_active = buf->VisualLineActive();
|
||||||
|
const std::size_t vsel_sy = vsel_active ? buf->VisualLineStartY() : 0;
|
||||||
|
const std::size_t vsel_ey = vsel_active ? buf->VisualLineEndY() : 0;
|
||||||
|
|
||||||
|
static bool mouse_selecting = false;
|
||||||
|
auto mouse_pos_to_buf = [&]() -> std::pair<std::size_t, std::size_t> {
|
||||||
ImVec2 mp = ImGui::GetIO().MousePos;
|
ImVec2 mp = ImGui::GetIO().MousePos;
|
||||||
// Compute content-relative position accounting for scroll
|
// Convert mouse pos to buffer row
|
||||||
// mp.y - child_window_pos.y gives us pixels from top of child window
|
|
||||||
// Adding scroll_y gives us pixels from top of content (buffer row 0)
|
|
||||||
float content_y = (mp.y - child_window_pos.y) + scroll_y;
|
float content_y = (mp.y - child_window_pos.y) + scroll_y;
|
||||||
long by_l = static_cast<long>(content_y / row_h);
|
long by_l = static_cast<long>(content_y / row_h);
|
||||||
if (by_l < 0)
|
if (by_l < 0)
|
||||||
by_l = 0;
|
by_l = 0;
|
||||||
|
|
||||||
// Convert to buffer row
|
|
||||||
std::size_t by = static_cast<std::size_t>(by_l);
|
std::size_t by = static_cast<std::size_t>(by_l);
|
||||||
if (by >= lines.size()) {
|
if (by >= lines.size())
|
||||||
if (!lines.empty())
|
by = lines.empty() ? 0 : (lines.size() - 1);
|
||||||
by = lines.size() - 1;
|
|
||||||
else
|
|
||||||
by = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute click X position relative to left edge of child window (in pixels)
|
// Convert mouse pos to rendered x
|
||||||
// This gives us the visual offset from the start of displayed content
|
|
||||||
float visual_x = mp.x - child_window_pos.x;
|
float visual_x = mp.x - child_window_pos.x;
|
||||||
if (visual_x < 0.0f)
|
if (visual_x < 0.0f)
|
||||||
visual_x = 0.0f;
|
visual_x = 0.0f;
|
||||||
|
|
||||||
// Convert visual pixel offset to rendered column, then add coloffs_now
|
|
||||||
// to get the absolute rendered column in the buffer
|
|
||||||
std::size_t clicked_rx = static_cast<std::size_t>(visual_x / space_w) + coloffs_now;
|
std::size_t clicked_rx = static_cast<std::size_t>(visual_x / space_w) + coloffs_now;
|
||||||
|
|
||||||
// Empty buffer guard: if there are no lines yet, just move to 0:0
|
// Convert rendered column to source column
|
||||||
if (lines.empty()) {
|
if (lines.empty())
|
||||||
Execute(ed, CommandId::MoveCursorTo, std::string("0:0"));
|
return {0, 0};
|
||||||
} else {
|
std::string line_clicked = static_cast<std::string>(lines[by]);
|
||||||
// Convert rendered column (clicked_rx) to source column accounting for tabs
|
const std::size_t tabw = 8;
|
||||||
std::string line_clicked = static_cast<std::string>(lines[by]);
|
std::size_t rx = 0;
|
||||||
const std::size_t tabw = 8;
|
std::size_t best_col = 0;
|
||||||
|
float best_dist = std::numeric_limits<float>::infinity();
|
||||||
// Iterate through source columns, computing rendered position, to find closest match
|
float clicked_rx_f = static_cast<float>(clicked_rx);
|
||||||
std::size_t rx = 0; // rendered column position
|
for (std::size_t i = 0; i <= line_clicked.size(); ++i) {
|
||||||
std::size_t best_col = 0;
|
float dist = std::fabs(clicked_rx_f - static_cast<float>(rx));
|
||||||
float best_dist = std::numeric_limits<float>::infinity();
|
if (dist < best_dist) {
|
||||||
float clicked_rx_f = static_cast<float>(clicked_rx);
|
best_dist = dist;
|
||||||
|
best_col = i;
|
||||||
for (std::size_t i = 0; i <= line_clicked.size(); ++i) {
|
}
|
||||||
// Check current position
|
if (i < line_clicked.size()) {
|
||||||
float dist = std::fabs(clicked_rx_f - static_cast<float>(rx));
|
rx += (line_clicked[i] == '\t') ? (tabw - (rx % tabw)) : 1;
|
||||||
if (dist < best_dist) {
|
|
||||||
best_dist = dist;
|
|
||||||
best_col = i;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Advance to next position if not at end
|
|
||||||
if (i < line_clicked.size()) {
|
|
||||||
if (line_clicked[i] == '\t') {
|
|
||||||
rx += (tabw - (rx % tabw));
|
|
||||||
} else {
|
|
||||||
rx += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispatch absolute buffer coordinates (row:col)
|
|
||||||
char tmp[64];
|
|
||||||
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, best_col);
|
|
||||||
Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
|
|
||||||
}
|
}
|
||||||
|
return {by, best_col};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mouse-driven selection: set mark on press, update cursor on drag
|
||||||
|
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
||||||
|
mouse_selecting = true;
|
||||||
|
auto [by, bx] = mouse_pos_to_buf();
|
||||||
|
char tmp[64];
|
||||||
|
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx);
|
||||||
|
Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
|
||||||
|
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
|
||||||
|
mbuf->SetMark(bx, by);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (mouse_selecting && ImGui::IsWindowHovered() && ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
|
||||||
|
auto [by, bx] = mouse_pos_to_buf();
|
||||||
|
char tmp[64];
|
||||||
|
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx);
|
||||||
|
Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
|
||||||
|
}
|
||||||
|
if (mouse_selecting && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
|
||||||
|
mouse_selecting = false;
|
||||||
}
|
}
|
||||||
for (std::size_t i = rowoffs; i < lines.size(); ++i) {
|
for (std::size_t i = rowoffs; i < lines.size(); ++i) {
|
||||||
// Capture the screen position before drawing the line
|
// Capture the screen position before drawing the line
|
||||||
@@ -295,6 +306,71 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Draw selection background (over search highlight; under text)
|
||||||
|
if (sel_active) {
|
||||||
|
bool line_has = false;
|
||||||
|
std::size_t sx = 0, ex = 0;
|
||||||
|
if (i < sel_sy || i > sel_ey) {
|
||||||
|
line_has = false;
|
||||||
|
} else if (sel_sy == sel_ey) {
|
||||||
|
sx = sel_sx;
|
||||||
|
ex = sel_ex;
|
||||||
|
line_has = ex > sx;
|
||||||
|
} else if (i == sel_sy) {
|
||||||
|
sx = sel_sx;
|
||||||
|
ex = line.size();
|
||||||
|
line_has = ex > sx;
|
||||||
|
} else if (i == sel_ey) {
|
||||||
|
sx = 0;
|
||||||
|
ex = std::min(sel_ex, line.size());
|
||||||
|
line_has = ex > sx;
|
||||||
|
} else {
|
||||||
|
sx = 0;
|
||||||
|
ex = line.size();
|
||||||
|
line_has = ex > sx;
|
||||||
|
}
|
||||||
|
if (line_has) {
|
||||||
|
std::size_t rx_start = src_to_rx(sx);
|
||||||
|
std::size_t rx_end = src_to_rx(ex);
|
||||||
|
if (rx_end > coloffs_now) {
|
||||||
|
std::size_t vx0 = (rx_start > coloffs_now)
|
||||||
|
? (rx_start - coloffs_now)
|
||||||
|
: 0;
|
||||||
|
std::size_t vx1 = rx_end - coloffs_now;
|
||||||
|
ImVec2 p0 = ImVec2(line_pos.x + static_cast<float>(vx0) * space_w,
|
||||||
|
line_pos.y);
|
||||||
|
ImVec2 p1 = ImVec2(line_pos.x + static_cast<float>(vx1) * space_w,
|
||||||
|
line_pos.y + line_h);
|
||||||
|
ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg);
|
||||||
|
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (vsel_active && i >= vsel_sy && i <= vsel_ey) {
|
||||||
|
// Visual-line (multi-cursor) mode: highlight only the per-line cursor spot.
|
||||||
|
const std::size_t spot_sx = std::min(buf->Curx(), line.size());
|
||||||
|
const std::size_t rx_start = src_to_rx(spot_sx);
|
||||||
|
std::size_t rx_end = rx_start;
|
||||||
|
if (spot_sx < line.size()) {
|
||||||
|
rx_end = src_to_rx(spot_sx + 1);
|
||||||
|
} else {
|
||||||
|
// EOL spot: draw a 1-cell highlight just past the last character.
|
||||||
|
rx_end = rx_start + 1;
|
||||||
|
}
|
||||||
|
if (rx_end > coloffs_now) {
|
||||||
|
std::size_t vx0 = (rx_start > coloffs_now)
|
||||||
|
? (rx_start - coloffs_now)
|
||||||
|
: 0;
|
||||||
|
std::size_t vx1 = rx_end - coloffs_now;
|
||||||
|
ImVec2 p0 = ImVec2(line_pos.x + static_cast<float>(vx0) * space_w,
|
||||||
|
line_pos.y);
|
||||||
|
ImVec2 p1 = ImVec2(line_pos.x + static_cast<float>(vx1) * space_w,
|
||||||
|
line_pos.y + line_h);
|
||||||
|
ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg);
|
||||||
|
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
||||||
|
}
|
||||||
|
}
|
||||||
// Emit entire line to an expanded buffer (tabs -> spaces)
|
// Emit entire line to an expanded buffer (tabs -> spaces)
|
||||||
for (std::size_t src = 0; src < line.size(); ++src) {
|
for (std::size_t src = 0; src < line.size(); ++src) {
|
||||||
char c = line[src];
|
char c = line[src];
|
||||||
@@ -836,12 +912,8 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
ed.SetFilePickerDir(e.path.string());
|
ed.SetFilePickerDir(e.path.string());
|
||||||
} else if (!e.is_dir && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
} else if (!e.is_dir && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
||||||
// Open file on single click
|
// Open file on single click
|
||||||
std::string err;
|
ed.RequestOpenFile(e.path.string());
|
||||||
if (!ed.OpenFile(e.path.string(), err)) {
|
(void) ed.ProcessPendingOpens();
|
||||||
ed.SetStatus(std::string("open: ") + err);
|
|
||||||
} else {
|
|
||||||
ed.SetStatus(std::string("Opened: ") + e.name);
|
|
||||||
}
|
|
||||||
ed.SetFilePickerVisible(false);
|
ed.SetFilePickerVisible(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -855,4 +927,4 @@ ImGuiRenderer::Draw(Editor &ed)
|
|||||||
ed.SetFilePickerVisible(false);
|
ed.SetFilePickerVisible(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,4 +11,4 @@ public:
|
|||||||
~ImGuiRenderer() override = default;
|
~ImGuiRenderer() override = default;
|
||||||
|
|
||||||
void Draw(Editor &ed) override;
|
void Draw(Editor &ed) override;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -28,4 +28,4 @@ public:
|
|||||||
// Poll for input and translate it to a command. Non-blocking.
|
// Poll for input and translate it to a command. Non-blocking.
|
||||||
// Returns true if a command is available in 'out'. Returns false if no input.
|
// Returns true if a command is available in 'out'. Returns false if no input.
|
||||||
virtual bool Poll(MappedInput &out) = 0;
|
virtual bool Poll(MappedInput &out) = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
11
KKeymap.cc
11
KKeymap.cc
@@ -17,6 +17,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
|||||||
case 'd':
|
case 'd':
|
||||||
out = CommandId::KillLine;
|
out = CommandId::KillLine;
|
||||||
return true;
|
return true;
|
||||||
|
case 's':
|
||||||
|
out = CommandId::Save;
|
||||||
|
return true;
|
||||||
case 'q':
|
case 'q':
|
||||||
out = CommandId::QuitNow;
|
out = CommandId::QuitNow;
|
||||||
return true;
|
return true;
|
||||||
@@ -42,6 +45,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
|||||||
case 'a':
|
case 'a':
|
||||||
out = CommandId::MarkAllAndJumpEnd;
|
out = CommandId::MarkAllAndJumpEnd;
|
||||||
return true;
|
return true;
|
||||||
|
case ' ': // C-k SPACE
|
||||||
|
out = CommandId::ToggleMark;
|
||||||
|
return true;
|
||||||
case 'i':
|
case 'i':
|
||||||
out = CommandId::BufferNew; // C-k i new empty buffer
|
out = CommandId::BufferNew; // C-k i new empty buffer
|
||||||
return true;
|
return true;
|
||||||
@@ -114,6 +120,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
|||||||
case '=':
|
case '=':
|
||||||
out = CommandId::IndentRegion;
|
out = CommandId::IndentRegion;
|
||||||
return true;
|
return true;
|
||||||
|
case '/':
|
||||||
|
out = CommandId::VisualLineModeToggle;
|
||||||
|
return true;
|
||||||
case ';':
|
case ';':
|
||||||
out = CommandId::CommandPromptStart; // C-k ; : generic command prompt
|
out = CommandId::CommandPromptStart; // C-k ; : generic command prompt
|
||||||
return true;
|
return true;
|
||||||
@@ -221,4 +230,4 @@ KLookupEscCommand(const int ascii_key, CommandId &out) -> bool
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,4 +27,4 @@ KLowerAscii(const int key)
|
|||||||
if (key >= 'A' && key <= 'Z')
|
if (key >= 'A' && key <= 'Z')
|
||||||
return key + ('a' - 'A');
|
return key + ('a' - 'A');
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,4 +20,4 @@ private:
|
|||||||
std::string last_pat_;
|
std::string last_pat_;
|
||||||
|
|
||||||
void build_bad_char(const std::string &pattern);
|
void build_bad_char(const std::string &pattern);
|
||||||
};
|
};
|
||||||
|
|||||||
36
PieceTable.h
36
PieceTable.h
@@ -1,5 +1,39 @@
|
|||||||
/*
|
/*
|
||||||
* PieceTable.h - Alternative to GapBuffer using a piece table representation
|
* PieceTable.h - Alternative to GapBuffer using a piece table representation
|
||||||
|
*
|
||||||
|
* PieceTable is kte's core text storage data structure. It provides efficient
|
||||||
|
* insert/delete operations without copying the entire buffer by maintaining a
|
||||||
|
* sequence of "pieces" that reference ranges in two underlying buffers:
|
||||||
|
* - original_: Initial file content (currently unused, reserved for future)
|
||||||
|
* - add_: All text added during editing
|
||||||
|
*
|
||||||
|
* Key advantages:
|
||||||
|
* - O(1) append/prepend operations (common case)
|
||||||
|
* - O(n) insert/delete at arbitrary positions (n = number of pieces, not bytes)
|
||||||
|
* - Efficient undo: just restore the piece list
|
||||||
|
* - Memory efficient: no gap buffer waste
|
||||||
|
*
|
||||||
|
* Performance characteristics:
|
||||||
|
* - Piece count grows with edit operations; automatic consolidation prevents unbounded growth
|
||||||
|
* - Materialization (Data() call) is O(total_size) but cached until next edit
|
||||||
|
* - Line index is lazily rebuilt on first line-based query after edits
|
||||||
|
* - Range and Find operations use lightweight caches for repeated queries
|
||||||
|
*
|
||||||
|
* API evolution:
|
||||||
|
* 1. Legacy API (GapBuffer compatibility):
|
||||||
|
* - Append/Prepend: Build content sequentially
|
||||||
|
* - Data(): Materialize entire buffer
|
||||||
|
*
|
||||||
|
* 2. New buffer-wide API (Phase 1):
|
||||||
|
* - Insert/Delete: Edit at arbitrary byte offsets
|
||||||
|
* - Line-based queries: LineCount, GetLine, GetLineRange
|
||||||
|
* - Position conversion: ByteOffsetToLineCol, LineColToByteOffset
|
||||||
|
* - Efficient extraction: GetRange, Find, WriteToStream
|
||||||
|
*
|
||||||
|
* Implementation notes:
|
||||||
|
* - Consolidation heuristics prevent piece fragmentation (configurable via SetConsolidationParams)
|
||||||
|
* - Thread-safe for concurrent reads (mutex protects caches and lazy rebuilds)
|
||||||
|
* - Version tracking invalidates caches on mutations
|
||||||
*/
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
@@ -184,4 +218,4 @@ private:
|
|||||||
mutable FindCache find_cache_;
|
mutable FindCache find_cache_;
|
||||||
|
|
||||||
mutable std::mutex mutex_;
|
mutable std::mutex mutex_;
|
||||||
};
|
};
|
||||||
@@ -123,8 +123,7 @@ protected:
|
|||||||
if (ed_ && viewport.height() > 0 && viewport.width() > 0) {
|
if (ed_ && viewport.height() > 0 && viewport.width() > 0) {
|
||||||
const Buffer *buf = ed_->CurrentBuffer();
|
const Buffer *buf = ed_->CurrentBuffer();
|
||||||
if (buf) {
|
if (buf) {
|
||||||
const auto &lines = buf->Rows();
|
const std::size_t nrows = buf->Nrows();
|
||||||
const std::size_t nrows = lines.size();
|
|
||||||
const std::size_t rowoffs = buf->Rowoffs();
|
const std::size_t rowoffs = buf->Rowoffs();
|
||||||
const std::size_t coloffs = buf->Coloffs();
|
const std::size_t coloffs = buf->Coloffs();
|
||||||
const std::size_t cy = buf->Cury();
|
const std::size_t cy = buf->Cury();
|
||||||
@@ -144,9 +143,8 @@ protected:
|
|||||||
|
|
||||||
// Iterate visible lines
|
// Iterate visible lines
|
||||||
for (std::size_t i = rowoffs, vis_idx = 0; i < last_row; ++i, ++vis_idx) {
|
for (std::size_t i = rowoffs, vis_idx = 0; i < last_row; ++i, ++vis_idx) {
|
||||||
// Materialize the Buffer::Line into a std::string for
|
// Get line as string for regex/iterator usage and general string ops.
|
||||||
// regex/iterator usage and general string ops.
|
const std::string line = buf->GetLineString(i);
|
||||||
const std::string line = static_cast<std::string>(lines[i]);
|
|
||||||
const int y = viewport.y() + static_cast<int>(vis_idx) * line_h;
|
const int y = viewport.y() + static_cast<int>(vis_idx) * line_h;
|
||||||
const int baseline = y + fm.ascent();
|
const int baseline = y + fm.ascent();
|
||||||
|
|
||||||
@@ -775,6 +773,9 @@ GUIFrontend::Step(Editor &ed, bool &running)
|
|||||||
if (app_)
|
if (app_)
|
||||||
app_->processEvents();
|
app_->processEvents();
|
||||||
|
|
||||||
|
// Allow deferred opens (including swap recovery prompts) to run.
|
||||||
|
ed.ProcessPendingOpens();
|
||||||
|
|
||||||
// Drain input queue
|
// Drain input queue
|
||||||
for (;;) {
|
for (;;) {
|
||||||
MappedInput mi;
|
MappedInput mi;
|
||||||
@@ -801,14 +802,8 @@ GUIFrontend::Step(Editor &ed, bool &running)
|
|||||||
const QStringList files = dlg.selectedFiles();
|
const QStringList files = dlg.selectedFiles();
|
||||||
if (!files.isEmpty()) {
|
if (!files.isEmpty()) {
|
||||||
const QString fp = files.front();
|
const QString fp = files.front();
|
||||||
std::string err;
|
ed.RequestOpenFile(fp.toStdString());
|
||||||
if (ed.OpenFile(fp.toStdString(), err)) {
|
(void) ed.ProcessPendingOpens();
|
||||||
ed.SetStatus(std::string("Opened: ") + fp.toStdString());
|
|
||||||
} else if (!err.empty()) {
|
|
||||||
ed.SetStatus(std::string("Open failed: ") + err);
|
|
||||||
} else {
|
|
||||||
ed.SetStatus("Open failed");
|
|
||||||
}
|
|
||||||
// Update picker dir for next time
|
// Update picker dir for next time
|
||||||
QFileInfo info(fp);
|
QFileInfo info(fp);
|
||||||
ed.SetFilePickerDir(info.dir().absolutePath().toStdString());
|
ed.SetFilePickerDir(info.dir().absolutePath().toStdString());
|
||||||
|
|||||||
@@ -33,4 +33,4 @@ private:
|
|||||||
QWidget *window_ = nullptr; // owned
|
QWidget *window_ = nullptr; // owned
|
||||||
int width_ = 1280;
|
int width_ = 1280;
|
||||||
int height_ = 800;
|
int height_ = 800;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -283,12 +283,11 @@ QtInputHandler::ProcessKeyEvent(const QKeyEvent &e)
|
|||||||
const bool ctrl_like = (mods & Qt::ControlModifier);
|
const bool ctrl_like = (mods & Qt::ControlModifier);
|
||||||
|
|
||||||
// 1) Universal argument digits (when active), consume digits without enqueuing commands
|
// 1) Universal argument digits (when active), consume digits without enqueuing commands
|
||||||
if (ed_ &&ed_
|
if (ed_ && ed_
|
||||||
|
|
||||||
->
|
->
|
||||||
UArg() != 0
|
UArg() != 0
|
||||||
)
|
) {
|
||||||
{
|
|
||||||
if (!(mods & (Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier))) {
|
if (!(mods & (Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier))) {
|
||||||
if (e.key() >= Qt::Key_0 && e.key() <= Qt::Key_9) {
|
if (e.key() >= Qt::Key_0 && e.key() <= Qt::Key_9) {
|
||||||
int d = e.key() - Qt::Key_0;
|
int d = e.key() - Qt::Key_0;
|
||||||
@@ -379,10 +378,9 @@ QtInputHandler::ProcessKeyEvent(const QKeyEvent &e)
|
|||||||
// ESC/meta chords: on macOS, do NOT treat Meta as ESC; only Alt (Option) should trigger.
|
// ESC/meta chords: on macOS, do NOT treat Meta as ESC; only Alt (Option) should trigger.
|
||||||
#if defined(__APPLE__)
|
#if defined(__APPLE__)
|
||||||
if (esc_meta_ || (mods & Qt::AltModifier)) {
|
if (esc_meta_ || (mods & Qt::AltModifier)) {
|
||||||
|
|
||||||
|
|
||||||
#else
|
#else
|
||||||
if (esc_meta_ || (mods & (Qt::AltModifier | Qt::MetaModifier))) {
|
if (esc_meta_ || (mods & (Qt::AltModifier | Qt::MetaModifier))) {
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
int ascii_key = 0;
|
int ascii_key = 0;
|
||||||
if (e.key() == Qt::Key_Backspace) {
|
if (e.key() == Qt::Key_Backspace) {
|
||||||
@@ -535,4 +533,4 @@ QtInputHandler::Poll(MappedInput &out)
|
|||||||
out = q_.front();
|
out = q_.front();
|
||||||
q_.pop();
|
q_.pop();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,4 +37,4 @@ private:
|
|||||||
bool esc_meta_ = false; // ESC-prefix for next key
|
bool esc_meta_ = false; // ESC-prefix for next key
|
||||||
bool suppress_text_input_once_ = false; // reserved (Qt sends text in keyPressEvent)
|
bool suppress_text_input_once_ = false; // reserved (Qt sends text in keyPressEvent)
|
||||||
Editor *ed_ = nullptr;
|
Editor *ed_ = nullptr;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -73,4 +73,4 @@ QtRenderer::Draw(Editor &ed)
|
|||||||
}
|
}
|
||||||
// Request a repaint
|
// Request a repaint
|
||||||
widget_->update();
|
widget_->update();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,4 +24,4 @@ public:
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
QWidget *widget_ = nullptr; // not owned
|
QWidget *widget_ = nullptr; // not owned
|
||||||
};
|
};
|
||||||
|
|||||||
43
README.md
43
README.md
@@ -32,27 +32,27 @@ Project Goals
|
|||||||
|
|
||||||
Keybindings
|
Keybindings
|
||||||
-----------
|
-----------
|
||||||
kte maintains ke’s command model while internals evolve. Highlights (subject to refinement):
|
kte maintains ke’s command model while internals evolve. Highlights (
|
||||||
|
subject to refinement):
|
||||||
|
|
||||||
- K‑command prefix: `C-k` enters k‑command mode; exit with `ESC` or
|
- K‑command prefix: `C-k` enters k‑command mode; exit with `ESC` or
|
||||||
`C-g`.
|
`C-g`.
|
||||||
- Save/Exit: `C-k s` (save), `C-k x` or `C-k C-x` (save and exit),
|
- Save/Exit: `C-k s` (save), `C-k x` or `C-k C-x` (save and exit),
|
||||||
`C-k q` (quit with confirm), `C-k C-q` (quit immediately).
|
`C-k q` (quit with confirm), `C-k C-q` (quit immediately).
|
||||||
- Editing: `C-k d` (kill to EOL), `C-k C-d` (kill line), `C-k
|
- Editing: `C-k d` (kill to EOL), `C-k C-d` (kill line), `C-w` (kill
|
||||||
BACKSPACE` (kill to BOL), `C-w` (kill region), `C-y` ( yank), `C-u`
|
region), `C-y` (yank), `C-u` (universal argument).
|
||||||
(universal argument).
|
|
||||||
- Navigation/Search: `C-s` (incremental find), `C-r` (regex search),
|
- Navigation/Search: `C-s` (incremental find), `C-r` (regex search),
|
||||||
`ESC f/b` (word next/prev), `ESC BACKSPACE` (delete previous word).
|
`ESC f/b` (word next/prev), `ESC BACKSPACE` (delete previous word).
|
||||||
- Buffers/Files: `C-k e` (open), `C-k b`/`C-k p` (switch), `C-k c`
|
- Buffers/Files: `C-k e` (open), `C-k b`/`C-k p` (switch), `C-k c`
|
||||||
(close), `C-k C-r` (reload).
|
(close), `C-k l` (reload).
|
||||||
- Misc: `C-l` (refresh), `C-g` (cancel), `C-k m` (run make), `C-k g`
|
- Misc: `C-l` (refresh), `C-g` (cancel), `C-k g` (goto line).
|
||||||
(goto line).
|
|
||||||
|
|
||||||
See `ke.md` for the canonical ke reference retained for now.
|
See `ke.md` for the canonical ke reference retained for now.
|
||||||
|
|
||||||
Build and Run
|
Build and Run
|
||||||
-------------
|
-------------
|
||||||
Prerequisites: C++17 compiler, CMake, and ncurses development headers/libs.
|
Prerequisites: C++20 compiler, CMake, and ncurses development
|
||||||
|
headers/libs.
|
||||||
|
|
||||||
Dependencies by platform
|
Dependencies by platform
|
||||||
------------------------
|
------------------------
|
||||||
@@ -62,30 +62,38 @@ Dependencies by platform
|
|||||||
- `brew install ncurses`
|
- `brew install ncurses`
|
||||||
- Optional GUI (enable with `-DBUILD_GUI=ON`):
|
- Optional GUI (enable with `-DBUILD_GUI=ON`):
|
||||||
- `brew install sdl2 freetype`
|
- `brew install sdl2 freetype`
|
||||||
- OpenGL is provided by the system framework on macOS; no package needed.
|
- OpenGL is provided by the system framework on macOS; no
|
||||||
|
package needed.
|
||||||
|
|
||||||
- Debian/Ubuntu
|
- Debian/Ubuntu
|
||||||
- Terminal (default):
|
- Terminal (default):
|
||||||
- `sudo apt-get install -y libncurses5-dev libncursesw5-dev`
|
- `sudo apt-get install -y libncurses5-dev libncursesw5-dev`
|
||||||
- Optional GUI (enable with `-DBUILD_GUI=ON`):
|
- Optional GUI (enable with `-DBUILD_GUI=ON`):
|
||||||
- `sudo apt-get install -y libsdl2-dev libfreetype6-dev mesa-common-dev`
|
-
|
||||||
- The `mesa-common-dev` package provides OpenGL headers/libs (`libGL`).
|
`sudo apt-get install -y libsdl2-dev libfreetype6-dev mesa-common-dev`
|
||||||
|
- The `mesa-common-dev` package provides OpenGL headers/libs (
|
||||||
|
`libGL`).
|
||||||
|
|
||||||
- NixOS/Nix
|
- NixOS/Nix
|
||||||
- Terminal (default):
|
- Terminal (default):
|
||||||
- Ad-hoc shell: `nix-shell -p cmake gcc ncurses`
|
- Ad-hoc shell: `nix-shell -p cmake gcc ncurses`
|
||||||
- Optional GUI (enable with `-DBUILD_GUI=ON`):
|
- Optional GUI (enable with `-DBUILD_GUI=ON`):
|
||||||
- Ad-hoc shell: `nix-shell -p cmake gcc ncurses SDL2 freetype libGL`
|
- Ad-hoc shell:
|
||||||
- With flakes/devshell (example `flake.nix` inputs not provided): include
|
`nix-shell -p cmake gcc ncurses SDL2 freetype libGL`
|
||||||
`ncurses` for TUI, and `SDL2`, `freetype`, `libGL` for GUI in your devShell.
|
- With flakes/devshell (example `flake.nix` inputs not provided):
|
||||||
|
include
|
||||||
|
`ncurses` for TUI, and `SDL2`, `freetype`, `libGL` for GUI in your
|
||||||
|
devShell.
|
||||||
|
|
||||||
Notes
|
Notes
|
||||||
-----
|
-----
|
||||||
|
|
||||||
- The GUI is OFF by default to keep SDL/OpenGL/Freetype optional. Enable it by
|
- The GUI is OFF by default to keep SDL/OpenGL/Freetype optional. Enable
|
||||||
|
it by
|
||||||
configuring with `-DBUILD_GUI=ON` and ensuring the GUI deps above are
|
configuring with `-DBUILD_GUI=ON` and ensuring the GUI deps above are
|
||||||
installed for your platform.
|
installed for your platform.
|
||||||
- If you previously configured with GUI ON and want to disable it, reconfigure
|
- If you previously configured with GUI ON and want to disable it,
|
||||||
|
reconfigure
|
||||||
the build directory with `-DBUILD_GUI=OFF`.
|
the build directory with `-DBUILD_GUI=OFF`.
|
||||||
|
|
||||||
Example build:
|
Example build:
|
||||||
@@ -113,7 +121,8 @@ built as `kge`) or request the GUI from `kte`:
|
|||||||
GUI build example
|
GUI build example
|
||||||
-----------------
|
-----------------
|
||||||
|
|
||||||
To build with the optional GUI (after installing the GUI dependencies listed above):
|
To build with the optional GUI (after installing the GUI dependencies
|
||||||
|
listed above):
|
||||||
|
|
||||||
```
|
```
|
||||||
cmake -S . -B cmake-build-debug -DCMAKE_BUILD_TYPE=Debug -DBUILD_GUI=ON
|
cmake -S . -B cmake-build-debug -DCMAKE_BUILD_TYPE=Debug -DBUILD_GUI=ON
|
||||||
|
|||||||
@@ -10,4 +10,4 @@ public:
|
|||||||
virtual ~Renderer() = default;
|
virtual ~Renderer() = default;
|
||||||
|
|
||||||
virtual void Draw(Editor &ed) = 0;
|
virtual void Draw(Editor &ed) = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
147
Swap.h
147
Swap.h
@@ -7,11 +7,14 @@
|
|||||||
#include <string_view>
|
#include <string_view>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
|
#include <memory>
|
||||||
#include <mutex>
|
#include <mutex>
|
||||||
#include <condition_variable>
|
#include <condition_variable>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
|
|
||||||
|
#include "SwapRecorder.h"
|
||||||
|
|
||||||
class Buffer;
|
class Buffer;
|
||||||
|
|
||||||
namespace kte {
|
namespace kte {
|
||||||
@@ -29,50 +32,88 @@ struct SwapConfig {
|
|||||||
// Grouping and durability knobs (stage 1 defaults)
|
// Grouping and durability knobs (stage 1 defaults)
|
||||||
unsigned flush_interval_ms{200}; // group small writes
|
unsigned flush_interval_ms{200}; // group small writes
|
||||||
unsigned fsync_interval_ms{1000}; // at most once per second
|
unsigned fsync_interval_ms{1000}; // at most once per second
|
||||||
};
|
|
||||||
|
|
||||||
// Lightweight interface that Buffer can call without depending on full manager impl
|
// Checkpoint/compaction knobs (stage 2 defaults)
|
||||||
class SwapRecorder {
|
// A checkpoint is a full snapshot of the buffer content written as a CHKPT record.
|
||||||
public:
|
// Compaction rewrites the swap file to contain just the latest checkpoint.
|
||||||
virtual ~SwapRecorder() = default;
|
std::size_t checkpoint_bytes{1024 * 1024}; // request checkpoint after this many queued edit-bytes
|
||||||
|
unsigned checkpoint_interval_ms{60000}; // request checkpoint at least this often while editing
|
||||||
|
std::size_t compact_bytes{8 * 1024 * 1024}; // compact on checkpoint once journal grows beyond this
|
||||||
|
|
||||||
virtual void RecordInsert(Buffer &buf, int row, int col, std::string_view text) = 0;
|
// Cleanup / retention (best-effort)
|
||||||
|
bool prune_on_startup{true};
|
||||||
virtual void RecordDelete(Buffer &buf, int row, int col, std::size_t len) = 0;
|
unsigned prune_max_age_days{30};
|
||||||
|
std::size_t prune_max_files{2048};
|
||||||
virtual void RecordSplit(Buffer &buf, int row, int col) = 0;
|
|
||||||
|
|
||||||
virtual void RecordJoin(Buffer &buf, int row) = 0;
|
|
||||||
|
|
||||||
virtual void NotifyFilenameChanged(Buffer &buf) = 0;
|
|
||||||
|
|
||||||
virtual void SetSuspended(Buffer &buf, bool on) = 0;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// SwapManager manages sidecar swap files and a single background writer thread.
|
// SwapManager manages sidecar swap files and a single background writer thread.
|
||||||
class SwapManager final : public SwapRecorder {
|
class SwapManager final {
|
||||||
public:
|
public:
|
||||||
SwapManager();
|
SwapManager();
|
||||||
|
|
||||||
~SwapManager() override;
|
~SwapManager();
|
||||||
|
|
||||||
// Attach a buffer to begin journaling. Safe to call multiple times; idempotent.
|
// Attach a buffer to begin journaling. Safe to call multiple times; idempotent.
|
||||||
void Attach(Buffer *buf);
|
void Attach(Buffer *buf);
|
||||||
|
|
||||||
// Detach and close journal.
|
// Detach and close journal.
|
||||||
void Detach(Buffer *buf);
|
// If remove_file is true, the swap file is deleted after closing.
|
||||||
|
// Intended for clean shutdown/close flows.
|
||||||
|
void Detach(Buffer *buf, bool remove_file = false);
|
||||||
|
|
||||||
// SwapRecorder: Notify that the buffer's filename changed (e.g., SaveAs)
|
// Reset (truncate-by-delete) the journal for a buffer after a clean save.
|
||||||
void NotifyFilenameChanged(Buffer &buf) override;
|
// Best-effort: closes the current fd, deletes the swap file, and resumes recording.
|
||||||
|
void ResetJournal(Buffer &buf);
|
||||||
|
|
||||||
// SwapRecorder
|
// Best-effort pruning of old swap files under the swap directory.
|
||||||
void RecordInsert(Buffer &buf, int row, int col, std::string_view text) override;
|
// Never touches non-`.swp` files.
|
||||||
|
void PruneSwapDir();
|
||||||
|
|
||||||
void RecordDelete(Buffer &buf, int row, int col, std::size_t len) override;
|
// Block until all currently queued records have been written.
|
||||||
|
// If buf is non-null, flushes all records (stage 1) but is primarily intended
|
||||||
|
// for tests and shutdown.
|
||||||
|
void Flush(Buffer *buf = nullptr);
|
||||||
|
|
||||||
void RecordSplit(Buffer &buf, int row, int col) override;
|
// Request a full-content checkpoint record for one buffer (or all buffers if buf is null).
|
||||||
|
// This is best-effort and asynchronous; call Flush() if you need it written before continuing.
|
||||||
|
void Checkpoint(Buffer *buf = nullptr);
|
||||||
|
|
||||||
void RecordJoin(Buffer &buf, int row) override;
|
|
||||||
|
void SetConfig(const SwapConfig &cfg)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lg(mtx_);
|
||||||
|
cfg_ = cfg;
|
||||||
|
cv_.notify_one();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Obtain a per-buffer recorder adapter that emits records for that buffer.
|
||||||
|
// The returned pointer is owned by the SwapManager and remains valid until
|
||||||
|
// Detach(buf) or SwapManager destruction.
|
||||||
|
SwapRecorder *RecorderFor(Buffer *buf);
|
||||||
|
|
||||||
|
// Notify that the buffer's filename changed (e.g., SaveAs)
|
||||||
|
void NotifyFilenameChanged(Buffer &buf);
|
||||||
|
|
||||||
|
// Replay a swap journal into an already-open buffer.
|
||||||
|
// On success, the buffer content reflects all valid journal records.
|
||||||
|
// On failure (corrupt/truncated/invalid), the buffer is left in whatever
|
||||||
|
// state results from applying records up to the failure point; callers should
|
||||||
|
// treat this as a recovery failure and surface `err`.
|
||||||
|
static bool ReplayFile(Buffer &buf, const std::string &swap_path, std::string &err);
|
||||||
|
|
||||||
|
// Compute the swap path for a file-backed buffer by filename.
|
||||||
|
// Returns empty string if filename is empty.
|
||||||
|
static std::string ComputeSwapPathForFilename(const std::string &filename);
|
||||||
|
|
||||||
|
// Test-only hook to keep swap path logic centralized.
|
||||||
|
// (Avoid duplicating naming rules in unit tests.)
|
||||||
|
#ifdef KTE_TESTS
|
||||||
|
static std::string ComputeSwapPathForTests(const Buffer &buf)
|
||||||
|
{
|
||||||
|
return ComputeSidecarPath(buf);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// RAII guard to suspend recording for internal operations
|
// RAII guard to suspend recording for internal operations
|
||||||
class SuspendGuard {
|
class SuspendGuard {
|
||||||
@@ -88,17 +129,44 @@ public:
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Per-buffer toggle
|
// Per-buffer toggle
|
||||||
void SetSuspended(Buffer &buf, bool on) override;
|
void SetSuspended(Buffer &buf, bool on);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
class BufferRecorder final : public SwapRecorder {
|
||||||
|
public:
|
||||||
|
BufferRecorder(SwapManager &m, Buffer &b) : m_(m), buf_(b) {}
|
||||||
|
|
||||||
|
void OnInsert(int row, int col, std::string_view bytes) override;
|
||||||
|
|
||||||
|
void OnDelete(int row, int col, std::size_t len) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
SwapManager &m_;
|
||||||
|
Buffer &buf_;
|
||||||
|
};
|
||||||
|
|
||||||
|
void RecordInsert(Buffer &buf, int row, int col, std::string_view text);
|
||||||
|
|
||||||
|
void RecordDelete(Buffer &buf, int row, int col, std::size_t len);
|
||||||
|
|
||||||
|
void RecordSplit(Buffer &buf, int row, int col);
|
||||||
|
|
||||||
|
void RecordJoin(Buffer &buf, int row);
|
||||||
|
|
||||||
|
void RecordCheckpoint(Buffer &buf, bool urgent_flush);
|
||||||
|
|
||||||
|
void maybe_request_checkpoint(Buffer &buf, std::size_t approx_edit_bytes);
|
||||||
|
|
||||||
struct JournalCtx {
|
struct JournalCtx {
|
||||||
std::string path;
|
std::string path;
|
||||||
void *file{nullptr}; // FILE*
|
|
||||||
int fd{-1};
|
int fd{-1};
|
||||||
bool header_ok{false};
|
bool header_ok{false};
|
||||||
bool suspended{false};
|
bool suspended{false};
|
||||||
std::uint64_t last_flush_ns{0};
|
std::uint64_t last_flush_ns{0};
|
||||||
std::uint64_t last_fsync_ns{0};
|
std::uint64_t last_fsync_ns{0};
|
||||||
|
std::uint64_t last_chkpt_ns{0};
|
||||||
|
std::uint64_t edit_bytes_since_chkpt{0};
|
||||||
|
std::uint64_t approx_size_bytes{0};
|
||||||
};
|
};
|
||||||
|
|
||||||
struct Pending {
|
struct Pending {
|
||||||
@@ -106,26 +174,35 @@ private:
|
|||||||
SwapRecType type{SwapRecType::INS};
|
SwapRecType type{SwapRecType::INS};
|
||||||
std::vector<std::uint8_t> payload; // framed payload only
|
std::vector<std::uint8_t> payload; // framed payload only
|
||||||
bool urgent_flush{false};
|
bool urgent_flush{false};
|
||||||
|
std::uint64_t seq{0};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
static std::string ComputeSidecarPath(const Buffer &buf);
|
static std::string ComputeSidecarPath(const Buffer &buf);
|
||||||
|
|
||||||
|
static std::string ComputeSidecarPathForFilename(const std::string &filename);
|
||||||
|
|
||||||
static std::uint64_t now_ns();
|
static std::uint64_t now_ns();
|
||||||
|
|
||||||
static bool ensure_parent_dir(const std::string &path);
|
static bool ensure_parent_dir(const std::string &path);
|
||||||
|
|
||||||
static bool write_header(JournalCtx &ctx);
|
static std::string SwapDirRoot();
|
||||||
|
|
||||||
static bool open_ctx(JournalCtx &ctx);
|
static bool write_header(int fd);
|
||||||
|
|
||||||
|
static bool open_ctx(JournalCtx &ctx, const std::string &path);
|
||||||
|
|
||||||
static void close_ctx(JournalCtx &ctx);
|
static void close_ctx(JournalCtx &ctx);
|
||||||
|
|
||||||
|
static bool compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8_t> &chkpt_record);
|
||||||
|
|
||||||
static std::uint32_t crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed = 0);
|
static std::uint32_t crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed = 0);
|
||||||
|
|
||||||
static void put_varu64(std::vector<std::uint8_t> &out, std::uint64_t v);
|
static void put_le32(std::vector<std::uint8_t> &out, std::uint32_t v);
|
||||||
|
|
||||||
static void put_u24(std::uint8_t dst[3], std::uint32_t v);
|
static void put_le64(std::uint8_t dst[8], std::uint64_t v);
|
||||||
|
|
||||||
|
static void put_u24_le(std::uint8_t dst[3], std::uint32_t v);
|
||||||
|
|
||||||
void enqueue(Pending &&p);
|
void enqueue(Pending &&p);
|
||||||
|
|
||||||
@@ -136,10 +213,14 @@ private:
|
|||||||
// State
|
// State
|
||||||
SwapConfig cfg_{};
|
SwapConfig cfg_{};
|
||||||
std::unordered_map<Buffer *, JournalCtx> journals_;
|
std::unordered_map<Buffer *, JournalCtx> journals_;
|
||||||
|
std::unordered_map<Buffer *, std::unique_ptr<BufferRecorder> > recorders_;
|
||||||
std::mutex mtx_;
|
std::mutex mtx_;
|
||||||
std::condition_variable cv_;
|
std::condition_variable cv_;
|
||||||
std::vector<Pending> queue_;
|
std::vector<Pending> queue_;
|
||||||
|
std::uint64_t next_seq_{0};
|
||||||
|
std::uint64_t last_processed_{0};
|
||||||
|
std::uint64_t inflight_{0};
|
||||||
std::atomic<bool> running_{false};
|
std::atomic<bool> running_{false};
|
||||||
std::thread worker_;
|
std::thread worker_;
|
||||||
};
|
};
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
|
|||||||
19
SwapRecorder.h
Normal file
19
SwapRecorder.h
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// SwapRecorder.h - minimal swap journal recording interface for Buffer mutations
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <string_view>
|
||||||
|
|
||||||
|
namespace kte {
|
||||||
|
// SwapRecorder is a tiny, non-blocking callback interface.
|
||||||
|
// Implementations must return quickly; Buffer calls these hooks after a
|
||||||
|
// mutation succeeds.
|
||||||
|
class SwapRecorder {
|
||||||
|
public:
|
||||||
|
virtual ~SwapRecorder() = default;
|
||||||
|
|
||||||
|
virtual void OnInsert(int row, int col, std::string_view bytes) = 0;
|
||||||
|
|
||||||
|
virtual void OnDelete(int row, int col, std::size_t len) = 0;
|
||||||
|
};
|
||||||
|
} // namespace kte
|
||||||
@@ -94,6 +94,9 @@ TerminalFrontend::Step(Editor &ed, bool &running)
|
|||||||
}
|
}
|
||||||
ed.SetDimensions(static_cast<std::size_t>(r), static_cast<std::size_t>(c));
|
ed.SetDimensions(static_cast<std::size_t>(r), static_cast<std::size_t>(c));
|
||||||
|
|
||||||
|
// Allow deferred opens (including swap recovery prompts) to run.
|
||||||
|
ed.ProcessPendingOpens();
|
||||||
|
|
||||||
MappedInput mi;
|
MappedInput mi;
|
||||||
if (input_.Poll(mi)) {
|
if (input_.Poll(mi)) {
|
||||||
if (mi.hasCommand) {
|
if (mi.hasCommand) {
|
||||||
@@ -123,4 +126,4 @@ TerminalFrontend::Shutdown()
|
|||||||
have_old_sigint_ = false;
|
have_old_sigint_ = false;
|
||||||
}
|
}
|
||||||
endwin();
|
endwin();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,4 +38,4 @@ private:
|
|||||||
// Saved SIGINT handler to restore on shutdown
|
// Saved SIGINT handler to restore on shutdown
|
||||||
bool have_old_sigint_ = false;
|
bool have_old_sigint_ = false;
|
||||||
struct sigaction old_sigint_{};
|
struct sigaction old_sigint_{};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#include "TerminalInputHandler.h"
|
#include "TerminalInputHandler.h"
|
||||||
#include "KKeymap.h"
|
#include "KKeymap.h"
|
||||||
|
#include "Command.h"
|
||||||
#include "Editor.h"
|
#include "Editor.h"
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -23,6 +24,7 @@ map_key_to_command(const int ch,
|
|||||||
bool &k_prefix,
|
bool &k_prefix,
|
||||||
bool &esc_meta,
|
bool &esc_meta,
|
||||||
bool &k_ctrl_pending,
|
bool &k_ctrl_pending,
|
||||||
|
bool &mouse_selecting,
|
||||||
Editor *ed,
|
Editor *ed,
|
||||||
MappedInput &out)
|
MappedInput &out)
|
||||||
{
|
{
|
||||||
@@ -54,12 +56,33 @@ map_key_to_command(const int ch,
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
// React to left button click/press
|
// React to left button click/press
|
||||||
if (ev.bstate & (BUTTON1_CLICKED | BUTTON1_PRESSED | BUTTON1_RELEASED)) {
|
if (ed && (ev.bstate & (BUTTON1_CLICKED | BUTTON1_PRESSED | BUTTON1_RELEASED |
|
||||||
|
REPORT_MOUSE_POSITION))) {
|
||||||
char buf[64];
|
char buf[64];
|
||||||
// Use screen coordinates; command handler will translate via offsets
|
// Use screen coordinates; command handler will translate via offsets
|
||||||
std::snprintf(buf, sizeof(buf), "@%d:%d", ev.y, ev.x);
|
std::snprintf(buf, sizeof(buf), "@%d:%d", ev.y, ev.x);
|
||||||
out = {true, CommandId::MoveCursorTo, std::string(buf), 0};
|
const bool pressed = (ev.bstate & (BUTTON1_PRESSED | BUTTON1_CLICKED)) != 0;
|
||||||
return true;
|
const bool released = (ev.bstate & BUTTON1_RELEASED) != 0;
|
||||||
|
const bool moved = (ev.bstate & REPORT_MOUSE_POSITION) != 0;
|
||||||
|
if (pressed) {
|
||||||
|
mouse_selecting = true;
|
||||||
|
Execute(*ed, CommandId::MoveCursorTo, std::string(buf));
|
||||||
|
if (Buffer *b = ed->CurrentBuffer()) {
|
||||||
|
b->SetMark(b->Curx(), b->Cury());
|
||||||
|
}
|
||||||
|
out.hasCommand = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (mouse_selecting && moved) {
|
||||||
|
Execute(*ed, CommandId::MoveCursorTo, std::string(buf));
|
||||||
|
out.hasCommand = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (released) {
|
||||||
|
mouse_selecting = false;
|
||||||
|
out.hasCommand = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// No actionable mouse event
|
// No actionable mouse event
|
||||||
@@ -178,15 +201,15 @@ map_key_to_command(const int ch,
|
|||||||
ctrl = true;
|
ctrl = true;
|
||||||
ascii_key = 'a' + (ch - 1);
|
ascii_key = 'a' + (ch - 1);
|
||||||
}
|
}
|
||||||
// If user typed literal 'C' or '^' as a qualifier, keep k-prefix and set pending
|
// If user typed literal 'C' or '^' as a qualifier, keep k-prefix and set pending
|
||||||
// Note: Do NOT treat lowercase 'c' as a qualifier, since 'c' is a valid C-k command (BufferClose).
|
// Note: Do NOT treat lowercase 'c' as a qualifier, since 'c' is a valid C-k command (BufferClose).
|
||||||
if (ascii_key == 'C' || ascii_key == '^') {
|
if (ascii_key == 'C' || ascii_key == '^') {
|
||||||
k_ctrl_pending = true;
|
k_ctrl_pending = true;
|
||||||
if (ed)
|
if (ed)
|
||||||
ed->SetStatus("C-k C _");
|
ed->SetStatus("C-k C _");
|
||||||
out.hasCommand = false;
|
out.hasCommand = false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// For actual suffix, consume the k-prefix
|
// For actual suffix, consume the k-prefix
|
||||||
k_prefix = false;
|
k_prefix = false;
|
||||||
// Do NOT lowercase here; KLookupKCommand handles case-sensitive bindings
|
// Do NOT lowercase here; KLookupKCommand handles case-sensitive bindings
|
||||||
@@ -292,6 +315,7 @@ TerminalInputHandler::decode_(MappedInput &out)
|
|||||||
ch,
|
ch,
|
||||||
k_prefix_, esc_meta_,
|
k_prefix_, esc_meta_,
|
||||||
k_ctrl_pending_,
|
k_ctrl_pending_,
|
||||||
|
mouse_selecting_,
|
||||||
ed_,
|
ed_,
|
||||||
out);
|
out);
|
||||||
if (!consumed)
|
if (!consumed)
|
||||||
@@ -305,4 +329,4 @@ TerminalInputHandler::Poll(MappedInput &out)
|
|||||||
{
|
{
|
||||||
out = {};
|
out = {};
|
||||||
return decode_(out) && out.hasCommand;
|
return decode_(out) && out.hasCommand;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,5 +30,8 @@ private:
|
|||||||
// Simple meta (ESC) state for ESC sequences like ESC b/f
|
// Simple meta (ESC) state for ESC sequences like ESC b/f
|
||||||
bool esc_meta_ = false;
|
bool esc_meta_ = false;
|
||||||
|
|
||||||
|
// Mouse drag selection state
|
||||||
|
bool mouse_selecting_ = false;
|
||||||
|
|
||||||
Editor *ed_ = nullptr; // attached editor for uarg handling
|
Editor *ed_ = nullptr; // attached editor for uarg handling
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -107,13 +107,82 @@ TerminalRenderer::Draw(Editor &ed)
|
|||||||
const std::size_t cur_mx = has_current ? ed.SearchMatchX() : 0;
|
const std::size_t cur_mx = has_current ? ed.SearchMatchX() : 0;
|
||||||
const std::size_t cur_my = has_current ? ed.SearchMatchY() : 0;
|
const std::size_t cur_my = has_current ? ed.SearchMatchY() : 0;
|
||||||
const std::size_t cur_mend = has_current ? (ed.SearchMatchX() + ed.SearchMatchLen()) : 0;
|
const std::size_t cur_mend = has_current ? (ed.SearchMatchX() + ed.SearchMatchLen()) : 0;
|
||||||
bool hl_on = false;
|
|
||||||
bool cur_on = false;
|
// Mark selection (mark -> cursor), in source coordinates
|
||||||
int written = 0;
|
bool sel_active = false;
|
||||||
|
std::size_t sel_sy = 0, sel_sx = 0, sel_ey = 0, sel_ex = 0;
|
||||||
|
if (buf->MarkSet()) {
|
||||||
|
sel_sy = buf->MarkCury();
|
||||||
|
sel_sx = buf->MarkCurx();
|
||||||
|
sel_ey = buf->Cury();
|
||||||
|
sel_ex = buf->Curx();
|
||||||
|
if (sel_sy > sel_ey || (sel_sy == sel_ey && sel_sx > sel_ex)) {
|
||||||
|
std::swap(sel_sy, sel_ey);
|
||||||
|
std::swap(sel_sx, sel_ex);
|
||||||
|
}
|
||||||
|
sel_active = !(sel_sy == sel_ey && sel_sx == sel_ex);
|
||||||
|
}
|
||||||
|
// Visual-line selection: full-line selection range
|
||||||
|
const bool vsel_active = buf->VisualLineActive();
|
||||||
|
const std::size_t vsel_sy = vsel_active ? buf->VisualLineStartY() : 0;
|
||||||
|
const std::size_t vsel_ey = vsel_active ? buf->VisualLineEndY() : 0;
|
||||||
|
auto is_src_in_mark_sel = [&](std::size_t y, std::size_t sx) -> bool {
|
||||||
|
if (!sel_active)
|
||||||
|
return false;
|
||||||
|
if (y < sel_sy || y > sel_ey)
|
||||||
|
return false;
|
||||||
|
if (sel_sy == sel_ey)
|
||||||
|
return sx >= sel_sx && sx < sel_ex;
|
||||||
|
if (y == sel_sy)
|
||||||
|
return sx >= sel_sx;
|
||||||
|
if (y == sel_ey)
|
||||||
|
return sx < sel_ex;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
int written = 0;
|
||||||
if (li < lines.size()) {
|
if (li < lines.size()) {
|
||||||
std::string line = static_cast<std::string>(lines[li]);
|
std::string line = static_cast<std::string>(lines[li]);
|
||||||
src_i = 0;
|
const bool vsel_on_line = vsel_active && li >= vsel_sy && li <= vsel_ey;
|
||||||
render_col = 0;
|
const std::size_t vsel_spot_src = vsel_on_line
|
||||||
|
? std::min(buf->Curx(), line.size())
|
||||||
|
: 0;
|
||||||
|
const bool vsel_spot_is_eol = vsel_on_line && vsel_spot_src == line.size();
|
||||||
|
std::size_t vsel_line_rx = 0;
|
||||||
|
if (vsel_spot_is_eol) {
|
||||||
|
// Compute the rendered (column) width of the line so we can highlight a
|
||||||
|
// single cell at EOL when the spot falls beyond the last character.
|
||||||
|
std::size_t rc = 0;
|
||||||
|
std::size_t si = 0;
|
||||||
|
while (si < line.size()) {
|
||||||
|
wchar_t wch = L' ';
|
||||||
|
int wch_len = 1;
|
||||||
|
std::mbstate_t state = std::mbstate_t();
|
||||||
|
size_t res = std::mbrtowc(&wch, &line[si], line.size() - si, &state);
|
||||||
|
if (res == (size_t) -1 || res == (size_t) -2) {
|
||||||
|
wch = static_cast<unsigned char>(line[si]);
|
||||||
|
wch_len = 1;
|
||||||
|
} else if (res == 0) {
|
||||||
|
wch = L'\0';
|
||||||
|
wch_len = 1;
|
||||||
|
} else {
|
||||||
|
wch_len = static_cast<int>(res);
|
||||||
|
}
|
||||||
|
if (wch == L'\t') {
|
||||||
|
constexpr std::size_t tab_width = 8;
|
||||||
|
const std::size_t next_tab = tab_width - (rc % tab_width);
|
||||||
|
rc += next_tab;
|
||||||
|
} else {
|
||||||
|
int w = wcwidth(wch);
|
||||||
|
if (w < 0)
|
||||||
|
w = 1;
|
||||||
|
rc += static_cast<std::size_t>(w);
|
||||||
|
}
|
||||||
|
si += static_cast<std::size_t>(wch_len);
|
||||||
|
}
|
||||||
|
vsel_line_rx = rc;
|
||||||
|
}
|
||||||
|
src_i = 0;
|
||||||
|
render_col = 0;
|
||||||
// Syntax highlighting: fetch per-line spans (sanitized copy)
|
// Syntax highlighting: fetch per-line spans (sanitized copy)
|
||||||
std::vector<kte::HighlightSpan> sane_spans;
|
std::vector<kte::HighlightSpan> sane_spans;
|
||||||
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->
|
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->
|
||||||
@@ -156,27 +225,21 @@ TerminalRenderer::Draw(Editor &ed)
|
|||||||
}
|
}
|
||||||
return kte::TokenKind::Default;
|
return kte::TokenKind::Default;
|
||||||
};
|
};
|
||||||
auto apply_token_attr = [&](kte::TokenKind k) {
|
auto token_attr = [&](kte::TokenKind k) -> attr_t {
|
||||||
// Map to simple attributes; search highlight uses A_STANDOUT which takes precedence below
|
|
||||||
attrset(A_NORMAL);
|
|
||||||
switch (k) {
|
switch (k) {
|
||||||
case kte::TokenKind::Keyword:
|
case kte::TokenKind::Keyword:
|
||||||
case kte::TokenKind::Type:
|
case kte::TokenKind::Type:
|
||||||
case kte::TokenKind::Constant:
|
case kte::TokenKind::Constant:
|
||||||
case kte::TokenKind::Function:
|
case kte::TokenKind::Function:
|
||||||
attron(A_BOLD);
|
return A_BOLD;
|
||||||
break;
|
|
||||||
case kte::TokenKind::Comment:
|
case kte::TokenKind::Comment:
|
||||||
attron(A_DIM);
|
return A_DIM;
|
||||||
break;
|
|
||||||
case kte::TokenKind::String:
|
case kte::TokenKind::String:
|
||||||
case kte::TokenKind::Char:
|
case kte::TokenKind::Char:
|
||||||
case kte::TokenKind::Number:
|
case kte::TokenKind::Number:
|
||||||
// standout a bit using A_UNDERLINE if available
|
return A_UNDERLINE;
|
||||||
attron(A_UNDERLINE);
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
break;
|
return 0;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
while (written < cols) {
|
while (written < cols) {
|
||||||
@@ -218,36 +281,27 @@ TerminalRenderer::Draw(Editor &ed)
|
|||||||
}
|
}
|
||||||
// Now render visible spaces
|
// Now render visible spaces
|
||||||
while (next_tab > 0 && written < cols) {
|
while (next_tab > 0 && written < cols) {
|
||||||
|
bool in_mark = is_src_in_mark_sel(li, src_i);
|
||||||
|
bool in_vsel =
|
||||||
|
vsel_on_line && !vsel_spot_is_eol && src_i ==
|
||||||
|
vsel_spot_src;
|
||||||
|
bool in_sel = in_mark || in_vsel;
|
||||||
bool in_hl = search_mode && is_src_in_hl(src_i);
|
bool in_hl = search_mode && is_src_in_hl(src_i);
|
||||||
bool in_cur =
|
bool in_cur =
|
||||||
has_current && li == cur_my && src_i >= cur_mx
|
has_current && li == cur_my && src_i >= cur_mx
|
||||||
&& src_i < cur_mend;
|
&&
|
||||||
// Toggle highlight attributes
|
src_i < cur_mend;
|
||||||
int attr = 0;
|
attr_t a = A_NORMAL;
|
||||||
if (in_hl)
|
a |= token_attr(token_at(src_i));
|
||||||
attr |= A_STANDOUT;
|
if (in_sel) {
|
||||||
if (in_cur)
|
a |= A_REVERSE;
|
||||||
attr |= A_BOLD;
|
} else {
|
||||||
if ((attr & A_STANDOUT) && !hl_on) {
|
if (in_hl)
|
||||||
attron(A_STANDOUT);
|
a |= A_STANDOUT;
|
||||||
hl_on = true;
|
if (in_cur)
|
||||||
}
|
a |= A_BOLD;
|
||||||
if (!(attr & A_STANDOUT) && hl_on) {
|
|
||||||
attroff(A_STANDOUT);
|
|
||||||
hl_on = false;
|
|
||||||
}
|
|
||||||
if ((attr & A_BOLD) && !cur_on) {
|
|
||||||
attron(A_BOLD);
|
|
||||||
cur_on = true;
|
|
||||||
}
|
|
||||||
if (!(attr & A_BOLD) && cur_on) {
|
|
||||||
attroff(A_BOLD);
|
|
||||||
cur_on = false;
|
|
||||||
}
|
|
||||||
// Apply syntax attribute only if not in search highlight
|
|
||||||
if (!in_hl) {
|
|
||||||
apply_token_attr(token_at(src_i));
|
|
||||||
}
|
}
|
||||||
|
attrset(a);
|
||||||
addch(' ');
|
addch(' ');
|
||||||
++written;
|
++written;
|
||||||
++render_col;
|
++render_col;
|
||||||
@@ -281,34 +335,36 @@ TerminalRenderer::Draw(Editor &ed)
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool in_mark = from_src && is_src_in_mark_sel(li, src_i);
|
||||||
|
bool in_vsel = false;
|
||||||
|
if (vsel_on_line) {
|
||||||
|
if (from_src) {
|
||||||
|
in_vsel = !vsel_spot_is_eol && src_i == vsel_spot_src;
|
||||||
|
} else {
|
||||||
|
in_vsel = vsel_spot_is_eol && render_col == vsel_line_rx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bool in_sel = in_mark || in_vsel;
|
||||||
bool in_hl = search_mode && from_src && is_src_in_hl(src_i);
|
bool in_hl = search_mode && from_src && is_src_in_hl(src_i);
|
||||||
bool in_cur =
|
bool in_cur = has_current && li == cur_my && from_src && src_i >= cur_mx &&
|
||||||
has_current && li == cur_my && from_src && src_i >= cur_mx && src_i <
|
src_i < cur_mend;
|
||||||
cur_mend;
|
attr_t a = A_NORMAL;
|
||||||
if (in_hl && !hl_on) {
|
if (from_src)
|
||||||
attron(A_STANDOUT);
|
a |= token_attr(token_at(src_i));
|
||||||
hl_on = true;
|
if (in_sel) {
|
||||||
}
|
a |= A_REVERSE;
|
||||||
if (!in_hl && hl_on) {
|
} else {
|
||||||
attroff(A_STANDOUT);
|
if (in_hl)
|
||||||
hl_on = false;
|
a |= A_STANDOUT;
|
||||||
}
|
if (in_cur)
|
||||||
if (in_cur && !cur_on) {
|
a |= A_BOLD;
|
||||||
attron(A_BOLD);
|
|
||||||
cur_on = true;
|
|
||||||
}
|
|
||||||
if (!in_cur && cur_on) {
|
|
||||||
attroff(A_BOLD);
|
|
||||||
cur_on = false;
|
|
||||||
}
|
|
||||||
if (!in_hl && from_src) {
|
|
||||||
apply_token_attr(token_at(src_i));
|
|
||||||
}
|
}
|
||||||
|
attrset(a);
|
||||||
|
|
||||||
if (from_src) {
|
if (from_src) {
|
||||||
cchar_t cch;
|
cchar_t cch;
|
||||||
wchar_t warr[2] = {wch, L'\0'};
|
wchar_t warr[2] = {wch, L'\0'};
|
||||||
setcchar(&cch, warr, A_NORMAL, 0, nullptr);
|
setcchar(&cch, warr, 0, 0, nullptr);
|
||||||
add_wch(&cch);
|
add_wch(&cch);
|
||||||
} else {
|
} else {
|
||||||
addch(' ');
|
addch(' ');
|
||||||
@@ -322,14 +378,6 @@ TerminalRenderer::Draw(Editor &ed)
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (hl_on) {
|
|
||||||
attroff(A_STANDOUT);
|
|
||||||
hl_on = false;
|
|
||||||
}
|
|
||||||
if (cur_on) {
|
|
||||||
attroff(A_BOLD);
|
|
||||||
cur_on = false;
|
|
||||||
}
|
|
||||||
attrset(A_NORMAL);
|
attrset(A_NORMAL);
|
||||||
clrtoeol();
|
clrtoeol();
|
||||||
}
|
}
|
||||||
@@ -567,4 +615,4 @@ TerminalRenderer::Draw(Editor &ed)
|
|||||||
}
|
}
|
||||||
|
|
||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,4 +12,4 @@ public:
|
|||||||
~TerminalRenderer() override;
|
~TerminalRenderer() override;
|
||||||
|
|
||||||
void Draw(Editor &ed) override;
|
void Draw(Editor &ed) override;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ TestFrontend::Init(int &argc, char **argv, Editor &ed)
|
|||||||
void
|
void
|
||||||
TestFrontend::Step(Editor &ed, bool &running)
|
TestFrontend::Step(Editor &ed, bool &running)
|
||||||
{
|
{
|
||||||
|
// Allow deferred opens (including swap recovery prompts) to run.
|
||||||
|
ed.ProcessPendingOpens();
|
||||||
|
|
||||||
MappedInput mi;
|
MappedInput mi;
|
||||||
if (input_.Poll(mi)) {
|
if (input_.Poll(mi)) {
|
||||||
if (mi.hasCommand) {
|
if (mi.hasCommand) {
|
||||||
@@ -32,4 +35,4 @@ TestFrontend::Step(Editor &ed, bool &running)
|
|||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
TestFrontend::Shutdown() {}
|
TestFrontend::Shutdown() {}
|
||||||
|
|||||||
@@ -34,4 +34,4 @@ public:
|
|||||||
private:
|
private:
|
||||||
TestInputHandler input_{};
|
TestInputHandler input_{};
|
||||||
TestRenderer renderer_{};
|
TestRenderer renderer_{};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,4 +27,4 @@ public:
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
std::queue<MappedInput> queue_;
|
std::queue<MappedInput> queue_;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,4 +29,4 @@ public:
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
std::size_t draw_count_ = 0;
|
std::size_t draw_count_ = 0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ struct UndoNode {
|
|||||||
UndoType type{};
|
UndoType type{};
|
||||||
int row{};
|
int row{};
|
||||||
int col{};
|
int col{};
|
||||||
|
std::uint64_t group_id = 0; // 0 means ungrouped; non-zero means undo/redo as an atomic group
|
||||||
std::string text;
|
std::string text;
|
||||||
UndoNode *child = nullptr; // next in current timeline
|
UndoNode *parent = nullptr; // previous state; null means pre-first-edit
|
||||||
UndoNode *next = nullptr; // redo branch
|
UndoNode *child = nullptr; // next in current timeline
|
||||||
};
|
UndoNode *next = nullptr; // redo branch
|
||||||
|
};
|
||||||
|
|||||||
@@ -20,10 +20,11 @@ public:
|
|||||||
available_.pop();
|
available_.pop();
|
||||||
// Node comes zeroed; ensure links are reset
|
// Node comes zeroed; ensure links are reset
|
||||||
node->text.clear();
|
node->text.clear();
|
||||||
node->child = nullptr;
|
node->parent = nullptr;
|
||||||
node->next = nullptr;
|
node->child = nullptr;
|
||||||
node->row = node->col = 0;
|
node->next = nullptr;
|
||||||
node->type = UndoType{};
|
node->row = node->col = 0;
|
||||||
|
node->type = UndoType{};
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,10 +35,11 @@ public:
|
|||||||
return;
|
return;
|
||||||
// Clear heavy fields to free memory held by strings
|
// Clear heavy fields to free memory held by strings
|
||||||
node->text.clear();
|
node->text.clear();
|
||||||
node->child = nullptr;
|
node->parent = nullptr;
|
||||||
node->next = nullptr;
|
node->child = nullptr;
|
||||||
node->row = node->col = 0;
|
node->next = nullptr;
|
||||||
node->type = UndoType{};
|
node->row = node->col = 0;
|
||||||
|
node->type = UndoType{};
|
||||||
available_.push(node);
|
available_.push(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,4 +60,4 @@ private:
|
|||||||
std::size_t block_size_;
|
std::size_t block_size_;
|
||||||
std::vector<std::unique_ptr<UndoNode[]> > blocks_;
|
std::vector<std::unique_ptr<UndoNode[]> > blocks_;
|
||||||
std::stack<UndoNode *> available_;
|
std::stack<UndoNode *> available_;
|
||||||
};
|
};
|
||||||
|
|||||||
233
UndoSystem.cc
233
UndoSystem.cc
@@ -8,69 +8,262 @@ UndoSystem::UndoSystem(Buffer &owner, UndoTree &tree)
|
|||||||
: buf_(&owner), tree_(tree) {}
|
: buf_(&owner), tree_(tree) {}
|
||||||
|
|
||||||
|
|
||||||
|
std::uint64_t
|
||||||
|
UndoSystem::BeginGroup()
|
||||||
|
{
|
||||||
|
// Ensure any pending typed run is sealed so the group is a distinct undo step.
|
||||||
|
commit();
|
||||||
|
if (active_group_id_ == 0)
|
||||||
|
active_group_id_ = next_group_id_++;
|
||||||
|
return active_group_id_;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
void
|
||||||
|
UndoSystem::EndGroup()
|
||||||
|
{
|
||||||
|
commit();
|
||||||
|
active_group_id_ = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
UndoSystem::Begin(UndoType type)
|
UndoSystem::Begin(UndoType type)
|
||||||
{
|
{
|
||||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
if (!buf_)
|
||||||
(void) type;
|
return;
|
||||||
|
const int row = static_cast<int>(buf_->Cury());
|
||||||
|
const int col = static_cast<int>(buf_->Curx());
|
||||||
|
|
||||||
|
// Some operations should always be standalone undo steps.
|
||||||
|
const bool always_standalone = (type == UndoType::Newline || type == UndoType::DeleteRow);
|
||||||
|
if (always_standalone) {
|
||||||
|
commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tree_.pending) {
|
||||||
|
if (tree_.pending->type == type) {
|
||||||
|
// Typed-run coalescing rules.
|
||||||
|
switch (type) {
|
||||||
|
case UndoType::Insert:
|
||||||
|
case UndoType::Paste: {
|
||||||
|
// Cursor must be at the end of the pending insert.
|
||||||
|
if (tree_.pending->row == row
|
||||||
|
&& col == tree_.pending->col + static_cast<int>(tree_.pending->text.size())) {
|
||||||
|
pending_mode_ = PendingAppendMode::Append;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case UndoType::Delete: {
|
||||||
|
if (tree_.pending->row == row) {
|
||||||
|
// Two common delete shapes:
|
||||||
|
// 1) backspace-run: cursor moves left each time (so new col is pending.col - 1)
|
||||||
|
// 2) delete-run: cursor stays, always deleting at the same col
|
||||||
|
if (col == tree_.pending->col) {
|
||||||
|
pending_mode_ = PendingAppendMode::Append;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (col + 1 == tree_.pending->col) {
|
||||||
|
// Extend a backspace run to the left; update the start column now.
|
||||||
|
tree_.pending->col = col;
|
||||||
|
pending_mode_ = PendingAppendMode::Prepend;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case UndoType::Newline:
|
||||||
|
case UndoType::DeleteRow:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Can't coalesce: seal the previous pending step.
|
||||||
|
commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start a new pending node.
|
||||||
|
tree_.pending = new UndoNode{};
|
||||||
|
tree_.pending->type = type;
|
||||||
|
tree_.pending->row = row;
|
||||||
|
tree_.pending->col = col;
|
||||||
|
tree_.pending->group_id = active_group_id_;
|
||||||
|
tree_.pending->text.clear();
|
||||||
|
tree_.pending->parent = nullptr;
|
||||||
|
tree_.pending->child = nullptr;
|
||||||
|
tree_.pending->next = nullptr;
|
||||||
|
pending_mode_ = PendingAppendMode::Append;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
UndoSystem::Append(char ch)
|
UndoSystem::Append(char ch)
|
||||||
{
|
{
|
||||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
if (!tree_.pending)
|
||||||
(void) ch;
|
return;
|
||||||
|
if (pending_mode_ == PendingAppendMode::Prepend) {
|
||||||
|
tree_.pending->text.insert(tree_.pending->text.begin(), ch);
|
||||||
|
} else {
|
||||||
|
tree_.pending->text.push_back(ch);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
UndoSystem::Append(std::string_view text)
|
UndoSystem::Append(std::string_view text)
|
||||||
{
|
{
|
||||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
if (!tree_.pending)
|
||||||
(void) text;
|
return;
|
||||||
|
if (text.empty())
|
||||||
|
return;
|
||||||
|
if (pending_mode_ == PendingAppendMode::Prepend) {
|
||||||
|
tree_.pending->text.insert(0, text.data(), text.size());
|
||||||
|
} else {
|
||||||
|
tree_.pending->text.append(text.data(), text.size());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
UndoSystem::commit()
|
UndoSystem::commit()
|
||||||
{
|
{
|
||||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
if (!tree_.pending)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Drop empty text batches for text-based operations.
|
||||||
|
if ((tree_.pending->type == UndoType::Insert || tree_.pending->type == UndoType::Delete
|
||||||
|
|| tree_.pending->type == UndoType::Paste)
|
||||||
|
&& tree_.pending->text.empty()) {
|
||||||
|
delete tree_.pending;
|
||||||
|
tree_.pending = nullptr;
|
||||||
|
pending_mode_ = PendingAppendMode::Append;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tree_.root) {
|
||||||
|
tree_.root = tree_.pending;
|
||||||
|
tree_.pending->parent = nullptr;
|
||||||
|
tree_.current = tree_.pending;
|
||||||
|
} else if (!tree_.current) {
|
||||||
|
// We are at the "pre-first-edit" state (undo past the first node).
|
||||||
|
// In branching history, preserve the existing root chain as an alternate branch.
|
||||||
|
tree_.pending->parent = nullptr;
|
||||||
|
tree_.pending->next = tree_.root;
|
||||||
|
tree_.root = tree_.pending;
|
||||||
|
tree_.current = tree_.pending;
|
||||||
|
} else {
|
||||||
|
// Branching semantics: attach as a new redo branch under current.
|
||||||
|
// Make the new edit the active child by inserting it at the head.
|
||||||
|
tree_.pending->parent = tree_.current;
|
||||||
|
if (!tree_.current->child) {
|
||||||
|
tree_.current->child = tree_.pending;
|
||||||
|
} else {
|
||||||
|
tree_.pending->next = tree_.current->child;
|
||||||
|
tree_.current->child = tree_.pending;
|
||||||
|
}
|
||||||
|
tree_.current = tree_.pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
tree_.pending = nullptr;
|
||||||
|
pending_mode_ = PendingAppendMode::Append;
|
||||||
|
update_dirty_flag();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
UndoSystem::undo()
|
UndoSystem::undo()
|
||||||
{
|
{
|
||||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
// Seal any in-progress typed run before undo.
|
||||||
|
commit();
|
||||||
|
if (!tree_.current)
|
||||||
|
return;
|
||||||
|
debug_log("undo");
|
||||||
|
const std::uint64_t gid = tree_.current->group_id;
|
||||||
|
do {
|
||||||
|
UndoNode *node = tree_.current;
|
||||||
|
apply(node, -1);
|
||||||
|
tree_.current = node->parent;
|
||||||
|
} while (gid != 0 && tree_.current && tree_.current->group_id == gid);
|
||||||
|
update_dirty_flag();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
UndoSystem::redo()
|
UndoSystem::redo(int branch_index)
|
||||||
{
|
{
|
||||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
commit();
|
||||||
|
UndoNode **head = nullptr;
|
||||||
|
if (!tree_.current) {
|
||||||
|
head = &tree_.root;
|
||||||
|
} else {
|
||||||
|
head = &tree_.current->child;
|
||||||
|
}
|
||||||
|
if (!head || !*head)
|
||||||
|
return;
|
||||||
|
if (branch_index < 0)
|
||||||
|
branch_index = 0;
|
||||||
|
|
||||||
|
// Select the Nth sibling from the branch list and make it the active head.
|
||||||
|
UndoNode *prev = nullptr;
|
||||||
|
UndoNode *sel = *head;
|
||||||
|
for (int i = 0; i < branch_index && sel; ++i) {
|
||||||
|
prev = sel;
|
||||||
|
sel = sel->next;
|
||||||
|
}
|
||||||
|
if (!sel)
|
||||||
|
return;
|
||||||
|
if (prev) {
|
||||||
|
prev->next = sel->next;
|
||||||
|
sel->next = *head;
|
||||||
|
*head = sel;
|
||||||
|
}
|
||||||
|
|
||||||
|
debug_log("redo");
|
||||||
|
UndoNode *node = *head;
|
||||||
|
const std::uint64_t gid = node->group_id;
|
||||||
|
apply(node, +1);
|
||||||
|
tree_.current = node;
|
||||||
|
while (gid != 0 && tree_.current && tree_.current->child
|
||||||
|
&& tree_.current->child->group_id == gid) {
|
||||||
|
UndoNode *child = tree_.current->child;
|
||||||
|
apply(child, +1);
|
||||||
|
tree_.current = child;
|
||||||
|
}
|
||||||
|
update_dirty_flag();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
UndoSystem::mark_saved()
|
UndoSystem::mark_saved()
|
||||||
{
|
{
|
||||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
commit();
|
||||||
|
tree_.saved = tree_.current;
|
||||||
|
update_dirty_flag();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
UndoSystem::discard_pending()
|
UndoSystem::discard_pending()
|
||||||
{
|
{
|
||||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
if (tree_.pending) {
|
||||||
|
delete tree_.pending;
|
||||||
|
tree_.pending = nullptr;
|
||||||
|
}
|
||||||
|
pending_mode_ = PendingAppendMode::Append;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
void
|
void
|
||||||
UndoSystem::clear()
|
UndoSystem::clear()
|
||||||
{
|
{
|
||||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
discard_pending();
|
||||||
|
free_node(tree_.root);
|
||||||
|
tree_.root = nullptr;
|
||||||
|
tree_.current = nullptr;
|
||||||
|
tree_.saved = nullptr;
|
||||||
|
active_group_id_ = 0;
|
||||||
|
next_group_id_ = 1;
|
||||||
|
update_dirty_flag();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -79,34 +272,46 @@ UndoSystem::apply(const UndoNode *node, int direction)
|
|||||||
{
|
{
|
||||||
if (!node)
|
if (!node)
|
||||||
return;
|
return;
|
||||||
|
// Cursor positioning: keep the point at a sensible location after undo/redo.
|
||||||
|
// Low-level Buffer edit primitives do not move the cursor.
|
||||||
switch (node->type) {
|
switch (node->type) {
|
||||||
case UndoType::Insert:
|
case UndoType::Insert:
|
||||||
case UndoType::Paste:
|
case UndoType::Paste:
|
||||||
if (direction > 0) {
|
if (direction > 0) {
|
||||||
buf_->insert_text(node->row, node->col, node->text);
|
buf_->insert_text(node->row, node->col, node->text);
|
||||||
|
buf_->SetCursor(static_cast<std::size_t>(node->col + node->text.size()),
|
||||||
|
static_cast<std::size_t>(node->row));
|
||||||
} else {
|
} else {
|
||||||
buf_->delete_text(node->row, node->col, node->text.size());
|
buf_->delete_text(node->row, node->col, node->text.size());
|
||||||
|
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case UndoType::Delete:
|
case UndoType::Delete:
|
||||||
if (direction > 0) {
|
if (direction > 0) {
|
||||||
buf_->delete_text(node->row, node->col, node->text.size());
|
buf_->delete_text(node->row, node->col, node->text.size());
|
||||||
|
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
||||||
} else {
|
} else {
|
||||||
buf_->insert_text(node->row, node->col, node->text);
|
buf_->insert_text(node->row, node->col, node->text);
|
||||||
|
buf_->SetCursor(static_cast<std::size_t>(node->col + node->text.size()),
|
||||||
|
static_cast<std::size_t>(node->row));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case UndoType::Newline:
|
case UndoType::Newline:
|
||||||
if (direction > 0) {
|
if (direction > 0) {
|
||||||
buf_->split_line(node->row, node->col);
|
buf_->split_line(node->row, node->col);
|
||||||
|
buf_->SetCursor(0, static_cast<std::size_t>(node->row + 1));
|
||||||
} else {
|
} else {
|
||||||
buf_->join_lines(node->row);
|
buf_->join_lines(node->row);
|
||||||
|
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case UndoType::DeleteRow:
|
case UndoType::DeleteRow:
|
||||||
if (direction > 0) {
|
if (direction > 0) {
|
||||||
buf_->delete_row(node->row);
|
buf_->delete_row(node->row);
|
||||||
|
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
|
||||||
} else {
|
} else {
|
||||||
buf_->insert_row(node->row, node->text);
|
buf_->insert_row(node->row, node->text);
|
||||||
|
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -247,4 +452,4 @@ UndoSystem::debug_log(const char *op) const
|
|||||||
#else
|
#else
|
||||||
(void) op;
|
(void) op;
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
70
UndoSystem.h
70
UndoSystem.h
@@ -1,3 +1,44 @@
|
|||||||
|
/*
|
||||||
|
* UndoSystem.h - undo/redo system with tree-based branching
|
||||||
|
*
|
||||||
|
* UndoSystem manages the undo/redo history for a Buffer. It provides:
|
||||||
|
*
|
||||||
|
* - Tree-based undo: Multiple redo branches at each node (not just linear history)
|
||||||
|
* - Atomic grouping: Multiple operations can be undone/redone as a single step
|
||||||
|
* - Dirty tracking: Marks when buffer matches last saved state
|
||||||
|
* - Efficient storage: Nodes stored in UndoTree, operations applied to Buffer
|
||||||
|
*
|
||||||
|
* Key concepts:
|
||||||
|
*
|
||||||
|
* 1. Undo tree structure:
|
||||||
|
* - Each edit creates a node in the tree
|
||||||
|
* - Undo moves up the tree (toward root)
|
||||||
|
* - Redo moves down the tree (toward leaves)
|
||||||
|
* - Multiple redo branches preserved (not lost on new edits after undo)
|
||||||
|
*
|
||||||
|
* 2. Operation lifecycle:
|
||||||
|
* - Begin(type): Start recording an operation (insert/delete)
|
||||||
|
* - Append(text): Add content to the pending operation
|
||||||
|
* - commit(): Finalize and add to undo tree
|
||||||
|
* - discard_pending(): Cancel without recording
|
||||||
|
*
|
||||||
|
* 3. Atomic grouping:
|
||||||
|
* - BeginGroup()/EndGroup(): Bracket multiple operations
|
||||||
|
* - All operations in a group share the same group_id
|
||||||
|
* - Undo/redo treats the entire group as one step
|
||||||
|
*
|
||||||
|
* 4. Integration with Buffer:
|
||||||
|
* - UndoSystem holds a reference to its owning Buffer
|
||||||
|
* - apply() executes undo/redo by calling Buffer's editing methods
|
||||||
|
* - Buffer's dirty flag updated automatically
|
||||||
|
*
|
||||||
|
* Usage pattern:
|
||||||
|
* undo_system.Begin(UndoType::Insert);
|
||||||
|
* undo_system.Append("text");
|
||||||
|
* undo_system.commit(); // Now undoable
|
||||||
|
*
|
||||||
|
* See also: UndoTree.h (storage), UndoNode.h (node structure)
|
||||||
|
*/
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
@@ -12,6 +53,12 @@ class UndoSystem {
|
|||||||
public:
|
public:
|
||||||
explicit UndoSystem(Buffer &owner, UndoTree &tree);
|
explicit UndoSystem(Buffer &owner, UndoTree &tree);
|
||||||
|
|
||||||
|
// Begin an atomic group: subsequent committed nodes with the same group_id will be
|
||||||
|
// undone/redone as a single step. Returns the active group id.
|
||||||
|
std::uint64_t BeginGroup();
|
||||||
|
|
||||||
|
void EndGroup();
|
||||||
|
|
||||||
void Begin(UndoType type);
|
void Begin(UndoType type);
|
||||||
|
|
||||||
void Append(char ch);
|
void Append(char ch);
|
||||||
@@ -22,7 +69,10 @@ public:
|
|||||||
|
|
||||||
void undo();
|
void undo();
|
||||||
|
|
||||||
void redo();
|
// Redo the current node's active child branch.
|
||||||
|
// If `branch_index` > 0, selects that redo sibling (0-based) and makes it active.
|
||||||
|
// When current is null (pre-first-edit), branches are selected among `tree_.root` siblings.
|
||||||
|
void redo(int branch_index = 0);
|
||||||
|
|
||||||
void mark_saved();
|
void mark_saved();
|
||||||
|
|
||||||
@@ -32,7 +82,20 @@ public:
|
|||||||
|
|
||||||
void UpdateBufferReference(Buffer &new_buf);
|
void UpdateBufferReference(Buffer &new_buf);
|
||||||
|
|
||||||
|
#if defined(KTE_TESTS)
|
||||||
|
// Test-only introspection hook.
|
||||||
|
const UndoTree &TreeForTests() const
|
||||||
|
{
|
||||||
|
return tree_;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
enum class PendingAppendMode : std::uint8_t {
|
||||||
|
Append,
|
||||||
|
Prepend,
|
||||||
|
};
|
||||||
|
|
||||||
void apply(const UndoNode *node, int direction); // +1 redo, -1 undo
|
void apply(const UndoNode *node, int direction); // +1 redo, -1 undo
|
||||||
void free_node(UndoNode *node);
|
void free_node(UndoNode *node);
|
||||||
|
|
||||||
@@ -48,6 +111,11 @@ private:
|
|||||||
|
|
||||||
void update_dirty_flag();
|
void update_dirty_flag();
|
||||||
|
|
||||||
|
PendingAppendMode pending_mode_ = PendingAppendMode::Append;
|
||||||
|
|
||||||
|
std::uint64_t active_group_id_ = 0;
|
||||||
|
std::uint64_t next_group_id_ = 1;
|
||||||
|
|
||||||
Buffer *buf_;
|
Buffer *buf_;
|
||||||
UndoTree &tree_;
|
UndoTree &tree_;
|
||||||
};
|
};
|
||||||
@@ -7,4 +7,4 @@ struct UndoTree {
|
|||||||
UndoNode *current = nullptr; // current state of buffer
|
UndoNode *current = nullptr; // current state of buffer
|
||||||
UndoNode *saved = nullptr; // points to node matching last save (for dirty flag)
|
UndoNode *saved = nullptr; // points to node matching last save (for dirty flag)
|
||||||
UndoNode *pending = nullptr; // in-progress batch (detached)
|
UndoNode *pending = nullptr; // in-progress batch (detached)
|
||||||
};
|
};
|
||||||
|
|||||||
28
docker-build.sh
Executable file
28
docker-build.sh
Executable file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Helper script to test Linux builds using Docker/Podman
|
||||||
|
# This script mounts the current source tree into a Linux container,
|
||||||
|
# builds kte in terminal-only mode, and runs the test suite.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Detect whether to use docker or podman
|
||||||
|
if command -v docker &> /dev/null; then
|
||||||
|
CONTAINER_CMD="docker"
|
||||||
|
elif command -v podman &> /dev/null; then
|
||||||
|
CONTAINER_CMD="podman"
|
||||||
|
else
|
||||||
|
echo "Error: Neither docker nor podman found in PATH"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
IMAGE_NAME="kte-linux"
|
||||||
|
|
||||||
|
# Check if image exists, if not, build it
|
||||||
|
if ! $CONTAINER_CMD image inspect "$IMAGE_NAME" &> /dev/null; then
|
||||||
|
echo "Building $IMAGE_NAME image..."
|
||||||
|
$CONTAINER_CMD build -t "$IMAGE_NAME" .
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run the container with the current directory mounted
|
||||||
|
echo "Running Linux build and tests..."
|
||||||
|
$CONTAINER_CMD run --rm -v "$(pwd):/kte" "$IMAGE_NAME"
|
||||||
245
docs/BENCHMARKS.md
Normal file
245
docs/BENCHMARKS.md
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
# kte Benchmarking and Testing Guide
|
||||||
|
|
||||||
|
This document describes the benchmarking infrastructure and testing
|
||||||
|
improvements added to ensure high performance and correctness of core
|
||||||
|
operations.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The kte test suite now includes comprehensive benchmarks and migration
|
||||||
|
coverage tests to:
|
||||||
|
|
||||||
|
- Measure performance of core operations (PieceTable, Buffer, syntax
|
||||||
|
highlighting)
|
||||||
|
- Ensure no performance regressions from refactorings
|
||||||
|
- Validate correctness of API migrations (Buffer::Rows() →
|
||||||
|
GetLineString/GetLineView)
|
||||||
|
- Provide performance baselines for future optimizations
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
### All Tests (including benchmarks)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cmake --build cmake-build-debug --target kte_tests && ./cmake-build-debug/kte_tests
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Organization
|
||||||
|
|
||||||
|
- **58 existing tests**: Core functionality, undo/redo, swap recovery,
|
||||||
|
search, etc.
|
||||||
|
- **15 benchmark tests**: Performance measurements for critical
|
||||||
|
operations
|
||||||
|
- **30 migration coverage tests**: Edge cases and correctness validation
|
||||||
|
|
||||||
|
Total: **98 tests**
|
||||||
|
|
||||||
|
## Benchmark Results
|
||||||
|
|
||||||
|
### Buffer Iteration Patterns (5,000 lines)
|
||||||
|
|
||||||
|
| Pattern | Time | Speedup vs Rows() |
|
||||||
|
|-----------------------------------------|---------|-------------------|
|
||||||
|
| `Rows()` + iteration | 3.1 ms | 1.0x (baseline) |
|
||||||
|
| `Nrows()` + `GetLineString()` | 1.9 ms | **1.7x faster** |
|
||||||
|
| `Nrows()` + `GetLineView()` (zero-copy) | 0.28 ms | **11x faster** |
|
||||||
|
|
||||||
|
**Key Insight**: `GetLineView()` provides zero-copy access and is
|
||||||
|
dramatically faster than materializing the entire rows cache.
|
||||||
|
|
||||||
|
### PieceTable Operations (10,000 lines)
|
||||||
|
|
||||||
|
| Operation | Time |
|
||||||
|
|-----------------------------|---------|
|
||||||
|
| Sequential inserts (10K) | 2.1 ms |
|
||||||
|
| Random inserts (5K) | 32.9 ms |
|
||||||
|
| `GetLine()` sequential | 4.7 ms |
|
||||||
|
| `GetLineRange()` sequential | 1.3 ms |
|
||||||
|
|
||||||
|
### Buffer Operations
|
||||||
|
|
||||||
|
| Operation | Time |
|
||||||
|
|--------------------------------------|---------|
|
||||||
|
| `Nrows()` (1M calls) | 13.0 ms |
|
||||||
|
| `GetLineString()` (10K lines) | 4.8 ms |
|
||||||
|
| `GetLineView()` (10K lines) | 1.6 ms |
|
||||||
|
| `Rows()` materialization (10K lines) | 6.2 ms |
|
||||||
|
|
||||||
|
### Syntax Highlighting
|
||||||
|
|
||||||
|
| Operation | Time | Notes |
|
||||||
|
|------------------------------------|---------|----------------|
|
||||||
|
| C++ highlighting (~1000 lines) | 2.0 ms | First pass |
|
||||||
|
| HighlighterEngine cache population | 19.9 ms | |
|
||||||
|
| HighlighterEngine cache hits | 0.52 ms | **38x faster** |
|
||||||
|
|
||||||
|
### Large File Performance
|
||||||
|
|
||||||
|
| Operation | Time |
|
||||||
|
|---------------------------------|---------|
|
||||||
|
| Insert 50K lines | 0.53 ms |
|
||||||
|
| Iterate 50K lines (GetLineView) | 2.7 ms |
|
||||||
|
| Random access (10K accesses) | 1.8 ms |
|
||||||
|
|
||||||
|
## API Differences: GetLineString vs GetLineView
|
||||||
|
|
||||||
|
Understanding the difference between these APIs is critical:
|
||||||
|
|
||||||
|
### `GetLineString(row)`
|
||||||
|
|
||||||
|
- Returns: `std::string` (copy)
|
||||||
|
- Content: Line text **without** trailing newline
|
||||||
|
- Use case: When you need to modify the string or store it
|
||||||
|
- Example: `"hello"` for line `"hello\n"`
|
||||||
|
|
||||||
|
### `GetLineView(row)`
|
||||||
|
|
||||||
|
- Returns: `std::string_view` (zero-copy)
|
||||||
|
- Content: Raw line range **including** trailing newline
|
||||||
|
- Use case: Read-only access, maximum performance
|
||||||
|
- Example: `"hello\n"` for line `"hello\n"`
|
||||||
|
- **Warning**: View becomes invalid after buffer modifications
|
||||||
|
|
||||||
|
### `Rows()`
|
||||||
|
|
||||||
|
- Returns: `std::vector<Buffer::Line>&` (materialized cache)
|
||||||
|
- Content: Lines **without** trailing newlines
|
||||||
|
- Use case: Legacy code, being phased out
|
||||||
|
- Performance: Slower due to materialization overhead
|
||||||
|
|
||||||
|
## Migration Coverage Tests
|
||||||
|
|
||||||
|
The `test_migration_coverage.cc` file provides 30 tests covering:
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- Empty buffers
|
||||||
|
- Single lines (with/without newlines)
|
||||||
|
- Very long lines (10,000 characters)
|
||||||
|
- Many empty lines (1,000 newlines)
|
||||||
|
|
||||||
|
### Consistency
|
||||||
|
|
||||||
|
- `GetLineString()` vs `GetLineView()` vs `Rows()`
|
||||||
|
- Consistency after edits (insert, delete, split, join)
|
||||||
|
|
||||||
|
### Boundary Conditions
|
||||||
|
|
||||||
|
- First line access
|
||||||
|
- Last line access
|
||||||
|
- Line range boundaries
|
||||||
|
|
||||||
|
### Special Characters
|
||||||
|
|
||||||
|
- Tabs, carriage returns, null bytes
|
||||||
|
- Unicode (UTF-8 multibyte characters)
|
||||||
|
|
||||||
|
### Stress Tests
|
||||||
|
|
||||||
|
- Large files (10,000 lines)
|
||||||
|
- Many small operations (100+ inserts)
|
||||||
|
- Alternating insert/delete patterns
|
||||||
|
|
||||||
|
### Regression Tests
|
||||||
|
|
||||||
|
- Shebang detection pattern (Editor.cc)
|
||||||
|
- Empty buffer check pattern (Editor.cc)
|
||||||
|
- Syntax highlighter pattern (all highlighters)
|
||||||
|
- Swap snapshot pattern (Swap.cc)
|
||||||
|
|
||||||
|
## Performance Recommendations
|
||||||
|
|
||||||
|
Based on benchmark results:
|
||||||
|
|
||||||
|
1. **Prefer `GetLineView()` for read-only access**
|
||||||
|
- 11x faster than `Rows()` for iteration
|
||||||
|
- Zero-copy, minimal overhead
|
||||||
|
- Use immediately (view invalidates on edit)
|
||||||
|
|
||||||
|
2. **Use `GetLineString()` when you need a copy**
|
||||||
|
- Still 1.7x faster than `Rows()`
|
||||||
|
- Safe to store and modify
|
||||||
|
- Strips trailing newlines automatically
|
||||||
|
|
||||||
|
3. **Avoid `Rows()` in hot paths**
|
||||||
|
- Materializes entire line cache
|
||||||
|
- Slower for large files
|
||||||
|
- Being phased out (legacy API)
|
||||||
|
|
||||||
|
4. **Cache `Nrows()` in tight loops**
|
||||||
|
- Very fast (13ms for 1M calls)
|
||||||
|
- But still worth caching in inner loops
|
||||||
|
|
||||||
|
5. **Leverage HighlighterEngine caching**
|
||||||
|
- 38x speedup on cache hits
|
||||||
|
- Automatically invalidates on edits
|
||||||
|
- Prefetch viewport for smooth scrolling
|
||||||
|
|
||||||
|
## Adding New Benchmarks
|
||||||
|
|
||||||
|
To add a new benchmark:
|
||||||
|
|
||||||
|
1. Add a `TEST(Benchmark_YourName)` in `tests/test_benchmarks.cc`
|
||||||
|
2. Use `BenchmarkTimer` to measure critical sections:
|
||||||
|
```cpp
|
||||||
|
{
|
||||||
|
BenchmarkTimer timer("Operation description");
|
||||||
|
// ... code to benchmark ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
3. Print section headers with `std::cout` for clarity
|
||||||
|
4. Use `ASSERT_EQ` or `EXPECT_TRUE` to validate results
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
TEST(Benchmark_MyOperation) {
|
||||||
|
std::cout << "\n=== My Operation Benchmark ===\n";
|
||||||
|
|
||||||
|
// Setup
|
||||||
|
Buffer buf;
|
||||||
|
std::string data = generate_test_data();
|
||||||
|
buf.insert_text(0, 0, data);
|
||||||
|
|
||||||
|
std::size_t result = 0;
|
||||||
|
{
|
||||||
|
BenchmarkTimer timer("My operation on 10K lines");
|
||||||
|
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||||
|
result += my_operation(buf, i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EXPECT_TRUE(result > 0);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Continuous Performance Monitoring
|
||||||
|
|
||||||
|
Run benchmarks regularly to detect regressions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run tests and save output
|
||||||
|
./cmake-build-debug/kte_tests > benchmark_results.txt
|
||||||
|
|
||||||
|
# Compare with baseline
|
||||||
|
diff benchmark_baseline.txt benchmark_results.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Look for:
|
||||||
|
|
||||||
|
- Significant time increases (>20%) in any benchmark
|
||||||
|
- New operations that are slower than expected
|
||||||
|
- Cache effectiveness degradation
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The benchmark suite provides:
|
||||||
|
|
||||||
|
- **Performance validation**: Ensures migrations don't regress
|
||||||
|
performance
|
||||||
|
- **Optimization guidance**: Identifies fastest APIs for each use case
|
||||||
|
- **Regression detection**: Catches performance issues early
|
||||||
|
- **Documentation**: Demonstrates correct API usage patterns
|
||||||
|
|
||||||
|
All 98 tests pass with 0 failures, confirming both correctness and
|
||||||
|
performance of the migrated codebase.
|
||||||
652
docs/DEVELOPER_GUIDE.md
Normal file
652
docs/DEVELOPER_GUIDE.md
Normal file
@@ -0,0 +1,652 @@
|
|||||||
|
# kte Developer Guide
|
||||||
|
|
||||||
|
Welcome to kte development! This guide will help you understand the
|
||||||
|
codebase, make changes, and contribute effectively.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Architecture Overview](#architecture-overview)
|
||||||
|
2. [Core Components](#core-components)
|
||||||
|
3. [Code Organization](#code-organization)
|
||||||
|
4. [Building and Testing](#building-and-testing)
|
||||||
|
5. [Making Changes](#making-changes)
|
||||||
|
6. [Code Style](#code-style)
|
||||||
|
7. [Common Tasks](#common-tasks)
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
kte follows a clean separation of concerns with three main layers:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Frontend Layer (Terminal/ImGui/Qt) │
|
||||||
|
│ - TerminalFrontend / ImGuiFrontend │
|
||||||
|
│ - InputHandler + Renderer interfaces │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Command Layer │
|
||||||
|
│ - Command registry and execution │
|
||||||
|
│ - All editing operations │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ Core Model Layer │
|
||||||
|
│ - Editor (top-level state) │
|
||||||
|
│ - Buffer (document model) │
|
||||||
|
│ - PieceTable (text storage) │
|
||||||
|
│ - UndoSystem (undo/redo) │
|
||||||
|
│ - SwapManager (crash recovery) │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Design Principles
|
||||||
|
|
||||||
|
- **Frontend Independence**: Core editing logic is independent of UI.
|
||||||
|
Frontends implement `Frontend`, `InputHandler`, and `Renderer`
|
||||||
|
interfaces.
|
||||||
|
- **Command Pattern**: All editing operations go through the command
|
||||||
|
system, enabling consistent undo/redo and testing.
|
||||||
|
- **Piece Table**: Efficient text storage using a piece table data
|
||||||
|
structure that avoids copying large buffers.
|
||||||
|
- **Lazy Materialization**: Text is materialized on-demand to minimize
|
||||||
|
memory allocations.
|
||||||
|
|
||||||
|
## Core Components
|
||||||
|
|
||||||
|
### Editor (`Editor.h/.cc`)
|
||||||
|
|
||||||
|
The top-level editor state container. Manages:
|
||||||
|
|
||||||
|
- Multiple buffers
|
||||||
|
- Editor modes (normal, k-command prefix, prompts)
|
||||||
|
- Kill ring (clipboard history)
|
||||||
|
- Universal argument state
|
||||||
|
- Search state
|
||||||
|
- Status messages
|
||||||
|
- Swap file management
|
||||||
|
|
||||||
|
**Key Insight**: Editor is primarily a state holder with many
|
||||||
|
getter/setter pairs. It doesn't contain editing logic - that's in
|
||||||
|
commands.
|
||||||
|
|
||||||
|
### Buffer (`Buffer.h/.cc`)
|
||||||
|
|
||||||
|
Represents an open document. Manages:
|
||||||
|
|
||||||
|
- File I/O (open, save, external modification detection)
|
||||||
|
- Cursor position and viewport offsets
|
||||||
|
- Mark (selection start point)
|
||||||
|
- Visual line mode state
|
||||||
|
- Syntax highlighting integration
|
||||||
|
- Undo system integration
|
||||||
|
- Swap recording integration
|
||||||
|
|
||||||
|
**Key Insight**: Buffer wraps a PieceTable and provides a higher-level
|
||||||
|
interface. The nested `Buffer::Line` class is a legacy wrapper that has
|
||||||
|
been largely phased out in favor of direct PieceTable operations.
|
||||||
|
|
||||||
|
**Line Access APIs**: Buffer provides three ways to access line content:
|
||||||
|
|
||||||
|
- `GetLineView(row)` - Zero-copy `string_view` (fastest, 11x faster than
|
||||||
|
Rows())
|
||||||
|
- `GetLineString(row)` - Returns `std::string` copy (1.7x faster than
|
||||||
|
Rows())
|
||||||
|
- `Rows()` - Materializes all lines into cache (legacy, avoid in new
|
||||||
|
code)
|
||||||
|
|
||||||
|
See `docs/BENCHMARKS.md` for detailed performance analysis and usage
|
||||||
|
guidance.
|
||||||
|
|
||||||
|
### PieceTable (`PieceTable.h/.cc`)
|
||||||
|
|
||||||
|
The core text storage data structure. Provides:
|
||||||
|
|
||||||
|
- Efficient insert/delete operations without copying entire buffer
|
||||||
|
- Line-based queries (line count, get line, line ranges)
|
||||||
|
- Position conversion (byte offset ↔ line/column)
|
||||||
|
- Substring extraction
|
||||||
|
- Search functionality
|
||||||
|
- Automatic consolidation to prevent piece fragmentation
|
||||||
|
|
||||||
|
**Key Insight**: PieceTable uses lazy materialization - the full text is
|
||||||
|
only assembled when `Data()` is called. Most operations work directly on
|
||||||
|
the piece list.
|
||||||
|
|
||||||
|
### UndoSystem (`UndoSystem.h/.cc`, `UndoTree.h/.cc`, `UndoNode.h/.cc`)
|
||||||
|
|
||||||
|
Implements undo/redo with a tree structure supporting:
|
||||||
|
|
||||||
|
- Linear undo/redo
|
||||||
|
- Branching history (future enhancement)
|
||||||
|
- Checkpointing and compaction
|
||||||
|
- Memory-efficient node pooling
|
||||||
|
|
||||||
|
**Key Insight**: The undo system records operations at the PieceTable
|
||||||
|
level, not at the command level.
|
||||||
|
|
||||||
|
### Command System (`Command.h/.cc`)
|
||||||
|
|
||||||
|
All editing operations are implemented as commands:
|
||||||
|
|
||||||
|
- File operations (save, open, close)
|
||||||
|
- Navigation (move cursor, page up/down, word movement)
|
||||||
|
- Editing (insert, delete, kill, yank)
|
||||||
|
- Search and replace
|
||||||
|
- Buffer management
|
||||||
|
- Configuration (syntax, theme, font)
|
||||||
|
|
||||||
|
**Key Insight**: `Command.cc` is currently a monolithic 5000-line file.
|
||||||
|
This is the biggest maintainability challenge in the codebase.
|
||||||
|
|
||||||
|
### Frontend Abstraction
|
||||||
|
|
||||||
|
Three interfaces define the frontend contract:
|
||||||
|
|
||||||
|
- **Frontend** (`Frontend.h`): Top-level lifecycle (Init/Step/Shutdown)
|
||||||
|
- **InputHandler** (`InputHandler.h`): Converts UI events to commands
|
||||||
|
- **Renderer** (`Renderer.h`): Draws the editor state
|
||||||
|
|
||||||
|
Implementations:
|
||||||
|
|
||||||
|
- **Terminal**: ncurses-based (`TerminalFrontend`,
|
||||||
|
`TerminalInputHandler`, `TerminalRenderer`)
|
||||||
|
- **ImGui**: Dear ImGui-based (`ImGuiFrontend`, `ImGuiInputHandler`,
|
||||||
|
`ImGuiRenderer`)
|
||||||
|
- **Qt**: Qt-based (`QtFrontend`, `QtInputHandler`, `QtRenderer`)
|
||||||
|
- **Test**: Programmatic testing (`TestFrontend`, `TestInputHandler`,
|
||||||
|
`TestRenderer`)
|
||||||
|
|
||||||
|
## Code Organization
|
||||||
|
|
||||||
|
### Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
kte/
|
||||||
|
├── *.h, *.cc # Core implementation (root level)
|
||||||
|
├── main.cc # Entry point
|
||||||
|
├── docs/ # Documentation
|
||||||
|
│ ├── ke.md # Original ke editor reference (keybindings)
|
||||||
|
│ ├── swap.md # Swap file design
|
||||||
|
│ ├── syntax.md # Syntax highlighting
|
||||||
|
│ ├── themes.md # Theme system
|
||||||
|
│ └── plans/ # Design documents
|
||||||
|
├── tests/ # Test suite
|
||||||
|
│ ├── Test.h # Minimal test framework
|
||||||
|
│ ├── TestRunner.cc # Test runner
|
||||||
|
│ └── test_*.cc # Individual test files
|
||||||
|
├── syntax/ # Syntax highlighting engines
|
||||||
|
├── fonts/ # Embedded fonts for GUI
|
||||||
|
├── themes/ # Color themes
|
||||||
|
└── ext/ # External dependencies (imgui)
|
||||||
|
```
|
||||||
|
|
||||||
|
### File Naming Conventions
|
||||||
|
|
||||||
|
- Headers: `ComponentName.h`
|
||||||
|
- Implementation: `ComponentName.cc`
|
||||||
|
- Tests: `test_feature_name.cc`
|
||||||
|
|
||||||
|
### Key Files by Size
|
||||||
|
|
||||||
|
Large files that may need attention:
|
||||||
|
|
||||||
|
- `Command.cc` (4995 lines) - **Needs refactoring**: Consider splitting
|
||||||
|
into logical groups
|
||||||
|
- `Swap.cc` (1300 lines) - Crash recovery system (migrated to direct
|
||||||
|
PieceTable operations)
|
||||||
|
- `QtFrontend.cc` (985 lines) - Qt integration
|
||||||
|
- `ImGuiRenderer.cc` (930 lines) - ImGui rendering
|
||||||
|
- `PieceTable.cc` (800 lines) - Core data structure
|
||||||
|
- `Buffer.cc` (763 lines) - Document model
|
||||||
|
|
||||||
|
## Building and Testing
|
||||||
|
|
||||||
|
### Build System
|
||||||
|
|
||||||
|
kte uses CMake with multiple build profiles:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Debug build (terminal only)
|
||||||
|
cmake -S . -B cmake-build-debug -DCMAKE_BUILD_TYPE=Debug
|
||||||
|
cmake --build cmake-build-debug
|
||||||
|
|
||||||
|
# Release build with GUI
|
||||||
|
cmake -S . -B cmake-build-release -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=ON
|
||||||
|
cmake --build cmake-build-release
|
||||||
|
|
||||||
|
# Build specific target
|
||||||
|
cmake --build cmake-build-debug --target kte_tests
|
||||||
|
```
|
||||||
|
|
||||||
|
### CMake Targets
|
||||||
|
|
||||||
|
- `kte` - Terminal editor executable
|
||||||
|
- `kge` - GUI editor executable (when `BUILD_GUI=ON`)
|
||||||
|
- `kte_tests` - Test suite
|
||||||
|
- `imgui` - Dear ImGui library (when `BUILD_GUI=ON`)
|
||||||
|
|
||||||
|
### Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build and run all tests
|
||||||
|
cmake --build cmake-build-debug --target kte_tests && ./cmake-build-debug/kte_tests
|
||||||
|
|
||||||
|
# Run tests with verbose output
|
||||||
|
./cmake-build-debug/kte_tests
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Organization
|
||||||
|
|
||||||
|
The test suite uses a minimal custom framework (`Test.h`):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
TEST(TestName) {
|
||||||
|
// Test body
|
||||||
|
ASSERT_EQ(actual, expected);
|
||||||
|
ASSERT_TRUE(condition);
|
||||||
|
EXPECT_TRUE(condition); // Non-fatal
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Test files by category:
|
||||||
|
|
||||||
|
- **Core Data Structures**:
|
||||||
|
- `test_piece_table.cc` - PieceTable operations, line indexing,
|
||||||
|
random edits
|
||||||
|
- `test_buffer_rows.cc` - Buffer row operations
|
||||||
|
- `test_buffer_io.cc` - File I/O (open, save, SaveAs)
|
||||||
|
|
||||||
|
- **Editing Operations**:
|
||||||
|
- `test_command_semantics.cc` - Command execution
|
||||||
|
- `test_kkeymap.cc` - Keybinding system
|
||||||
|
- `test_visual_line_mode.cc` - Visual line selection
|
||||||
|
|
||||||
|
- **Search and Replace**:
|
||||||
|
- `test_search.cc` - Search functionality
|
||||||
|
- `test_search_replace_flow.cc` - Interactive search/replace
|
||||||
|
|
||||||
|
- **Text Reflow**:
|
||||||
|
- `test_reflow_paragraph.cc` - Paragraph reformatting
|
||||||
|
- `test_reflow_indented_bullets.cc` - Indented list handling
|
||||||
|
|
||||||
|
- **Undo System**:
|
||||||
|
- `test_undo.cc` - Undo/redo operations
|
||||||
|
|
||||||
|
- **Swap Files** (Crash Recovery):
|
||||||
|
- `test_swap_recorder.cc` - Recording operations
|
||||||
|
- `test_swap_writer.cc` - Writing swap files
|
||||||
|
- `test_swap_replay.cc` - Replaying operations
|
||||||
|
- `test_swap_recovery_prompt.cc` - Recovery UI
|
||||||
|
- `test_swap_cleanup.cc` - Cleanup logic
|
||||||
|
- `test_swap_git_editor.cc` - Git editor integration
|
||||||
|
|
||||||
|
- **Performance and Migration**:
|
||||||
|
- `test_benchmarks.cc` - Performance benchmarks for core operations
|
||||||
|
- `test_migration_coverage.cc` - Buffer::Line migration validation
|
||||||
|
|
||||||
|
- **Integration Tests**:
|
||||||
|
- `test_daily_workflows.cc` - Real-world editing scenarios
|
||||||
|
- `test_daily_driver_harness.cc` - Workflow test infrastructure
|
||||||
|
|
||||||
|
**Total**: 98 tests across 22 test files. See `docs/BENCHMARKS.md` for
|
||||||
|
performance benchmark results.
|
||||||
|
|
||||||
|
### Docker/Podman for Linux Builds
|
||||||
|
|
||||||
|
A minimal `Dockerfile` is provided for **testing Linux builds** without
|
||||||
|
requiring a native Linux system. The Dockerfile creates a build
|
||||||
|
environment container with all necessary dependencies. Your source tree
|
||||||
|
is mounted into the container at runtime, allowing you to test
|
||||||
|
compilation and run tests on Linux.
|
||||||
|
|
||||||
|
**Important**: This is intended for testing Linux builds, not for
|
||||||
|
running
|
||||||
|
kte locally. The container expects the source tree to be mounted when
|
||||||
|
run.
|
||||||
|
|
||||||
|
This is particularly useful for:
|
||||||
|
|
||||||
|
- **macOS/Windows developers** testing Linux compatibility
|
||||||
|
- **CI/CD pipelines** ensuring cross-platform builds
|
||||||
|
- **Reproducible builds** with a known Alpine Linux 3.19 environment
|
||||||
|
|
||||||
|
#### Prerequisites
|
||||||
|
|
||||||
|
Install Docker or Podman:
|
||||||
|
|
||||||
|
- **macOS**: `brew install podman` (Docker Desktop also works)
|
||||||
|
- **Linux**: Use your distribution's package manager
|
||||||
|
- **Windows**: Docker Desktop or Podman Desktop
|
||||||
|
|
||||||
|
If using Podman on macOS, start the VM:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman machine init
|
||||||
|
podman machine start
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Building the Docker Image
|
||||||
|
|
||||||
|
The Dockerfile installs all build dependencies including GUI support (
|
||||||
|
g++ 13.2.1, CMake 3.27.8, ncurses-dev, SDL2, OpenGL/Mesa, Freetype). It
|
||||||
|
does not copy or build the source code.
|
||||||
|
|
||||||
|
From the project root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the environment image
|
||||||
|
docker build -t kte-linux .
|
||||||
|
|
||||||
|
# Or with Podman
|
||||||
|
podman build -t kte-linux .
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Testing Linux Builds
|
||||||
|
|
||||||
|
Mount your source tree and run the build + tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build and test (default command)
|
||||||
|
docker run --rm -v "$(pwd):/kte" kte-linux
|
||||||
|
|
||||||
|
# Expected output: "98 tests passed, 0 failed"
|
||||||
|
```
|
||||||
|
|
||||||
|
The default command builds both `kte` (terminal) and `kge` (GUI)
|
||||||
|
executables with full GUI support (`-DBUILD_GUI=ON`) and runs the
|
||||||
|
complete test suite.
|
||||||
|
|
||||||
|
#### Custom Build Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Open a shell in the build environment
|
||||||
|
docker run --rm -it -v "$(pwd):/kte" kte-linux /bin/bash
|
||||||
|
|
||||||
|
# Then inside the container:
|
||||||
|
cmake -B build -DBUILD_GUI=ON -DBUILD_TESTS=ON
|
||||||
|
cmake --build build --target kte # Terminal version
|
||||||
|
cmake --build build --target kge # GUI version
|
||||||
|
cmake --build build --target kte_tests
|
||||||
|
./build/kte_tests
|
||||||
|
|
||||||
|
# Or run kte directly
|
||||||
|
./build/kte --help
|
||||||
|
|
||||||
|
# Terminal-only build (smaller, faster)
|
||||||
|
cmake -B build -DBUILD_GUI=OFF -DBUILD_TESTS=ON
|
||||||
|
cmake --build build --target kte
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Running kte Interactively
|
||||||
|
|
||||||
|
To test kte's terminal UI on Linux:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run kte with a file from your host system
|
||||||
|
docker run --rm -it -v "$(pwd):/kte" kte-linux sh -c "cmake -B build -DBUILD_GUI=OFF && cmake --build build --target kte && ./build/kte README.md"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### CI/CD Integration
|
||||||
|
|
||||||
|
Example GitHub Actions workflow:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Test Linux Build
|
||||||
|
run: |
|
||||||
|
docker build -t kte-linux .
|
||||||
|
docker run --rm -v "${{ github.workspace }}:/kte" kte-linux
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Troubleshooting
|
||||||
|
|
||||||
|
**"Cannot connect to Podman socket"** (macOS):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman machine start
|
||||||
|
```
|
||||||
|
|
||||||
|
**"Permission denied"** (Linux):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add your user to the docker group
|
||||||
|
sudo usermod -aG docker $USER
|
||||||
|
# Log out and back in
|
||||||
|
```
|
||||||
|
|
||||||
|
**Build fails with ncurses errors**:
|
||||||
|
The Dockerfile explicitly installs `ncurses-dev` (wide-character
|
||||||
|
ncurses). If you modify the Dockerfile, ensure this dependency remains.
|
||||||
|
|
||||||
|
**"No such file or directory" errors**:
|
||||||
|
Ensure you're mounting the source tree with `-v "$(pwd):/kte"` when
|
||||||
|
running the container.
|
||||||
|
|
||||||
|
### Writing Tests
|
||||||
|
|
||||||
|
When adding new functionality:
|
||||||
|
|
||||||
|
1. **Add a test first** - Write a failing test that demonstrates the
|
||||||
|
desired behavior
|
||||||
|
2. **Use descriptive names** - Test names should explain what's being
|
||||||
|
validated
|
||||||
|
3. **Test edge cases** - Empty buffers, EOF, beginning of file, etc.
|
||||||
|
4. **Use TestFrontend** - For integration tests, use the programmatic
|
||||||
|
test frontend
|
||||||
|
|
||||||
|
Example test structure:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
TEST(Feature_Behavior_Scenario) {
|
||||||
|
// Setup
|
||||||
|
Buffer buf;
|
||||||
|
buf.insert_text(0, 0, "test content\n");
|
||||||
|
|
||||||
|
// Exercise
|
||||||
|
buf.delete_text(0, 5, 4);
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
ASSERT_EQ(buf.GetLineString(0), std::string("test\n"));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Making Changes
|
||||||
|
|
||||||
|
### Development Workflow
|
||||||
|
|
||||||
|
1. **Understand the change scope**:
|
||||||
|
- Pure UI change? → Modify frontend only
|
||||||
|
- New editing operation? → Add command in `Command.cc`
|
||||||
|
- Core data structure? → Modify `PieceTable` or `Buffer`
|
||||||
|
|
||||||
|
2. **Find relevant code**:
|
||||||
|
- Use `git grep` or IDE search to find similar functionality
|
||||||
|
- Check `Command.cc` for existing command patterns
|
||||||
|
- Look at tests to understand expected behavior
|
||||||
|
|
||||||
|
3. **Make the change**:
|
||||||
|
- Follow existing code style (see below)
|
||||||
|
- Add or update tests
|
||||||
|
- Update documentation if needed
|
||||||
|
|
||||||
|
4. **Test thoroughly**:
|
||||||
|
- Run the full test suite
|
||||||
|
- Manually test in both terminal and GUI (if applicable)
|
||||||
|
- Test edge cases (empty files, large files, EOF, etc.)
|
||||||
|
|
||||||
|
### Common Pitfalls
|
||||||
|
|
||||||
|
- **Don't modify `Buffer::Rows()` directly** - Use the PieceTable API (
|
||||||
|
`insert_text`, `delete_text`, etc.) to ensure undo and swap recording
|
||||||
|
work correctly.
|
||||||
|
- **Prefer efficient line access** - Use `GetLineView()` for read-only
|
||||||
|
access (11x faster than `Rows()`), or `GetLineString()` when you need
|
||||||
|
a copy. Avoid `Rows()` in new code.
|
||||||
|
- **Remember to invalidate caches** - If you modify PieceTable
|
||||||
|
internals, ensure line index and materialization caches are
|
||||||
|
invalidated.
|
||||||
|
- **Cursor visibility** - After editing operations, call
|
||||||
|
`ensure_cursor_visible()` to update viewport offsets.
|
||||||
|
- **Undo boundaries** - Use `buf.Undo()->BeginGroup()` and `EndGroup()`
|
||||||
|
to group related operations.
|
||||||
|
- **GetLineView() lifetime** - The returned `string_view` is only valid
|
||||||
|
until the next buffer modification. Use immediately or copy to
|
||||||
|
`std::string`.
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
kte uses C++20 with these conventions:
|
||||||
|
|
||||||
|
### Naming
|
||||||
|
|
||||||
|
- **Classes/Structs**: `PascalCase` (e.g., `PieceTable`, `Buffer`)
|
||||||
|
- **Functions/Methods**: `PascalCase` (e.g., `GetLine`, `Insert`)
|
||||||
|
- **Variables**: `snake_case` with trailing underscore for members (
|
||||||
|
e.g., `total_size_`, `line_index_`)
|
||||||
|
- **Constants**: `snake_case` or `UPPER_CASE` depending on context
|
||||||
|
- **Private members**: Trailing underscore (e.g., `pieces_`, `dirty_`)
|
||||||
|
|
||||||
|
### Formatting
|
||||||
|
|
||||||
|
- **Indentation**: Tabs (width 8 in most files, but follow existing
|
||||||
|
style)
|
||||||
|
- **Braces**: Opening brace on same line for functions, control
|
||||||
|
structures
|
||||||
|
- **Line length**: No strict limit, but keep reasonable (~100-120 chars)
|
||||||
|
- **Includes**: Group by category (system, external, project) with blank
|
||||||
|
lines between
|
||||||
|
|
||||||
|
### Comments
|
||||||
|
|
||||||
|
- **File headers**: Brief description of the file's purpose
|
||||||
|
- **Function comments**: Explain non-obvious behavior, not what the code
|
||||||
|
obviously does
|
||||||
|
- **Inline comments**: Explain *why*, not *what*
|
||||||
|
- **TODO comments**: Use `TODO:` prefix for future work
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Consolidate small pieces to prevent fragmentation.
|
||||||
|
// This is a heuristic: we only consolidate when piece count exceeds
|
||||||
|
// a threshold, and we cap the bytes processed per consolidation run.
|
||||||
|
void maybeConsolidate() {
|
||||||
|
if (pieces_.size() < piece_limit_)
|
||||||
|
return;
|
||||||
|
// ... implementation
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Tasks
|
||||||
|
|
||||||
|
### Adding a New Command
|
||||||
|
|
||||||
|
1. **Define the command function** in `Command.cc`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
bool cmd_my_feature(CommandContext &ctx) {
|
||||||
|
Editor &ed = ctx.ed;
|
||||||
|
Buffer *buf = ed.CurrentBuffer();
|
||||||
|
if (!buf) return false;
|
||||||
|
|
||||||
|
// Implement the command
|
||||||
|
buf->insert_text(buf->Cury(), buf->Curx(), "text");
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Register the command** in `InstallDefaultCommands()`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
CommandRegistry::Register({
|
||||||
|
CommandId::MyFeature,
|
||||||
|
"my-feature",
|
||||||
|
"Description of what it does",
|
||||||
|
cmd_my_feature
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Add keybinding** in the appropriate `InputHandler` (e.g.,
|
||||||
|
`TerminalInputHandler.cc`).
|
||||||
|
|
||||||
|
4. **Write tests** in `tests/test_command_semantics.cc` or a new test
|
||||||
|
file.
|
||||||
|
|
||||||
|
### Adding a New Frontend
|
||||||
|
|
||||||
|
1. **Implement the three interfaces**:
|
||||||
|
- `Frontend` - Lifecycle management
|
||||||
|
- `InputHandler` - Event → Command translation
|
||||||
|
- `Renderer` - Draw the editor state
|
||||||
|
|
||||||
|
2. **Study existing implementations**:
|
||||||
|
- `TerminalFrontend` - Simplest, good starting point
|
||||||
|
- `ImGuiFrontend` - More complex, shows GUI patterns
|
||||||
|
|
||||||
|
3. **Register in `main.cc`** to make it selectable.
|
||||||
|
|
||||||
|
### Modifying the PieceTable
|
||||||
|
|
||||||
|
The PieceTable is performance-critical. When making changes:
|
||||||
|
|
||||||
|
1. **Understand the piece list** - Each piece references a range in
|
||||||
|
either `original_` or `add_` buffer
|
||||||
|
2. **Maintain invariants**:
|
||||||
|
- `total_size_` must match sum of piece lengths
|
||||||
|
- Line index must be invalidated on content changes
|
||||||
|
- Version must increment on mutations
|
||||||
|
3. **Test thoroughly** - Use `test_piece_table.cc` random edit test as a
|
||||||
|
reference model
|
||||||
|
4. **Profile if needed** - Large file performance is a key goal
|
||||||
|
|
||||||
|
### Adding Syntax Highlighting
|
||||||
|
|
||||||
|
1. **Create a new highlighter** in `syntax/` directory:
|
||||||
|
- Inherit from `HighlighterEngine`
|
||||||
|
- Implement `HighlightLine()` method
|
||||||
|
|
||||||
|
2. **Register in `HighlighterRegistry`** (
|
||||||
|
`syntax/HighlighterRegistry.cc`)
|
||||||
|
|
||||||
|
3. **Add file extension mapping** in the registry
|
||||||
|
|
||||||
|
4. **Test with sample files** of that language
|
||||||
|
|
||||||
|
### Debugging Tips
|
||||||
|
|
||||||
|
- **Use the test frontend** - Write a test that reproduces the issue
|
||||||
|
- **Enable assertions** - Build in Debug mode
|
||||||
|
- **Check swap files** - Look in `/tmp/kte-swap-*` for recorded
|
||||||
|
operations
|
||||||
|
- **Print debugging** - Use `std::cerr` (stdout is used by ncurses)
|
||||||
|
- **GDB/LLDB** - Standard debuggers work fine with kte
|
||||||
|
|
||||||
|
## Getting Help
|
||||||
|
|
||||||
|
- **Read the code** - kte is designed to be understandable; follow the
|
||||||
|
data flow
|
||||||
|
- **Check existing tests** - Tests often show how to use APIs correctly
|
||||||
|
- **Look at git history** - See how similar features were implemented
|
||||||
|
- **Read design docs** - Check `docs/plans/` for design rationale
|
||||||
|
|
||||||
|
## Future Improvements
|
||||||
|
|
||||||
|
Areas where the codebase could be improved:
|
||||||
|
|
||||||
|
1. **Split Command.cc** - Break into logical groups (editing,
|
||||||
|
navigation, file ops, etc.)
|
||||||
|
2. **Complete Buffer::Line migration** - A few legacy editing functions
|
||||||
|
in Command.cc still use `Buffer::Rows()` directly (see lines 86-90
|
||||||
|
comment)
|
||||||
|
3. **Add more inline documentation** - Especially for complex algorithms
|
||||||
|
4. **Improve test coverage** - Add more edge case tests (current: 98
|
||||||
|
tests)
|
||||||
|
5. **Performance profiling** - Continue monitoring performance with
|
||||||
|
benchmark suite
|
||||||
|
6. **API documentation** - Consider adding Doxygen-style comments
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Welcome aboard! Start small, read the code, and don't hesitate to ask
|
||||||
|
questions.
|
||||||
@@ -12,11 +12,14 @@ Goals
|
|||||||
|
|
||||||
Model overview
|
Model overview
|
||||||
--------------
|
--------------
|
||||||
Per open buffer, maintain a sidecar swap journal next to the file:
|
Per open buffer, maintain a swap journal in a per-user state directory:
|
||||||
|
|
||||||
- Path: `.<basename>.kte.swp` in the same directory as the file (for
|
- Path: `$XDG_STATE_HOME/kte/swap/<encoded-path>.swp` (or
|
||||||
unnamed/unsaved buffers, use a per‑session temp dir like
|
`~/.local/state/kte/swap/...`)
|
||||||
`$TMPDIR/kte/` with a random UUID).
|
where `<encoded-path>` is the file path with separators replaced (e.g.
|
||||||
|
`/home/kyle/tmp/test.txt` → `home!kyle!tmp!test.txt.swp`).
|
||||||
|
Unnamed/unsaved
|
||||||
|
buffers use a unique `unnamed-<pid>-<counter>.swp` name.
|
||||||
- Format: append‑only journal of editing operations with periodic
|
- Format: append‑only journal of editing operations with periodic
|
||||||
checkpoints.
|
checkpoints.
|
||||||
- Crash safety: only append, fsync as per policy; checkpoint via
|
- Crash safety: only append, fsync as per policy; checkpoint via
|
||||||
@@ -84,7 +87,7 @@ Recovery flow
|
|||||||
|
|
||||||
On opening a file:
|
On opening a file:
|
||||||
|
|
||||||
1. Detect swap sidecar `.<basename>.kte.swp`.
|
1. Detect swap journal `$XDG_STATE_HOME/kte/swap/<encoded-path>.swp`.
|
||||||
2. Validate header, iterate records verifying CRCs.
|
2. Validate header, iterate records verifying CRCs.
|
||||||
3. Compare recorded original file identity against actual file; if
|
3. Compare recorded original file identity against actual file; if
|
||||||
mismatch, warn user but allow recovery (content wins).
|
mismatch, warn user but allow recovery (content wins).
|
||||||
@@ -98,7 +101,7 @@ Stability & corruption mitigation
|
|||||||
---------------------------------
|
---------------------------------
|
||||||
|
|
||||||
- Append‑only with per‑record CRC32 guards against torn writes.
|
- Append‑only with per‑record CRC32 guards against torn writes.
|
||||||
- Atomic checkpoint rotation: write `.<basename>.kte.swp.tmp`, fsync,
|
- Atomic checkpoint rotation: write `<encoded-path>.swp.tmp`, fsync,
|
||||||
then rename over old `.swp`.
|
then rename over old `.swp`.
|
||||||
- Size caps: rotate or compact when `.swp` exceeds a threshold (e.g.,
|
- Size caps: rotate or compact when `.swp` exceeds a threshold (e.g.,
|
||||||
64–128 MB). Compaction creates a fresh file with a single checkpoint.
|
64–128 MB). Compaction creates a fresh file with a single checkpoint.
|
||||||
@@ -117,8 +120,8 @@ Security considerations
|
|||||||
Interoperability & UX
|
Interoperability & UX
|
||||||
---------------------
|
---------------------
|
||||||
|
|
||||||
- Use a distinctive extension `.kte.swp` to avoid conflicts with other
|
- Use a distinctive directory (`$XDG_STATE_HOME/kte/swap`) to avoid
|
||||||
editors.
|
conflicts with other editors’ `.swp` conventions.
|
||||||
- Status bar indicator when swap is active; commands to purge/compact.
|
- Status bar indicator when swap is active; commands to purge/compact.
|
||||||
- On save: do not delete swap immediately; keep until the buffer is
|
- On save: do not delete swap immediately; keep until the buffer is
|
||||||
clean and idle for a short grace period (allows undo of accidental
|
clean and idle for a short grace period (allows undo of accidental
|
||||||
|
|||||||
237
docs/swap.md
Normal file
237
docs/swap.md
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
# Swap journaling (crash recovery)
|
||||||
|
|
||||||
|
kte has a small “swap” system: an append-only per-buffer journal that
|
||||||
|
records edits so they can be replayed after a crash.
|
||||||
|
|
||||||
|
This document describes the **currently implemented** swap system (stage
|
||||||
|
2), as implemented in `Swap.h` / `Swap.cc`.
|
||||||
|
|
||||||
|
## What it is (and what it is not)
|
||||||
|
|
||||||
|
- The swap file is a **journal of editing operations** (currently
|
||||||
|
inserts, deletes, and periodic full-buffer checkpoints).
|
||||||
|
- It is written by a **single background writer thread** owned by
|
||||||
|
`kte::SwapManager`.
|
||||||
|
- It is intended for **best-effort crash recovery**.
|
||||||
|
|
||||||
|
kte automatically deletes/resets swap journals after a **clean save**
|
||||||
|
and when
|
||||||
|
closing a clean buffer, so old swap files do not accumulate under normal
|
||||||
|
workflows. A best-effort prune also runs at startup to remove very old
|
||||||
|
swap
|
||||||
|
files.
|
||||||
|
|
||||||
|
## Automatic recovery prompt
|
||||||
|
|
||||||
|
When kte opens a file-backed buffer, it checks whether a corresponding
|
||||||
|
swap journal exists.
|
||||||
|
|
||||||
|
- If a swap file exists and replay succeeds *and* produces different
|
||||||
|
content than what is currently on disk, kte prompts:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Recover swap edits for <path>? (y/N, C-g cancel)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `y`: open the file and apply swap replay (buffer becomes dirty)
|
||||||
|
- `Enter` (default) / any non-`y`: delete the swap file (
|
||||||
|
best-effort)
|
||||||
|
and open the file normally
|
||||||
|
- `C-g`: cancel opening the file
|
||||||
|
|
||||||
|
- If a swap file exists but is unreadable/corrupt, kte prompts:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Swap file unreadable for <path>. Delete it? (y/N, C-g cancel)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `y`: delete the swap file (best-effort) and open the file normally
|
||||||
|
- `Enter` (default): keep the swap file and open the file normally
|
||||||
|
- `C-g`: cancel opening the file
|
||||||
|
|
||||||
|
## Where swap files live
|
||||||
|
|
||||||
|
Swap files are stored under an XDG-style per-user *state* directory:
|
||||||
|
|
||||||
|
- If `XDG_STATE_HOME` is set and non-empty:
|
||||||
|
- `$XDG_STATE_HOME/kte/swap/…`
|
||||||
|
- Otherwise, if `HOME` is set:
|
||||||
|
- `~/.local/state/kte/swap/…`
|
||||||
|
- Last resort fallback:
|
||||||
|
- `<system-temp>/kte/state/kte/swap/…` (via
|
||||||
|
`std::filesystem::temp_directory_path()`)
|
||||||
|
|
||||||
|
Swap files are always created with permissions `0600`.
|
||||||
|
|
||||||
|
### Swap file naming
|
||||||
|
|
||||||
|
For file-backed buffers, the swap filename is derived from the buffer’s
|
||||||
|
path:
|
||||||
|
|
||||||
|
1. Take a canonical-ish path key (`std::filesystem::weakly_canonical`,
|
||||||
|
else `absolute`, else the raw `Buffer::Filename()`).
|
||||||
|
2. Encode it so it’s human-identifiable:
|
||||||
|
- Strip one leading path separator (`/` or `\\`)
|
||||||
|
- Replace path separators (`/` and `\\`) with `!`
|
||||||
|
- Append `.swp`
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/home/kyle/tmp/test.txt -> home!kyle!tmp!test.txt.swp
|
||||||
|
```
|
||||||
|
|
||||||
|
If the resulting name would be long (over ~200 characters), kte falls
|
||||||
|
back to a shorter stable name:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<basename>.<fnv1a64(path-key-as-hex)>.swp
|
||||||
|
```
|
||||||
|
|
||||||
|
For unnamed/unsaved buffers, kte uses:
|
||||||
|
|
||||||
|
```text
|
||||||
|
unnamed-<pid>-<counter>.swp
|
||||||
|
```
|
||||||
|
|
||||||
|
## Lifecycle (when swap is written)
|
||||||
|
|
||||||
|
`kte::SwapManager` is owned by `Editor` (see `Editor.cc`). Buffers are
|
||||||
|
attached for journaling when they are added/opened.
|
||||||
|
|
||||||
|
- `SwapManager::Attach(Buffer*)` starts tracking a buffer and
|
||||||
|
establishes its swap path.
|
||||||
|
- `Buffer` emits swap events from its low-level edit APIs:
|
||||||
|
- `Buffer::insert_text()` calls `SwapRecorder::OnInsert()`
|
||||||
|
- `Buffer::delete_text()` calls `SwapRecorder::OnDelete()`
|
||||||
|
- `Buffer::split_line()` / `join_lines()` are represented as
|
||||||
|
insert/delete of `\n` (they do **not** emit `SPLIT`/`JOIN` records
|
||||||
|
in stage 1).
|
||||||
|
- `SwapManager::Detach(Buffer*)` flushes queued records, `fsync()`s, and
|
||||||
|
closes the journal.
|
||||||
|
- On `Save As` / filename changes,
|
||||||
|
`SwapManager::NotifyFilenameChanged(Buffer&)` closes the existing
|
||||||
|
journal and switches to a new path.
|
||||||
|
- Note: the old swap file is currently left on disk (no
|
||||||
|
cleanup/rotation yet).
|
||||||
|
|
||||||
|
## Durability and performance
|
||||||
|
|
||||||
|
Swap writing is best-effort and asynchronous:
|
||||||
|
|
||||||
|
- Records are queued from the UI/editing thread(s).
|
||||||
|
- A background writer thread wakes at least every
|
||||||
|
`SwapConfig::flush_interval_ms` (default `200ms`) to write any queued
|
||||||
|
records.
|
||||||
|
- `fsync()` is throttled to at most once per
|
||||||
|
`SwapConfig::fsync_interval_ms` (default `1000ms`) per open swap file.
|
||||||
|
- `SwapManager::Flush()` blocks until the queue is fully written; it is
|
||||||
|
primarily used by tests and shutdown paths.
|
||||||
|
|
||||||
|
If a crash happens while writing, the swap file may end with a partial
|
||||||
|
record. Replay detects truncation/CRC mismatch and fails safely.
|
||||||
|
|
||||||
|
## On-disk format (v1)
|
||||||
|
|
||||||
|
The file is:
|
||||||
|
|
||||||
|
1. A fixed-size 64-byte header
|
||||||
|
2. Followed by a stream of records
|
||||||
|
|
||||||
|
All multi-byte integers in the swap file are **little-endian**.
|
||||||
|
|
||||||
|
### Header (64 bytes)
|
||||||
|
|
||||||
|
Layout (stage 1):
|
||||||
|
|
||||||
|
- `magic` (8 bytes): `KTE_SWP\0`
|
||||||
|
- `version` (`u32`): currently `1`
|
||||||
|
- `flags` (`u32`): currently `0`
|
||||||
|
- `created_time` (`u64`): Unix seconds
|
||||||
|
- remaining bytes are reserved/padding (currently zeroed)
|
||||||
|
|
||||||
|
### Record framing
|
||||||
|
|
||||||
|
Each record is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[type: u8][len: u24][payload: len bytes][crc32: u32]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `len` is a 24-bit little-endian length of the payload (`0..0xFFFFFF`).
|
||||||
|
- `crc32` is computed over the 4-byte record header (`type + len`)
|
||||||
|
followed by the payload bytes.
|
||||||
|
|
||||||
|
### Record types
|
||||||
|
|
||||||
|
Type codes are defined in `SwapRecType` (`Swap.h`). Stage 1 primarily
|
||||||
|
emits:
|
||||||
|
|
||||||
|
- `INS` (`1`): insert bytes at `(row, col)`
|
||||||
|
- `DEL` (`2`): delete `len` bytes at `(row, col)`
|
||||||
|
|
||||||
|
Other type codes exist for forward compatibility (`SPLIT`, `JOIN`,
|
||||||
|
`META`, `CHKPT`), but are not produced by the current `SwapRecorder`
|
||||||
|
interface.
|
||||||
|
|
||||||
|
### Payload encoding (v1)
|
||||||
|
|
||||||
|
Every payload starts with:
|
||||||
|
|
||||||
|
```text
|
||||||
|
[encver: u8]
|
||||||
|
```
|
||||||
|
|
||||||
|
Currently `encver` must be `1`.
|
||||||
|
|
||||||
|
#### `INS` payload (encver = 1)
|
||||||
|
|
||||||
|
```text
|
||||||
|
[encver: u8 = 1]
|
||||||
|
[row: u32]
|
||||||
|
[col: u32]
|
||||||
|
[nbytes:u32]
|
||||||
|
[bytes: nbytes]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `DEL` payload (encver = 1)
|
||||||
|
|
||||||
|
```text
|
||||||
|
[encver: u8 = 1]
|
||||||
|
[row: u32]
|
||||||
|
[col: u32]
|
||||||
|
[len: u32]
|
||||||
|
```
|
||||||
|
|
||||||
|
`row`/`col` are 0-based and are interpreted the same way as
|
||||||
|
`Buffer::insert_text()` / `Buffer::delete_text()`.
|
||||||
|
|
||||||
|
## Replay / recovery
|
||||||
|
|
||||||
|
Swap replay is implemented as a low-level API:
|
||||||
|
|
||||||
|
-
|
||||||
|
|
||||||
|
`bool kte::SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &err)`
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- The caller supplies an **already-open** `Buffer` (typically loaded
|
||||||
|
from the on-disk file) and a swap path.
|
||||||
|
- `ReplayFile()` validates header magic/version, then iterates records.
|
||||||
|
- On a truncated file or CRC mismatch, it returns `false` and sets
|
||||||
|
`err`.
|
||||||
|
- On unknown record types, it ignores them (forward compatibility).
|
||||||
|
- On failure, the buffer may have had a prefix of records applied;
|
||||||
|
callers should treat this as “recovery failed”.
|
||||||
|
|
||||||
|
Important: if the buffer is currently attached to a `SwapManager`, you
|
||||||
|
should suspend/disable recording during replay (or detach first),
|
||||||
|
otherwise replayed edits would be re-journaled.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Swap behavior and format are validated by unit tests:
|
||||||
|
|
||||||
|
- `tests/test_swap_writer.cc` (header, permissions, record CRC framing)
|
||||||
|
- `tests/test_swap_replay.cc` (record replay and truncation handling)
|
||||||
@@ -11884,4 +11884,4 @@ static const unsigned int DefaultFontRegularCompressedData[72616 / 4] =
|
|||||||
0x0070a96e,
|
0x0070a96e,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5586,4 +5586,4 @@ static const unsigned int DefaultFontBoldCompressedData[32380 / 4] =
|
|||||||
0x0e01060e, 0xff01b82a, 0x8d04b085,
|
0x0e01060e, 0xff01b82a, 0x8d04b085,
|
||||||
0x440002b1, 0x066405b3, 0x00444400, 0x01000000, 0x00000000, 0xfacafa05, 0x00004aa6,
|
0x440002b1, 0x066405b3, 0x00444400, 0x01000000, 0x00000000, 0xfacafa05, 0x00004aa6,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5918,4 +5918,4 @@ static const unsigned int DefaultFontRegularCompressedData[34064 / 4] =
|
|||||||
0x20080082, 0x01060eb3, 0x01b82a0e,
|
0x20080082, 0x01060eb3, 0x01b82a0e,
|
||||||
0x04b085ff, 0x0002b18d, 0x6405b344, 0x44440006, 0x00000000, 0x00000001, 0xccfa0500, 0x0030ee4f,
|
0x04b085ff, 0x0002b18d, 0x6405b344, 0x44440006, 0x00000000, 0x00000001, 0xccfa0500, 0x0030ee4f,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2851,4 +2851,4 @@ static const unsigned int DefaultFontRegularCompressedData[68288 / 4] =
|
|||||||
0x820a2003, 0x421e20c3, 0x18821057,
|
0x820a2003, 0x421e20c3, 0x18821057,
|
||||||
0x21830284, 0xdeda0024, 0x0d83c5d7, 0xa48ad12b, 0x0000001e, 0xa48ad100, 0x62fa051e, 0x00a176d4,
|
0x21830284, 0xdeda0024, 0x0d83c5d7, 0xa48ad12b, 0x0000001e, 0xa48ad100, 0x62fa051e, 0x00a176d4,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,4 +37,4 @@ Font::Load(const float size) const
|
|||||||
|
|
||||||
io.Fonts->Build();
|
io.Fonts->Build();
|
||||||
}
|
}
|
||||||
} // namespace kte::Fonts
|
} // namespace kte::Fonts
|
||||||
|
|||||||
@@ -119,4 +119,4 @@ private:
|
|||||||
|
|
||||||
|
|
||||||
void InstallDefaultFonts();
|
void InstallDefaultFonts();
|
||||||
}
|
}
|
||||||
|
|||||||
25743
fonts/Go.h
25743
fonts/Go.h
File diff suppressed because it is too large
Load Diff
@@ -13671,4 +13671,4 @@ static const unsigned int DefaultFontItalicCompressedData[84884 / 4] =
|
|||||||
0x4f1aea4f, 0x0d2022dc, 0x211fdc4f,
|
0x4f1aea4f, 0x0d2022dc, 0x211fdc4f,
|
||||||
0x164f2804, 0x8b952711, 0x236f6f19, 0xfa050081, 0x0bda00e5,
|
0x164f2804, 0x8b952711, 0x236f6f19, 0xfa050081, 0x0bda00e5,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6231,4 +6231,4 @@ static const unsigned int DefaultFontRegularCompressedData[149388 / 4] =
|
|||||||
0x86382043, 0x870f8383, 0x022e2463,
|
0x86382043, 0x870f8383, 0x022e2463,
|
||||||
0x05480066, 0x599623fa, 0x00000043,
|
0x05480066, 0x599623fa, 0x00000043,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5632,4 +5632,4 @@ static const unsigned int DefaultFontRegularCompressedData[66932 / 4] =
|
|||||||
0xc6221786, 0x1788a203, 0x86008524,
|
0xc6221786, 0x1788a203, 0x86008524,
|
||||||
0x2f88e200, 0x1788a520, 0xf6ffb424, 0xfa05fa00, 0xc234b8e9,
|
0x2f88e200, 0x1788a520, 0xf6ffb424, 0xfa05fa00, 0xc234b8e9,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5737,4 +5737,4 @@ static const unsigned int DefaultFontBoldCompressedData[68920 / 4] =
|
|||||||
0x86910006, 0x03c32217, 0x241788b7, 0x00ce0085, 0x202f88e4, 0x261788b5, 0x00ebffb4, 0x050000fe, 0x877646fa,
|
0x86910006, 0x03c32217, 0x241788b7, 0x00ce0085, 0x202f88e4, 0x261788b5, 0x00ebffb4, 0x050000fe, 0x877646fa,
|
||||||
0x000000de,
|
0x000000de,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
22065
fonts/Triplicate.h
22065
fonts/Triplicate.h
File diff suppressed because it is too large
Load Diff
27
main.cc
27
main.cc
@@ -207,6 +207,9 @@ main(int argc, char *argv[])
|
|||||||
// Open files passed on the CLI; support +N to jump to line N in the next file.
|
// Open files passed on the CLI; support +N to jump to line N in the next file.
|
||||||
// If no files are provided, create an empty buffer.
|
// If no files are provided, create an empty buffer.
|
||||||
if (optind < argc) {
|
if (optind < argc) {
|
||||||
|
// Seed a scratch buffer so the UI has something to show while deferred opens
|
||||||
|
// (and potential swap recovery prompts) are processed.
|
||||||
|
editor.AddBuffer(Buffer());
|
||||||
std::size_t pending_line = 0; // 0 = no pending line
|
std::size_t pending_line = 0; // 0 = no pending line
|
||||||
for (int i = optind; i < argc; ++i) {
|
for (int i = optind; i < argc; ++i) {
|
||||||
const char *arg = argv[i];
|
const char *arg = argv[i];
|
||||||
@@ -242,29 +245,9 @@ main(int argc, char *argv[])
|
|||||||
// Fall through: not a +number, treat as filename starting with '+'
|
// Fall through: not a +number, treat as filename starting with '+'
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string err;
|
|
||||||
const std::string path = arg;
|
const std::string path = arg;
|
||||||
if (!editor.OpenFile(path, err)) {
|
editor.RequestOpenFile(path, pending_line);
|
||||||
editor.SetStatus("open: " + err);
|
pending_line = 0; // consumed (if set)
|
||||||
std::cerr << "kte: " << err << "\n";
|
|
||||||
} else if (pending_line > 0) {
|
|
||||||
// Apply pending +N to the just-opened (current) buffer
|
|
||||||
if (Buffer *b = editor.CurrentBuffer()) {
|
|
||||||
std::size_t nrows = b->Nrows();
|
|
||||||
std::size_t line = pending_line > 0 ? pending_line - 1 : 0;
|
|
||||||
// 1-based to 0-based
|
|
||||||
if (nrows > 0) {
|
|
||||||
if (line >= nrows)
|
|
||||||
line = nrows - 1;
|
|
||||||
} else {
|
|
||||||
line = 0;
|
|
||||||
}
|
|
||||||
b->SetCursor(0, line);
|
|
||||||
// Do not force viewport offsets here; the frontend/renderer
|
|
||||||
// will establish dimensions and normalize visibility on first draw.
|
|
||||||
}
|
|
||||||
pending_line = 0; // consumed
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// If we ended with a pending +N but no subsequent file, ignore it.
|
// If we ended with a pending +N but no subsequent file, ignore it.
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -60,11 +60,10 @@ CppHighlighter::HighlightLineStateful(const Buffer &buf,
|
|||||||
const LineState &prev,
|
const LineState &prev,
|
||||||
std::vector<HighlightSpan> &out) const
|
std::vector<HighlightSpan> &out) const
|
||||||
{
|
{
|
||||||
const auto &rows = buf.Rows();
|
|
||||||
StatefulHighlighter::LineState state = prev;
|
StatefulHighlighter::LineState state = prev;
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||||
return state;
|
return state;
|
||||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||||
if (s.empty())
|
if (s.empty())
|
||||||
return state;
|
return state;
|
||||||
|
|
||||||
@@ -143,7 +142,7 @@ CppHighlighter::HighlightLineStateful(const Buffer &buf,
|
|||||||
bool closed = false;
|
bool closed = false;
|
||||||
while (j + 1 <= n) {
|
while (j + 1 <= n) {
|
||||||
if (j + 1 < n && s[j] == '*' && s[j + 1] == '/') {
|
if (j + 1 < n && s[j] == '*' && s[j + 1] == '/') {
|
||||||
j += 2;
|
j += 2;
|
||||||
closed = true;
|
closed = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,4 +32,4 @@ private:
|
|||||||
|
|
||||||
static bool is_ident_char(char c);
|
static bool is_ident_char(char c);
|
||||||
};
|
};
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
|
|||||||
@@ -40,10 +40,9 @@ ErlangHighlighter::ErlangHighlighter()
|
|||||||
void
|
void
|
||||||
ErlangHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
ErlangHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||||
{
|
{
|
||||||
const auto &rows = buf.Rows();
|
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
|
||||||
return;
|
return;
|
||||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||||
int n = static_cast<int>(s.size());
|
int n = static_cast<int>(s.size());
|
||||||
int i = 0;
|
int i = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -14,4 +14,4 @@ public:
|
|||||||
private:
|
private:
|
||||||
std::unordered_set<std::string> kws_;
|
std::unordered_set<std::string> kws_;
|
||||||
};
|
};
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
|
|||||||
@@ -40,10 +40,9 @@ ForthHighlighter::ForthHighlighter()
|
|||||||
void
|
void
|
||||||
ForthHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
ForthHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||||
{
|
{
|
||||||
const auto &rows = buf.Rows();
|
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
|
||||||
return;
|
return;
|
||||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||||
int n = static_cast<int>(s.size());
|
int n = static_cast<int>(s.size());
|
||||||
int i = 0;
|
int i = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -14,4 +14,4 @@ public:
|
|||||||
private:
|
private:
|
||||||
std::unordered_set<std::string> kws_;
|
std::unordered_set<std::string> kws_;
|
||||||
};
|
};
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
|
|||||||
@@ -46,10 +46,9 @@ GoHighlighter::GoHighlighter()
|
|||||||
void
|
void
|
||||||
GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||||
{
|
{
|
||||||
const auto &rows = buf.Rows();
|
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
|
||||||
return;
|
return;
|
||||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||||
int n = static_cast<int>(s.size());
|
int n = static_cast<int>(s.size());
|
||||||
int i = 0;
|
int i = 0;
|
||||||
int bol = 0;
|
int bol = 0;
|
||||||
@@ -75,7 +74,7 @@ GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSp
|
|||||||
bool closed = false;
|
bool closed = false;
|
||||||
while (j + 1 <= n) {
|
while (j + 1 <= n) {
|
||||||
if (j + 1 < n && s[j] == '*' && s[j + 1] == '/') {
|
if (j + 1 < n && s[j] == '*' && s[j + 1] == '/') {
|
||||||
j += 2;
|
j += 2;
|
||||||
closed = true;
|
closed = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,4 +15,4 @@ private:
|
|||||||
std::unordered_set<std::string> kws_;
|
std::unordered_set<std::string> kws_;
|
||||||
std::unordered_set<std::string> types_;
|
std::unordered_set<std::string> types_;
|
||||||
};
|
};
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ HighlighterEngine::GetLine(const Buffer &buf, int row, std::uint64_t buf_version
|
|||||||
// Only use cached state if it's for the current version and row still exists
|
// Only use cached state if it's for the current version and row still exists
|
||||||
if (r <= row - 1 && kv.second.version == buf_version) {
|
if (r <= row - 1 && kv.second.version == buf_version) {
|
||||||
// Validate that the cached row index is still valid in the buffer
|
// Validate that the cached row index is still valid in the buffer
|
||||||
if (r >= 0 && static_cast<std::size_t>(r) < buf.Rows().size()) {
|
if (r >= 0 && static_cast<std::size_t>(r) < buf.Nrows()) {
|
||||||
if (r > best)
|
if (r > best)
|
||||||
best = r;
|
best = r;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,4 +87,4 @@ private:
|
|||||||
|
|
||||||
void worker_loop() const;
|
void worker_loop() const;
|
||||||
};
|
};
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
|
|||||||
@@ -244,4 +244,4 @@ HighlighterRegistry::RegisterTreeSitter(std::string_view filetype,
|
|||||||
}, /*override_existing=*/true);
|
}, /*override_existing=*/true);
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
|
|||||||
@@ -44,4 +44,4 @@ public:
|
|||||||
const TSLanguage * (*get_language)());
|
const TSLanguage * (*get_language)());
|
||||||
#endif
|
#endif
|
||||||
};
|
};
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
|
|||||||
@@ -13,10 +13,9 @@ is_digit(char c)
|
|||||||
void
|
void
|
||||||
JSONHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
JSONHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||||
{
|
{
|
||||||
const auto &rows = buf.Rows();
|
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
|
||||||
return;
|
return;
|
||||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||||
int n = static_cast<int>(s.size());
|
int n = static_cast<int>(s.size());
|
||||||
auto push = [&](int a, int b, TokenKind k) {
|
auto push = [&](int a, int b, TokenKind k) {
|
||||||
if (b > a)
|
if (b > a)
|
||||||
|
|||||||
@@ -9,4 +9,4 @@ class JSONHighlighter final : public LanguageHighlighter {
|
|||||||
public:
|
public:
|
||||||
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
|
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
|
||||||
};
|
};
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
|
|||||||
@@ -48,4 +48,4 @@ public:
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
|
|||||||
@@ -25,10 +25,9 @@ LispHighlighter::LispHighlighter()
|
|||||||
void
|
void
|
||||||
LispHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
LispHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||||
{
|
{
|
||||||
const auto &rows = buf.Rows();
|
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
|
||||||
return;
|
return;
|
||||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||||
int n = static_cast<int>(s.size());
|
int n = static_cast<int>(s.size());
|
||||||
int i = 0;
|
int i = 0;
|
||||||
int bol = 0;
|
int bol = 0;
|
||||||
|
|||||||
@@ -14,4 +14,4 @@ public:
|
|||||||
private:
|
private:
|
||||||
std::unordered_set<std::string> kws_;
|
std::unordered_set<std::string> kws_;
|
||||||
};
|
};
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
|
|||||||
@@ -24,10 +24,9 @@ MarkdownHighlighter::HighlightLineStateful(const Buffer &buf, int row, const Lin
|
|||||||
std::vector<HighlightSpan> &out) const
|
std::vector<HighlightSpan> &out) const
|
||||||
{
|
{
|
||||||
StatefulHighlighter::LineState state = prev;
|
StatefulHighlighter::LineState state = prev;
|
||||||
const auto &rows = buf.Rows();
|
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
|
||||||
return state;
|
return state;
|
||||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||||
int n = static_cast<int>(s.size());
|
int n = static_cast<int>(s.size());
|
||||||
|
|
||||||
// Reuse in_block_comment flag as "in fenced code" state.
|
// Reuse in_block_comment flag as "in fenced code" state.
|
||||||
|
|||||||
@@ -11,4 +11,4 @@ public:
|
|||||||
LineState HighlightLineStateful(const Buffer &buf, int row, const LineState &prev,
|
LineState HighlightLineStateful(const Buffer &buf, int row, const LineState &prev,
|
||||||
std::vector<HighlightSpan> &out) const override;
|
std::vector<HighlightSpan> &out) const override;
|
||||||
};
|
};
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ namespace kte {
|
|||||||
void
|
void
|
||||||
NullHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
NullHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||||
{
|
{
|
||||||
const auto &rows = buf.Rows();
|
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
|
||||||
return;
|
return;
|
||||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||||
int n = static_cast<int>(s.size());
|
int n = static_cast<int>(s.size());
|
||||||
if (n <= 0)
|
if (n <= 0)
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -8,4 +8,4 @@ class NullHighlighter final : public LanguageHighlighter {
|
|||||||
public:
|
public:
|
||||||
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
|
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
|
||||||
};
|
};
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
|
|||||||
@@ -50,10 +50,9 @@ PythonHighlighter::HighlightLineStateful(const Buffer &buf, int row, const LineS
|
|||||||
std::vector<HighlightSpan> &out) const
|
std::vector<HighlightSpan> &out) const
|
||||||
{
|
{
|
||||||
StatefulHighlighter::LineState state = prev;
|
StatefulHighlighter::LineState state = prev;
|
||||||
const auto &rows = buf.Rows();
|
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
|
||||||
return state;
|
return state;
|
||||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||||
int n = static_cast<int>(s.size());
|
int n = static_cast<int>(s.size());
|
||||||
|
|
||||||
// Triple-quoted string continuation uses in_raw_string with raw_delim either "'''" or "\"\"\""
|
// Triple-quoted string continuation uses in_raw_string with raw_delim either "'''" or "\"\"\""
|
||||||
|
|||||||
@@ -17,4 +17,4 @@ public:
|
|||||||
private:
|
private:
|
||||||
std::unordered_set<std::string> kws_;
|
std::unordered_set<std::string> kws_;
|
||||||
};
|
};
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
|
|||||||
@@ -47,10 +47,9 @@ RustHighlighter::RustHighlighter()
|
|||||||
void
|
void
|
||||||
RustHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
RustHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||||
{
|
{
|
||||||
const auto &rows = buf.Rows();
|
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
|
||||||
return;
|
return;
|
||||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||||
int n = static_cast<int>(s.size());
|
int n = static_cast<int>(s.size());
|
||||||
int i = 0;
|
int i = 0;
|
||||||
while (i < n) {
|
while (i < n) {
|
||||||
@@ -72,7 +71,7 @@ RustHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<Highlight
|
|||||||
bool closed = false;
|
bool closed = false;
|
||||||
while (j + 1 <= n) {
|
while (j + 1 <= n) {
|
||||||
if (j + 1 < n && s[j] == '*' && s[j + 1] == '/') {
|
if (j + 1 < n && s[j] == '*' && s[j + 1] == '/') {
|
||||||
j += 2;
|
j += 2;
|
||||||
closed = true;
|
closed = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,4 +15,4 @@ private:
|
|||||||
std::unordered_set<std::string> kws_;
|
std::unordered_set<std::string> kws_;
|
||||||
std::unordered_set<std::string> types_;
|
std::unordered_set<std::string> types_;
|
||||||
};
|
};
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
|
|||||||
@@ -14,10 +14,9 @@ push(std::vector<HighlightSpan> &out, int a, int b, TokenKind k)
|
|||||||
void
|
void
|
||||||
ShellHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
ShellHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||||
{
|
{
|
||||||
const auto &rows = buf.Rows();
|
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
|
||||||
return;
|
return;
|
||||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||||
int n = static_cast<int>(s.size());
|
int n = static_cast<int>(s.size());
|
||||||
int i = 0;
|
int i = 0;
|
||||||
// if first non-space is '#', whole line is comment
|
// if first non-space is '#', whole line is comment
|
||||||
|
|||||||
@@ -8,4 +8,4 @@ class ShellHighlighter final : public LanguageHighlighter {
|
|||||||
public:
|
public:
|
||||||
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
|
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
|
||||||
};
|
};
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
|
|||||||
@@ -47,10 +47,9 @@ SqlHighlighter::SqlHighlighter()
|
|||||||
void
|
void
|
||||||
SqlHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
SqlHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||||
{
|
{
|
||||||
const auto &rows = buf.Rows();
|
if (row < 0 || static_cast<std::size_t>(row) >= buf.Nrows())
|
||||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size())
|
|
||||||
return;
|
return;
|
||||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
std::string s = buf.GetLineString(static_cast<std::size_t>(row));
|
||||||
int n = static_cast<int>(s.size());
|
int n = static_cast<int>(s.size());
|
||||||
int i = 0;
|
int i = 0;
|
||||||
|
|
||||||
@@ -75,7 +74,7 @@ SqlHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightS
|
|||||||
bool closed = false;
|
bool closed = false;
|
||||||
while (j + 1 <= n) {
|
while (j + 1 <= n) {
|
||||||
if (j + 1 < n && s[j] == '*' && s[j + 1] == '/') {
|
if (j + 1 < n && s[j] == '*' && s[j + 1] == '/') {
|
||||||
j += 2;
|
j += 2;
|
||||||
closed = true;
|
closed = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,4 +15,4 @@ private:
|
|||||||
std::unordered_set<std::string> kws_;
|
std::unordered_set<std::string> kws_;
|
||||||
std::unordered_set<std::string> types_;
|
std::unordered_set<std::string> types_;
|
||||||
};
|
};
|
||||||
} // namespace kte
|
} // namespace kte
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user