Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18ce005ec0 | ||
|
|
22f7573361 | ||
|
|
96e82cc6dd | ||
|
|
480f1390c7 | ||
|
|
3126a5e523 | ||
|
|
b60a8dc491 | ||
|
|
056c9af38e | ||
|
|
6413e14455 | ||
|
|
d1a45581bf | ||
|
|
81a5c25071 | ||
|
|
5667a6d7bd | ||
|
|
99c4bb2066 | ||
|
|
953fee97d7 | ||
|
|
d7e35727f1 | ||
|
|
23f04e4357 | ||
|
|
0585edad9e | ||
|
|
8712ea673d | ||
|
|
3148e16cf8 | ||
|
|
34eaa72033 | ||
|
|
f49f1698f4 | ||
|
|
f4b3188069 | ||
|
|
2571ab79c1 | ||
|
|
d768e56727 | ||
|
|
11c523ad52 | ||
|
|
c261261e26 | ||
|
|
27dcb41857 | ||
|
|
bc3433e988 | ||
|
|
690c51b0f3 | ||
|
|
0d87bc0b25 | ||
|
|
daeeecb342 | ||
|
|
a428b204a0 | ||
|
|
a21409e689 | ||
|
|
b0b5b55dce | ||
|
|
422b27b1ba | ||
|
|
9485d2aa24 | ||
|
|
8a6b7851d5 | ||
|
|
8ec0d6ac41 | ||
|
|
337b585ba0 | ||
|
|
95a588b0df | ||
|
|
199d7a20f7 | ||
|
|
44827fe53f | ||
|
|
2a6ff2a862 | ||
|
|
895e4ccb1e | ||
|
|
15b350bfaa | ||
|
|
cc8df36bdf | ||
|
|
1c0f04f076 | ||
|
|
ac0eadc345 | ||
|
|
f3bdced3d4 | ||
|
|
2551388420 | ||
|
|
d2d155f211 | ||
|
|
8634eb78f0 | ||
|
|
6eb240a0c4 | ||
|
|
4c402f5ef3 | ||
|
|
a8abda4b87 | ||
|
|
7347556aa2 | ||
|
|
289e155c98 | ||
|
|
147a52f3d4 | ||
|
|
dda7541e2f | ||
|
|
2408f5494c | ||
|
|
2542690eca | ||
|
|
cc0c187481 | ||
|
|
a8dcfbec58 | ||
|
|
65705e3354 | ||
|
|
e1f9a9eb6a | ||
|
|
c9f34003f2 | ||
|
|
f450ef825c | ||
|
|
f6f0c11be4 | ||
|
|
657c9bbc19 | ||
|
|
3493695165 | ||
|
|
5f57cf23dc | ||
|
|
9312550be4 | ||
|
|
f734f98891 | ||
|
|
1191e14ce9 | ||
|
|
12cc04d7e0 | ||
|
|
3f4c60d311 | ||
|
|
71c1c9e50b | ||
|
|
afb6888c31 | ||
|
|
222f73252b | ||
|
|
51ea473a91 | ||
|
|
fd517b5d57 | ||
|
|
952e1ed3f2 | ||
|
|
7069943df5 | ||
|
|
ee2c9939d7 | ||
|
|
f5a4625652 | ||
|
|
37472c71ec | ||
|
|
5ff4b2ed3e | ||
|
|
ab2f9918f3 | ||
|
|
d2b53601e2 | ||
|
|
78b9345799 | ||
|
|
495183ebd2 | ||
|
|
998b1b9817 | ||
|
|
dc2cf4c0a6 | ||
|
|
f6c4a5ab34 | ||
|
|
35ef74910d | ||
|
|
b17672d440 |
Generated
+1
@@ -1,5 +1,6 @@
|
||||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
<option name="PREFERRED_PROJECT_CODE_STYLE" value="sccl" />
|
||||
</state>
|
||||
</component>
|
||||
Generated
+2
-1
@@ -19,7 +19,7 @@
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppBoostFormatTooManyArgs/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCStyleCast/@EntryIndexedValue" value="SUGGESTION" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppCVQualifierCanNotBeAppliedToReference/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassCanBeFinal/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassCanBeFinal/@EntryIndexedValue" value="DO_NOT_SHOW" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassIsIncomplete/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassNeedsConstructorBecauseOfUninitializedMember/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppClassNeverUsed/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
@@ -58,6 +58,7 @@
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultInitializationWithNoUserConstructor/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultIsUsedAsIdentifier/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefaultedSpecialMemberFunctionIsImplicitlyDeleted/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDefinitionsOrder/@EntryIndexedValue" value="HINT" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDeletingVoidPointer/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDependentTemplateWithoutTemplateKeyword/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
<option name="/Default/CodeInspection/Highlighting/InspectionSeverities/=CppDependentTypeWithoutTypenameKeyword/@EntryIndexedValue" value="WARNING" type="string" />
|
||||
|
||||
Generated
+1
-1
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module classpath="CMake" type="CPP_MODULE" version="4">
|
||||
<module classpath="CIDR" type="CPP_MODULE" version="4">
|
||||
<component name="FacetManager">
|
||||
<facet type="Python" name="Python facet">
|
||||
<configuration sdkName="" />
|
||||
|
||||
+22
-12
@@ -1,28 +1,35 @@
|
||||
# Project Guidelines
|
||||
|
||||
kte is Kyle's Text Editor — a simple, fast text editor written in C++17. It
|
||||
replaces the earlier C implementation, ke (see the ke manual in `docs/ke.md`). The
|
||||
design draws inspiration from Antirez' kilo, with keybindings rooted in the
|
||||
kte is Kyle's Text Editor — a simple, fast text editor written in C++20.
|
||||
It
|
||||
replaces the earlier C implementation, ke (see the ke manual in
|
||||
`docs/ke.md`). The
|
||||
design draws inspiration from Antirez' kilo, with keybindings rooted in
|
||||
the
|
||||
WordStar/VDE family and emacs. The spiritual parent is `mg(1)`.
|
||||
|
||||
These guidelines summarize the goals, interfaces, key operations, and current
|
||||
These guidelines summarize the goals, interfaces, key operations, and
|
||||
current
|
||||
development practices for kte.
|
||||
|
||||
## Goals
|
||||
|
||||
- Keep the core small, fast, and understandable.
|
||||
- Provide an ncurses-based terminal-first editing experience, with an additional ImGui GUI.
|
||||
- Provide an ncurses-based terminal-first editing experience, with an
|
||||
additional ImGui GUI.
|
||||
- Preserve familiar keybindings from ke while modernizing the internals.
|
||||
- Favor simple data structures (e.g., piece table) and incremental evolution.
|
||||
- Favor simple data structures (e.g., piece table) and incremental
|
||||
evolution.
|
||||
|
||||
Project entry point: `main.cpp`
|
||||
|
||||
## Core Components (current codebase)
|
||||
|
||||
- Buffer: editing model and file I/O (`Buffer.h/.cpp`).
|
||||
- GapBuffer: editable in-memory text representation (`GapBuffer.h/.cpp`).
|
||||
- PieceTable: experimental/alternative representation (`PieceTable.h/.cpp`).
|
||||
- InputHandler: interface for handling text input (`InputHandler.h/`), along
|
||||
- PieceTable: editable in-memory text representation (
|
||||
`PieceTable.h/.cpp`).
|
||||
- InputHandler: interface for handling text input (`InputHandler.h/`),
|
||||
along
|
||||
with `TerminalInputHandler` (ncurses-based) and `GUIInputHandler`.
|
||||
- Renderer: interface for rendering text (`Renderer.h`), along with
|
||||
`TerminalRenderer` (ncurses-based) and `GUIRenderer`.
|
||||
@@ -36,13 +43,16 @@ The file `docs/ke.md` contains the canonical reference for keybindings.
|
||||
|
||||
## Contributing/Development Notes
|
||||
|
||||
- C++ standard: C++17.
|
||||
- C++ standard: C++20.
|
||||
- Keep dependencies minimal.
|
||||
- Prefer small, focused changes that preserve ke’s UX unless explicitly changing
|
||||
- Prefer small, focused changes that preserve ke’s UX unless explicitly
|
||||
changing
|
||||
behavior.
|
||||
|
||||
## References
|
||||
|
||||
- Previous editor manual: `ke.md` (canonical keybinding/spec reference for now).
|
||||
- Previous editor manual: `ke.md` (canonical keybinding/spec reference
|
||||
for now).
|
||||
- Inspiration: kilo, WordStar/VDE, emacs, `mg(1)`.
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
/*
|
||||
* AppendBuffer.h - selector header to choose GapBuffer or PieceTable
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#ifdef KTE_USE_PIECE_TABLE
|
||||
#include "PieceTable.h"
|
||||
using AppendBuffer = PieceTable;
|
||||
#else
|
||||
#include "GapBuffer.h"
|
||||
using AppendBuffer = GapBuffer;
|
||||
#endif
|
||||
@@ -2,10 +2,25 @@
|
||||
#include <sstream>
|
||||
#include <filesystem>
|
||||
#include <cstdlib>
|
||||
#include <limits>
|
||||
#include <cerrno>
|
||||
#include <cstring>
|
||||
#include <string_view>
|
||||
|
||||
#include <vector>
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
#include "Buffer.h"
|
||||
#include "SwapRecorder.h"
|
||||
#include "UndoSystem.h"
|
||||
#include "UndoTree.h"
|
||||
#include "ErrorHandler.h"
|
||||
#include "SyscallWrappers.h"
|
||||
#include "ErrorRecovery.h"
|
||||
// For reconstructing highlighter state on copies
|
||||
#include "syntax/HighlighterRegistry.h"
|
||||
#include "syntax/NullHighlighter.h"
|
||||
@@ -19,6 +34,177 @@ 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 = kte::syscall::Open(dir.c_str(), O_RDONLY);
|
||||
if (dfd < 0)
|
||||
return;
|
||||
(void) kte::syscall::Fsync(dfd);
|
||||
(void) kte::syscall::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');
|
||||
|
||||
// Retry on transient errors for temp file creation
|
||||
int fd = -1;
|
||||
auto mkstemp_fn = [&]() -> bool {
|
||||
// Reset buffer for each retry attempt
|
||||
buf.assign(tmpl_s.begin(), tmpl_s.end());
|
||||
buf.push_back('\0');
|
||||
fd = kte::syscall::Mkstemp(buf.data());
|
||||
return fd >= 0;
|
||||
};
|
||||
|
||||
if (!kte::RetryOnTransientError(mkstemp_fn, kte::RetryPolicy::Aggressive(), err)) {
|
||||
if (fd < 0) {
|
||||
err = std::string("Failed to create temp file for save: ") + std::strerror(errno) + err;
|
||||
}
|
||||
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) kte::syscall::Fchmod(fd, dst_st.st_mode);
|
||||
}
|
||||
|
||||
bool ok = write_all_fd(fd, data, len, err);
|
||||
if (ok) {
|
||||
// Retry fsync on transient errors
|
||||
auto fsync_fn = [&]() -> bool {
|
||||
return kte::syscall::Fsync(fd) == 0;
|
||||
};
|
||||
|
||||
std::string fsync_err;
|
||||
if (!kte::RetryOnTransientError(fsync_fn, kte::RetryPolicy::Aggressive(), fsync_err)) {
|
||||
err = std::string("fsync failed: ") + std::strerror(errno) + fsync_err;
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
(void) kte::syscall::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)
|
||||
{
|
||||
std::string err;
|
||||
@@ -29,24 +215,29 @@ Buffer::Buffer(const std::string &path)
|
||||
// Copy constructor/assignment: perform a deep copy of core fields; reinitialize undo for the new buffer.
|
||||
Buffer::Buffer(const Buffer &other)
|
||||
{
|
||||
curx_ = other.curx_;
|
||||
cury_ = other.cury_;
|
||||
rx_ = other.rx_;
|
||||
nrows_ = other.nrows_;
|
||||
rowoffs_ = other.rowoffs_;
|
||||
coloffs_ = other.coloffs_;
|
||||
rows_ = other.rows_;
|
||||
filename_ = other.filename_;
|
||||
is_file_backed_ = other.is_file_backed_;
|
||||
dirty_ = other.dirty_;
|
||||
read_only_ = other.read_only_;
|
||||
mark_set_ = other.mark_set_;
|
||||
mark_curx_ = other.mark_curx_;
|
||||
mark_cury_ = other.mark_cury_;
|
||||
// Copy syntax/highlighting flags
|
||||
version_ = other.version_;
|
||||
syntax_enabled_ = other.syntax_enabled_;
|
||||
filetype_ = other.filetype_;
|
||||
curx_ = other.curx_;
|
||||
cury_ = other.cury_;
|
||||
rx_ = other.rx_;
|
||||
nrows_ = other.nrows_;
|
||||
rowoffs_ = other.rowoffs_;
|
||||
coloffs_ = other.coloffs_;
|
||||
rows_ = other.rows_;
|
||||
content_ = other.content_;
|
||||
rows_cache_dirty_ = other.rows_cache_dirty_;
|
||||
filename_ = other.filename_;
|
||||
is_file_backed_ = other.is_file_backed_;
|
||||
dirty_ = other.dirty_;
|
||||
read_only_ = other.read_only_;
|
||||
mark_set_ = other.mark_set_;
|
||||
mark_curx_ = other.mark_curx_;
|
||||
mark_cury_ = other.mark_cury_;
|
||||
// Copy edit mode + syntax/highlighting flags
|
||||
edit_mode_ = other.edit_mode_;
|
||||
edit_mode_detected_ = other.edit_mode_detected_;
|
||||
version_ = other.version_;
|
||||
syntax_enabled_ = other.syntax_enabled_;
|
||||
syntax_user_override_ = other.syntax_user_override_;
|
||||
filetype_ = other.filetype_;
|
||||
// Fresh undo system for the copy
|
||||
undo_tree_ = std::make_unique<UndoTree>();
|
||||
undo_sys_ = std::make_unique<UndoSystem>(*this, *undo_tree_);
|
||||
@@ -77,23 +268,28 @@ Buffer::operator=(const Buffer &other)
|
||||
{
|
||||
if (this == &other)
|
||||
return *this;
|
||||
curx_ = other.curx_;
|
||||
cury_ = other.cury_;
|
||||
rx_ = other.rx_;
|
||||
nrows_ = other.nrows_;
|
||||
rowoffs_ = other.rowoffs_;
|
||||
coloffs_ = other.coloffs_;
|
||||
rows_ = other.rows_;
|
||||
filename_ = other.filename_;
|
||||
is_file_backed_ = other.is_file_backed_;
|
||||
dirty_ = other.dirty_;
|
||||
read_only_ = other.read_only_;
|
||||
mark_set_ = other.mark_set_;
|
||||
mark_curx_ = other.mark_curx_;
|
||||
mark_cury_ = other.mark_cury_;
|
||||
version_ = other.version_;
|
||||
syntax_enabled_ = other.syntax_enabled_;
|
||||
filetype_ = other.filetype_;
|
||||
curx_ = other.curx_;
|
||||
cury_ = other.cury_;
|
||||
rx_ = other.rx_;
|
||||
nrows_ = other.nrows_;
|
||||
rowoffs_ = other.rowoffs_;
|
||||
coloffs_ = other.coloffs_;
|
||||
rows_ = other.rows_;
|
||||
content_ = other.content_;
|
||||
rows_cache_dirty_ = other.rows_cache_dirty_;
|
||||
filename_ = other.filename_;
|
||||
is_file_backed_ = other.is_file_backed_;
|
||||
dirty_ = other.dirty_;
|
||||
read_only_ = other.read_only_;
|
||||
mark_set_ = other.mark_set_;
|
||||
mark_curx_ = other.mark_curx_;
|
||||
mark_cury_ = other.mark_cury_;
|
||||
edit_mode_ = other.edit_mode_;
|
||||
edit_mode_detected_ = other.edit_mode_detected_;
|
||||
version_ = other.version_;
|
||||
syntax_enabled_ = other.syntax_enabled_;
|
||||
syntax_user_override_ = other.syntax_user_override_;
|
||||
filetype_ = other.filetype_;
|
||||
// Recreate undo system for this instance
|
||||
undo_tree_ = std::make_unique<UndoTree>();
|
||||
undo_sys_ = std::make_unique<UndoSystem>(*this, *undo_tree_);
|
||||
@@ -133,14 +329,28 @@ Buffer::Buffer(Buffer &&other) noexcept
|
||||
mark_set_(other.mark_set_),
|
||||
mark_curx_(other.mark_curx_),
|
||||
mark_cury_(other.mark_cury_),
|
||||
visual_line_active_(other.visual_line_active_),
|
||||
visual_line_anchor_y_(other.visual_line_anchor_y_),
|
||||
visual_line_active_y_(other.visual_line_active_y_),
|
||||
undo_tree_(std::move(other.undo_tree_)),
|
||||
undo_sys_(std::move(other.undo_sys_))
|
||||
{
|
||||
// Move syntax/highlighting state
|
||||
version_ = other.version_;
|
||||
syntax_enabled_ = other.syntax_enabled_;
|
||||
filetype_ = std::move(other.filetype_);
|
||||
highlighter_ = std::move(other.highlighter_);
|
||||
// Move edit mode + syntax/highlighting state
|
||||
edit_mode_ = other.edit_mode_;
|
||||
edit_mode_detected_ = other.edit_mode_detected_;
|
||||
version_ = other.version_;
|
||||
syntax_enabled_ = other.syntax_enabled_;
|
||||
syntax_user_override_ = other.syntax_user_override_;
|
||||
filetype_ = std::move(other.filetype_);
|
||||
highlighter_ = std::move(other.highlighter_);
|
||||
content_ = std::move(other.content_);
|
||||
rows_cache_dirty_ = other.rows_cache_dirty_;
|
||||
on_disk_identity_ = other.on_disk_identity_;
|
||||
// Non-owning: the recorder object itself is owned by SwapManager and outlives
|
||||
// this move. The caller (Editor) is responsible for calling SwapManager::Rehome()
|
||||
// so the journal's Buffer* key follows this object to its new address.
|
||||
swap_rec_ = other.swap_rec_;
|
||||
other.swap_rec_ = nullptr;
|
||||
// Update UndoSystem's buffer reference to point to this object
|
||||
if (undo_sys_) {
|
||||
undo_sys_->UpdateBufferReference(*this);
|
||||
@@ -155,29 +365,42 @@ Buffer::operator=(Buffer &&other) noexcept
|
||||
if (this == &other)
|
||||
return *this;
|
||||
|
||||
curx_ = other.curx_;
|
||||
cury_ = other.cury_;
|
||||
rx_ = other.rx_;
|
||||
nrows_ = other.nrows_;
|
||||
rowoffs_ = other.rowoffs_;
|
||||
coloffs_ = other.coloffs_;
|
||||
rows_ = std::move(other.rows_);
|
||||
filename_ = std::move(other.filename_);
|
||||
is_file_backed_ = other.is_file_backed_;
|
||||
dirty_ = other.dirty_;
|
||||
read_only_ = other.read_only_;
|
||||
mark_set_ = other.mark_set_;
|
||||
mark_curx_ = other.mark_curx_;
|
||||
mark_cury_ = other.mark_cury_;
|
||||
undo_tree_ = std::move(other.undo_tree_);
|
||||
undo_sys_ = std::move(other.undo_sys_);
|
||||
|
||||
// Move syntax/highlighting state
|
||||
version_ = other.version_;
|
||||
syntax_enabled_ = other.syntax_enabled_;
|
||||
filetype_ = std::move(other.filetype_);
|
||||
highlighter_ = std::move(other.highlighter_);
|
||||
curx_ = other.curx_;
|
||||
cury_ = other.cury_;
|
||||
rx_ = other.rx_;
|
||||
nrows_ = other.nrows_;
|
||||
rowoffs_ = other.rowoffs_;
|
||||
coloffs_ = other.coloffs_;
|
||||
rows_ = std::move(other.rows_);
|
||||
filename_ = std::move(other.filename_);
|
||||
is_file_backed_ = other.is_file_backed_;
|
||||
dirty_ = other.dirty_;
|
||||
read_only_ = other.read_only_;
|
||||
mark_set_ = other.mark_set_;
|
||||
mark_curx_ = other.mark_curx_;
|
||||
mark_cury_ = other.mark_cury_;
|
||||
visual_line_active_ = other.visual_line_active_;
|
||||
visual_line_anchor_y_ = other.visual_line_anchor_y_;
|
||||
visual_line_active_y_ = other.visual_line_active_y_;
|
||||
undo_tree_ = std::move(other.undo_tree_);
|
||||
undo_sys_ = std::move(other.undo_sys_);
|
||||
|
||||
// Move edit mode + syntax/highlighting state
|
||||
edit_mode_ = other.edit_mode_;
|
||||
edit_mode_detected_ = other.edit_mode_detected_;
|
||||
version_ = other.version_;
|
||||
syntax_enabled_ = other.syntax_enabled_;
|
||||
syntax_user_override_ = other.syntax_user_override_;
|
||||
filetype_ = std::move(other.filetype_);
|
||||
highlighter_ = std::move(other.highlighter_);
|
||||
content_ = std::move(other.content_);
|
||||
rows_cache_dirty_ = other.rows_cache_dirty_;
|
||||
on_disk_identity_ = other.on_disk_identity_;
|
||||
// Non-owning: the recorder object itself is owned by SwapManager and outlives
|
||||
// this move. The caller (Editor) is responsible for calling SwapManager::Rehome()
|
||||
// so the journal's Buffer* key follows this object to its new address.
|
||||
swap_rec_ = other.swap_rec_;
|
||||
other.swap_rec_ = nullptr;
|
||||
// Update UndoSystem's buffer reference to point to this object
|
||||
if (undo_sys_) {
|
||||
undo_sys_->UpdateBufferReference(*this);
|
||||
@@ -229,59 +452,66 @@ Buffer::OpenFromFile(const std::string &path, std::string &err)
|
||||
mark_set_ = false;
|
||||
mark_curx_ = mark_cury_ = 0;
|
||||
|
||||
// Empty PieceTable
|
||||
content_.Clear();
|
||||
rows_cache_dirty_ = true;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
std::ifstream in(norm, std::ios::in | std::ios::binary);
|
||||
if (!in) {
|
||||
err = "Failed to open file: " + norm;
|
||||
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Detect if file ends with a newline so we can preserve a final empty line
|
||||
// in our in-memory representation (mg-style semantics).
|
||||
bool ends_with_nl = false;
|
||||
{
|
||||
in.seekg(0, std::ios::end);
|
||||
std::streamoff sz = in.tellg();
|
||||
if (sz > 0) {
|
||||
in.seekg(-1, std::ios::end);
|
||||
char last = 0;
|
||||
in.read(&last, 1);
|
||||
ends_with_nl = (last == '\n');
|
||||
} else {
|
||||
in.clear();
|
||||
}
|
||||
// Rewind to start for line-by-line read
|
||||
in.clear();
|
||||
// Read entire file into PieceTable as-is
|
||||
std::string data;
|
||||
in.seekg(0, std::ios::end);
|
||||
if (!in) {
|
||||
err = "Failed to seek to end of file: " + norm;
|
||||
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||
return false;
|
||||
}
|
||||
auto sz = in.tellg();
|
||||
if (sz < 0) {
|
||||
err = "Failed to get file size: " + norm;
|
||||
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||
return false;
|
||||
}
|
||||
if (sz > 0) {
|
||||
data.resize(static_cast<std::size_t>(sz));
|
||||
in.seekg(0, std::ios::beg);
|
||||
}
|
||||
|
||||
rows_.clear();
|
||||
std::string line;
|
||||
while (std::getline(in, line)) {
|
||||
// std::getline strips the '\n', keep raw line content only
|
||||
// Handle potential Windows CRLF: strip trailing '\r'
|
||||
if (!line.empty() && line.back() == '\r') {
|
||||
line.pop_back();
|
||||
if (!in) {
|
||||
err = "Failed to seek to beginning of file: " + norm;
|
||||
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||
return false;
|
||||
}
|
||||
rows_.emplace_back(line);
|
||||
}
|
||||
|
||||
// If the file ended with a newline and we didn't already get an
|
||||
// empty final row from getline (e.g., when the last textual line
|
||||
// had content followed by '\n'), append an empty row to represent
|
||||
// the cursor position past the last newline.
|
||||
if (ends_with_nl) {
|
||||
if (rows_.empty() || !rows_.back().empty()) {
|
||||
rows_.emplace_back(std::string());
|
||||
in.read(data.data(), static_cast<std::streamsize>(data.size()));
|
||||
if (!in && !in.eof()) {
|
||||
err = "Failed to read file: " + norm;
|
||||
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||
return false;
|
||||
}
|
||||
// Validate we read the expected number of bytes
|
||||
const std::streamsize bytes_read = in.gcount();
|
||||
if (bytes_read != static_cast<std::streamsize>(data.size())) {
|
||||
err = "Partial read of file (expected " + std::to_string(data.size()) +
|
||||
" bytes, got " + std::to_string(bytes_read) + "): " + norm;
|
||||
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
nrows_ = rows_.size();
|
||||
filename_ = norm;
|
||||
is_file_backed_ = true;
|
||||
dirty_ = false;
|
||||
content_.Clear();
|
||||
if (!data.empty())
|
||||
content_.Append(data.data(), data.size());
|
||||
rows_cache_dirty_ = true;
|
||||
nrows_ = 0; // not used under PieceTable
|
||||
filename_ = norm;
|
||||
is_file_backed_ = true;
|
||||
dirty_ = false;
|
||||
RefreshOnDiskIdentity();
|
||||
|
||||
// Reset/initialize undo system for this loaded file
|
||||
if (!undo_tree_)
|
||||
@@ -308,24 +538,18 @@ Buffer::Save(std::string &err) const
|
||||
err = "Buffer is not file-backed; use SaveAs()";
|
||||
return false;
|
||||
}
|
||||
std::ofstream out(filename_, std::ios::out | std::ios::binary | std::ios::trunc);
|
||||
if (!out) {
|
||||
err = "Failed to open for write: " + filename_;
|
||||
const std::size_t sz = content_.Size();
|
||||
const char *data = sz ? content_.Data() : nullptr;
|
||||
if (sz && !data) {
|
||||
err = "Internal error: buffer materialization failed";
|
||||
return false;
|
||||
}
|
||||
for (std::size_t i = 0; i < rows_.size(); ++i) {
|
||||
const char *d = rows_[i].Data();
|
||||
std::size_t n = rows_[i].Size();
|
||||
if (d && n)
|
||||
out.write(d, static_cast<std::streamsize>(n));
|
||||
if (i + 1 < rows_.size()) {
|
||||
out.put('\n');
|
||||
}
|
||||
}
|
||||
if (!out.good()) {
|
||||
err = "Write error";
|
||||
if (!atomic_write_file(filename_, data ? data : "", sz, err)) {
|
||||
kte::ErrorHandler::Instance().Error("Buffer", err, filename_);
|
||||
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
|
||||
// to decide when to flip dirty flag after successful save.
|
||||
return true;
|
||||
@@ -354,29 +578,21 @@ Buffer::SaveAs(const std::string &path, std::string &err)
|
||||
out_path = path;
|
||||
}
|
||||
|
||||
// Write to the given path
|
||||
std::ofstream out(out_path, std::ios::out | std::ios::binary | std::ios::trunc);
|
||||
if (!out) {
|
||||
err = "Failed to open for write: " + out_path;
|
||||
const std::size_t sz = content_.Size();
|
||||
const char *data = sz ? content_.Data() : nullptr;
|
||||
if (sz && !data) {
|
||||
err = "Internal error: buffer materialization failed";
|
||||
return false;
|
||||
}
|
||||
for (std::size_t i = 0; i < rows_.size(); ++i) {
|
||||
const char *d = rows_[i].Data();
|
||||
std::size_t n = rows_[i].Size();
|
||||
if (d && n)
|
||||
out.write(d, static_cast<std::streamsize>(n));
|
||||
if (i + 1 < rows_.size()) {
|
||||
out.put('\n');
|
||||
}
|
||||
}
|
||||
if (!out.good()) {
|
||||
err = "Write error";
|
||||
if (!atomic_write_file(out_path, data ? data : "", sz, err)) {
|
||||
kte::ErrorHandler::Instance().Error("Buffer", err, out_path);
|
||||
return false;
|
||||
}
|
||||
|
||||
filename_ = out_path;
|
||||
is_file_backed_ = true;
|
||||
dirty_ = false;
|
||||
RefreshOnDiskIdentity();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -389,7 +605,7 @@ Buffer::AsString() const
|
||||
if (this->Dirty()) {
|
||||
ss << "*";
|
||||
}
|
||||
ss << ">: " << rows_.size() << " lines";
|
||||
ss << ">: " << content_.LineCount() << " lines";
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
@@ -400,111 +616,166 @@ Buffer::insert_text(int row, int col, std::string_view text)
|
||||
{
|
||||
if (row < 0)
|
||||
row = 0;
|
||||
if (static_cast<std::size_t>(row) > rows_.size())
|
||||
row = static_cast<int>(rows_.size());
|
||||
if (rows_.empty())
|
||||
rows_.emplace_back("");
|
||||
if (static_cast<std::size_t>(row) >= rows_.size())
|
||||
rows_.emplace_back("");
|
||||
|
||||
auto y = static_cast<std::size_t>(row);
|
||||
auto x = static_cast<std::size_t>(col);
|
||||
if (x > rows_[y].size())
|
||||
x = rows_[y].size();
|
||||
|
||||
std::string remain(text);
|
||||
while (true) {
|
||||
auto pos = remain.find('\n');
|
||||
if (pos == std::string::npos) {
|
||||
rows_[y].insert(x, remain);
|
||||
break;
|
||||
}
|
||||
// Insert up to newline
|
||||
std::string seg = remain.substr(0, pos);
|
||||
rows_[y].insert(x, seg);
|
||||
x += seg.size();
|
||||
// Split line at x
|
||||
std::string tail = rows_[y].substr(x);
|
||||
rows_[y].erase(x);
|
||||
rows_.insert(rows_.begin() + static_cast<std::ptrdiff_t>(y + 1), Line(tail));
|
||||
y += 1;
|
||||
x = 0;
|
||||
remain.erase(0, pos + 1);
|
||||
if (col < 0)
|
||||
col = 0;
|
||||
const std::size_t off = content_.LineColToByteOffset(static_cast<std::size_t>(row),
|
||||
static_cast<std::size_t>(col));
|
||||
if (!text.empty()) {
|
||||
content_.Insert(off, text.data(), text.size());
|
||||
rows_cache_dirty_ = true;
|
||||
if (swap_rec_)
|
||||
swap_rec_->OnInsert(row, col, text);
|
||||
}
|
||||
// Do not set dirty here; UndoSystem will manage state/dirty externally
|
||||
}
|
||||
|
||||
|
||||
// ===== Adapter helpers for PieceTable-backed Buffer =====
|
||||
std::string_view
|
||||
Buffer::GetLineView(std::size_t row) const
|
||||
{
|
||||
// Get byte range for the logical line and return a view into materialized data
|
||||
auto range = content_.GetLineRange(row); // [start,end) in bytes
|
||||
const char *base = content_.Data(); // materializes if needed
|
||||
if (!base)
|
||||
return std::string_view();
|
||||
const std::size_t start = range.first;
|
||||
const std::size_t len = (range.second > range.first) ? (range.second - range.first) : 0;
|
||||
return std::string_view(base + start, len);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
Buffer::ensure_rows_cache() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(buffer_mutex_);
|
||||
if (!rows_cache_dirty_)
|
||||
return;
|
||||
rows_.clear();
|
||||
const std::size_t lc = content_.LineCount();
|
||||
rows_.reserve(lc);
|
||||
for (std::size_t i = 0; i < lc; ++i) {
|
||||
rows_.emplace_back(content_.GetLine(i));
|
||||
}
|
||||
// Keep nrows_ in sync for any legacy code that still reads it
|
||||
const_cast<Buffer *>(this)->nrows_ = rows_.size();
|
||||
rows_cache_dirty_ = false;
|
||||
}
|
||||
|
||||
|
||||
std::size_t
|
||||
Buffer::content_LineCount_() const
|
||||
{
|
||||
return content_.LineCount();
|
||||
}
|
||||
|
||||
|
||||
#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
|
||||
Buffer::delete_text(int row, int col, std::size_t len)
|
||||
{
|
||||
if (rows_.empty() || len == 0)
|
||||
if (len == 0)
|
||||
return;
|
||||
if (row < 0)
|
||||
row = 0;
|
||||
if (static_cast<std::size_t>(row) >= rows_.size())
|
||||
return;
|
||||
const auto y = static_cast<std::size_t>(row);
|
||||
const auto x = std::min<std::size_t>(static_cast<std::size_t>(col), rows_[y].size());
|
||||
if (col < 0)
|
||||
col = 0;
|
||||
|
||||
const std::size_t start = content_.LineColToByteOffset(static_cast<std::size_t>(row),
|
||||
static_cast<std::size_t>(col));
|
||||
std::size_t r = static_cast<std::size_t>(row);
|
||||
std::size_t c = static_cast<std::size_t>(col);
|
||||
std::size_t remaining = len;
|
||||
while (remaining > 0 && y < rows_.size()) {
|
||||
auto &line = rows_[y];
|
||||
const std::size_t in_line = std::min<std::size_t>(remaining, line.size() - std::min(x, line.size()));
|
||||
if (x < line.size() && in_line > 0) {
|
||||
line.erase(x, in_line);
|
||||
remaining -= in_line;
|
||||
const std::size_t lc = content_.LineCount();
|
||||
|
||||
while (remaining > 0 && r < lc) {
|
||||
const std::string line = content_.GetLine(r); // logical line (without trailing '\n')
|
||||
const std::size_t L = line.size();
|
||||
if (c < L) {
|
||||
const std::size_t take = std::min(remaining, L - c);
|
||||
c += take;
|
||||
remaining -= take;
|
||||
}
|
||||
if (remaining == 0)
|
||||
break;
|
||||
// If at or beyond end of line and there is a next line, join it (deleting the implied '\n')
|
||||
if (y + 1 < rows_.size()) {
|
||||
line += rows_[y + 1];
|
||||
rows_.erase(rows_.begin() + static_cast<std::ptrdiff_t>(y + 1));
|
||||
// deleting the newline consumes one virtual character
|
||||
if (remaining > 0) {
|
||||
// Treat the newline as one deletion unit if len spans it
|
||||
// We already joined, so nothing else to do here.
|
||||
}
|
||||
// Consume newline between lines as one char, if there is a next line
|
||||
if (r + 1 < lc) {
|
||||
remaining -= 1; // the newline
|
||||
r += 1;
|
||||
c = 0;
|
||||
} else {
|
||||
break;
|
||||
// At last line and still remaining: delete to EOF
|
||||
const std::size_t total = content_.Size();
|
||||
const std::size_t actual = (total > start) ? (total - start) : 0;
|
||||
if (actual == 0)
|
||||
return;
|
||||
content_.Delete(start, actual);
|
||||
rows_cache_dirty_ = true;
|
||||
if (swap_rec_)
|
||||
swap_rec_->OnDelete(row, col, actual);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Compute end offset at (r,c)
|
||||
std::size_t end = content_.LineColToByteOffset(r, c);
|
||||
if (end > start) {
|
||||
const std::size_t actual = end - start;
|
||||
content_.Delete(start, actual);
|
||||
rows_cache_dirty_ = true;
|
||||
if (swap_rec_)
|
||||
swap_rec_->OnDelete(row, col, actual);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
Buffer::split_line(int row, const int col)
|
||||
{
|
||||
if (row < 0) {
|
||||
int c = col;
|
||||
if (row < 0)
|
||||
row = 0;
|
||||
}
|
||||
|
||||
if (static_cast<std::size_t>(row) >= rows_.size()) {
|
||||
rows_.resize(static_cast<std::size_t>(row) + 1);
|
||||
}
|
||||
const auto y = static_cast<std::size_t>(row);
|
||||
const auto x = std::min<std::size_t>(static_cast<std::size_t>(col), rows_[y].size());
|
||||
const auto tail = rows_[y].substr(x);
|
||||
rows_[y].erase(x);
|
||||
rows_.insert(rows_.begin() + static_cast<std::ptrdiff_t>(y + 1), Line(tail));
|
||||
if (c < 0)
|
||||
c = 0;
|
||||
const std::size_t off = content_.LineColToByteOffset(static_cast<std::size_t>(row),
|
||||
static_cast<std::size_t>(c));
|
||||
const char nl = '\n';
|
||||
content_.Insert(off, &nl, 1);
|
||||
rows_cache_dirty_ = true;
|
||||
if (swap_rec_)
|
||||
swap_rec_->OnInsert(row, c, std::string_view("\n", 1));
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
Buffer::join_lines(int row)
|
||||
{
|
||||
if (row < 0) {
|
||||
if (row < 0)
|
||||
row = 0;
|
||||
}
|
||||
|
||||
const auto y = static_cast<std::size_t>(row);
|
||||
if (y + 1 >= rows_.size()) {
|
||||
std::size_t r = static_cast<std::size_t>(row);
|
||||
if (r + 1 >= content_.LineCount())
|
||||
return;
|
||||
}
|
||||
|
||||
rows_[y] += rows_[y + 1];
|
||||
rows_.erase(rows_.begin() + static_cast<std::ptrdiff_t>(y + 1));
|
||||
const int col = static_cast<int>(content_.GetLine(r).size());
|
||||
// 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());
|
||||
// end_of_line now equals line end (clamped before newline). The newline should be exactly at this position.
|
||||
content_.Delete(end_of_line, 1);
|
||||
rows_cache_dirty_ = true;
|
||||
if (swap_rec_)
|
||||
swap_rec_->OnDelete(row, col, 1);
|
||||
}
|
||||
|
||||
|
||||
@@ -513,9 +784,18 @@ Buffer::insert_row(int row, const std::string_view text)
|
||||
{
|
||||
if (row < 0)
|
||||
row = 0;
|
||||
if (static_cast<std::size_t>(row) > rows_.size())
|
||||
row = static_cast<int>(rows_.size());
|
||||
rows_.insert(rows_.begin() + row, Line(std::string(text)));
|
||||
std::size_t off = content_.LineColToByteOffset(static_cast<std::size_t>(row), 0);
|
||||
if (!text.empty())
|
||||
content_.Insert(off, text.data(), text.size());
|
||||
const char nl = '\n';
|
||||
content_.Insert(off + text.size(), &nl, 1);
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -524,9 +804,31 @@ Buffer::delete_row(int row)
|
||||
{
|
||||
if (row < 0)
|
||||
row = 0;
|
||||
if (static_cast<std::size_t>(row) >= rows_.size())
|
||||
std::size_t r = static_cast<std::size_t>(row);
|
||||
if (r >= content_.LineCount())
|
||||
return;
|
||||
rows_.erase(rows_.begin() + row);
|
||||
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 last line, end may equal total_size_. We still delete [start,end) which removes the last line content.
|
||||
const std::size_t start = range.first;
|
||||
const std::size_t end = range.second;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -542,4 +844,4 @@ const UndoSystem *
|
||||
Buffer::Undo() const
|
||||
{
|
||||
return undo_sys_.get();
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,80 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
#include <string_view>
|
||||
|
||||
#include "AppendBuffer.h"
|
||||
#include "PieceTable.h"
|
||||
#include "UndoSystem.h"
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include "syntax/HighlighterEngine.h"
|
||||
#include "Highlight.h"
|
||||
#include <mutex>
|
||||
|
||||
// Edit mode determines which font class is used for a buffer.
|
||||
enum class EditMode { Code, Writing };
|
||||
|
||||
// Detect edit mode from a filename's extension.
|
||||
inline EditMode
|
||||
DetectEditMode(const std::string &filename)
|
||||
{
|
||||
std::string ext = std::filesystem::path(filename).extension().string();
|
||||
std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) {
|
||||
return static_cast<char>(std::tolower(c));
|
||||
});
|
||||
static const std::unordered_set<std::string> writing_exts = {
|
||||
".txt", ".md", ".markdown", ".rst", ".org",
|
||||
".tex", ".adoc", ".asciidoc",
|
||||
};
|
||||
if (writing_exts.count(ext))
|
||||
return EditMode::Writing;
|
||||
return EditMode::Code;
|
||||
}
|
||||
|
||||
// Forward declaration for swap journal integration
|
||||
namespace kte {
|
||||
class SwapRecorder;
|
||||
}
|
||||
|
||||
|
||||
class Buffer {
|
||||
@@ -37,6 +97,14 @@ public:
|
||||
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
|
||||
|
||||
// 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
|
||||
[[nodiscard]] std::size_t Curx() const
|
||||
{
|
||||
@@ -58,7 +126,7 @@ public:
|
||||
|
||||
[[nodiscard]] std::size_t Nrows() const
|
||||
{
|
||||
return nrows_;
|
||||
return content_LineCount_();
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +142,8 @@ public:
|
||||
}
|
||||
|
||||
|
||||
// Line wrapper backed by AppendBuffer (GapBuffer/PieceTable)
|
||||
// Line wrapper used by legacy command paths.
|
||||
// Keep this lightweight: store materialized bytes only for that line.
|
||||
class Line {
|
||||
public:
|
||||
Line() = default;
|
||||
@@ -103,119 +172,102 @@ public:
|
||||
// capacity helpers
|
||||
void Clear()
|
||||
{
|
||||
buf_.Clear();
|
||||
s_.clear();
|
||||
}
|
||||
|
||||
|
||||
// size/access
|
||||
[[nodiscard]] std::size_t size() const
|
||||
{
|
||||
return buf_.Size();
|
||||
return s_.size();
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] bool empty() const
|
||||
{
|
||||
return size() == 0;
|
||||
return s_.empty();
|
||||
}
|
||||
|
||||
|
||||
// read-only raw view
|
||||
[[nodiscard]] const char *Data() const
|
||||
{
|
||||
return buf_.Data();
|
||||
return s_.data();
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] std::size_t Size() const
|
||||
{
|
||||
return buf_.Size();
|
||||
return s_.size();
|
||||
}
|
||||
|
||||
|
||||
// element access (read-only)
|
||||
[[nodiscard]] char operator[](std::size_t i) const
|
||||
{
|
||||
const char *d = buf_.Data();
|
||||
return (i < buf_.Size() && d) ? d[i] : '\0';
|
||||
return (i < s_.size()) ? s_[i] : '\0';
|
||||
}
|
||||
|
||||
|
||||
// conversions
|
||||
explicit operator std::string() const
|
||||
{
|
||||
return {buf_.Data() ? buf_.Data() : "", buf_.Size()};
|
||||
return s_;
|
||||
}
|
||||
|
||||
|
||||
// string-like API used by command/renderer layers (implemented via materialization for now)
|
||||
[[nodiscard]] std::string substr(std::size_t pos) const
|
||||
{
|
||||
const std::size_t n = buf_.Size();
|
||||
if (pos >= n)
|
||||
return {};
|
||||
return {buf_.Data() + pos, n - pos};
|
||||
return pos < s_.size() ? s_.substr(pos) : std::string();
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] std::string substr(std::size_t pos, std::size_t len) const
|
||||
{
|
||||
const std::size_t n = buf_.Size();
|
||||
if (pos >= n)
|
||||
return {};
|
||||
const std::size_t take = (pos + len > n) ? (n - pos) : len;
|
||||
return {buf_.Data() + pos, take};
|
||||
return pos < s_.size() ? s_.substr(pos, len) : std::string();
|
||||
}
|
||||
|
||||
|
||||
// minimal find() to support search within a line
|
||||
[[nodiscard]] std::size_t find(const std::string &needle, const std::size_t pos = 0) const
|
||||
{
|
||||
// Materialize to std::string for now; Line is backed by AppendBuffer
|
||||
const auto s = static_cast<std::string>(*this);
|
||||
return s.find(needle, pos);
|
||||
return s_.find(needle, pos);
|
||||
}
|
||||
|
||||
|
||||
void erase(std::size_t pos)
|
||||
{
|
||||
// erase to end
|
||||
material_edit([&](std::string &s) {
|
||||
if (pos < s.size())
|
||||
s.erase(pos);
|
||||
});
|
||||
if (pos < s_.size())
|
||||
s_.erase(pos);
|
||||
}
|
||||
|
||||
|
||||
void erase(std::size_t pos, std::size_t len)
|
||||
{
|
||||
material_edit([&](std::string &s) {
|
||||
if (pos < s.size())
|
||||
s.erase(pos, len);
|
||||
});
|
||||
if (pos < s_.size())
|
||||
s_.erase(pos, len);
|
||||
}
|
||||
|
||||
|
||||
void insert(std::size_t pos, const std::string &seg)
|
||||
{
|
||||
material_edit([&](std::string &s) {
|
||||
if (pos > s.size())
|
||||
pos = s.size();
|
||||
s.insert(pos, seg);
|
||||
});
|
||||
if (pos > s_.size())
|
||||
pos = s_.size();
|
||||
s_.insert(pos, seg);
|
||||
}
|
||||
|
||||
|
||||
Line &operator+=(const Line &other)
|
||||
{
|
||||
buf_.Append(other.buf_.Data(), other.buf_.Size());
|
||||
s_ += other.s_;
|
||||
return *this;
|
||||
}
|
||||
|
||||
|
||||
Line &operator+=(const std::string &s)
|
||||
{
|
||||
buf_.Append(s.data(), s.size());
|
||||
s_ += s;
|
||||
return *this;
|
||||
}
|
||||
|
||||
@@ -229,37 +281,47 @@ public:
|
||||
private:
|
||||
void assign_from(const std::string &s)
|
||||
{
|
||||
buf_.Clear();
|
||||
if (!s.empty())
|
||||
buf_.Append(s.data(), s.size());
|
||||
s_ = s;
|
||||
}
|
||||
|
||||
|
||||
template<typename F>
|
||||
void material_edit(F fn)
|
||||
{
|
||||
std::string tmp = static_cast<std::string>(*this);
|
||||
fn(tmp);
|
||||
assign_from(tmp);
|
||||
}
|
||||
|
||||
|
||||
AppendBuffer buf_;
|
||||
std::string s_;
|
||||
};
|
||||
|
||||
|
||||
[[nodiscard]] const std::vector<Line> &Rows() const
|
||||
{
|
||||
ensure_rows_cache();
|
||||
return rows_;
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] std::vector<Line> &Rows()
|
||||
{
|
||||
ensure_rows_cache();
|
||||
return rows_;
|
||||
}
|
||||
|
||||
|
||||
// Lightweight, lazy per-line accessors that avoid materializing all rows.
|
||||
// Prefer these over Rows() in hot paths to reduce memory overhead on large files.
|
||||
[[nodiscard]] std::string GetLineString(std::size_t row) const
|
||||
{
|
||||
return content_.GetLine(row);
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] std::pair<std::size_t, std::size_t> GetLineRange(std::size_t row) const
|
||||
{
|
||||
return content_.GetLineRange(row);
|
||||
}
|
||||
|
||||
|
||||
// Zero-copy view of a line. Points into the materialized backing store; becomes
|
||||
// invalid after subsequent edits. Use immediately.
|
||||
[[nodiscard]] std::string_view GetLineView(std::size_t row) const;
|
||||
|
||||
|
||||
[[nodiscard]] const std::string &Filename() const
|
||||
{
|
||||
return filename_;
|
||||
@@ -371,6 +433,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;
|
||||
|
||||
// Syntax highlighting integration (per-buffer)
|
||||
@@ -380,6 +507,35 @@ public:
|
||||
}
|
||||
|
||||
|
||||
// Edit mode (code vs writing)
|
||||
[[nodiscard]] EditMode GetEditMode() const
|
||||
{
|
||||
return edit_mode_;
|
||||
}
|
||||
|
||||
|
||||
void SetEditMode(EditMode m)
|
||||
{
|
||||
edit_mode_ = m;
|
||||
edit_mode_detected_ = true;
|
||||
}
|
||||
|
||||
|
||||
void ToggleEditMode()
|
||||
{
|
||||
edit_mode_ = (edit_mode_ == EditMode::Code)
|
||||
? EditMode::Writing
|
||||
: EditMode::Code;
|
||||
edit_mode_detected_ = true;
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] bool EditModeDetected() const
|
||||
{
|
||||
return edit_mode_detected_;
|
||||
}
|
||||
|
||||
|
||||
void SetSyntaxEnabled(bool on)
|
||||
{
|
||||
syntax_enabled_ = on;
|
||||
@@ -392,6 +548,25 @@ public:
|
||||
}
|
||||
|
||||
|
||||
// Marks that the user explicitly set syntax state via a command (:syntax
|
||||
// on/off, :set filetype=...), as opposed to it being auto-applied from
|
||||
// GUIConfig. Frontends that re-apply the config-driven default every
|
||||
// frame (e.g. ImGuiFrontend::apply_syntax_to_buffer) must check this
|
||||
// first, mirroring EditModeDetected()'s "don't stomp a manual toggle"
|
||||
// pattern - otherwise a manual :syntax off is silently undone on the very
|
||||
// next frame.
|
||||
void SetSyntaxUserOverride(bool on)
|
||||
{
|
||||
syntax_user_override_ = on;
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] bool SyntaxUserOverride() const
|
||||
{
|
||||
return syntax_user_override_;
|
||||
}
|
||||
|
||||
|
||||
void SetFiletype(const std::string &ft)
|
||||
{
|
||||
filetype_ = ft;
|
||||
@@ -404,13 +579,13 @@ public:
|
||||
}
|
||||
|
||||
|
||||
kte::HighlighterEngine *Highlighter()
|
||||
[[nodiscard]] kte::HighlighterEngine *Highlighter()
|
||||
{
|
||||
return highlighter_.get();
|
||||
}
|
||||
|
||||
|
||||
const kte::HighlighterEngine *Highlighter() const
|
||||
[[nodiscard]] const kte::HighlighterEngine *Highlighter() const
|
||||
{
|
||||
return highlighter_.get();
|
||||
}
|
||||
@@ -423,6 +598,19 @@ public:
|
||||
}
|
||||
|
||||
|
||||
// Swap journal integration (set by Editor)
|
||||
void SetSwapRecorder(kte::SwapRecorder *rec)
|
||||
{
|
||||
swap_rec_ = rec;
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] kte::SwapRecorder *SwapRecorder() const
|
||||
{
|
||||
return swap_rec_;
|
||||
}
|
||||
|
||||
|
||||
// Raw, low-level editing APIs used by UndoSystem apply().
|
||||
// These must NOT trigger undo recording. They also do not move the cursor.
|
||||
void insert_text(int row, int col, std::string_view text);
|
||||
@@ -437,32 +625,78 @@ public:
|
||||
|
||||
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)
|
||||
UndoSystem *Undo();
|
||||
[[nodiscard]] UndoSystem *Undo();
|
||||
|
||||
[[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:
|
||||
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)
|
||||
std::size_t curx_ = 0, cury_ = 0; // cursor position in characters
|
||||
std::size_t rx_ = 0; // render x (tabs expanded)
|
||||
std::size_t nrows_ = 0; // number of rows
|
||||
std::size_t rowoffs_ = 0, coloffs_ = 0; // viewport offsets
|
||||
std::vector<Line> rows_; // buffer rows (without trailing newlines)
|
||||
mutable std::vector<Line> rows_; // materialized cache of rows (without trailing newlines)
|
||||
// PieceTable is the source of truth.
|
||||
PieceTable content_{};
|
||||
mutable bool rows_cache_dirty_ = true; // invalidate on edits / I/O
|
||||
|
||||
// Helper to rebuild rows_ from content_
|
||||
void ensure_rows_cache() const;
|
||||
|
||||
// Helper to query content_.LineCount() while keeping header minimal
|
||||
std::size_t content_LineCount_() const;
|
||||
|
||||
std::string filename_;
|
||||
bool is_file_backed_ = false;
|
||||
bool dirty_ = false;
|
||||
bool read_only_ = false;
|
||||
bool mark_set_ = false;
|
||||
std::size_t mark_curx_ = 0, mark_cury_ = 0;
|
||||
bool is_file_backed_ = false;
|
||||
bool dirty_ = false;
|
||||
bool read_only_ = false;
|
||||
bool mark_set_ = false;
|
||||
std::size_t mark_curx_ = 0, mark_cury_ = 0;
|
||||
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
|
||||
std::unique_ptr<struct UndoTree> undo_tree_;
|
||||
std::unique_ptr<UndoSystem> undo_sys_;
|
||||
|
||||
// Edit mode (code vs writing)
|
||||
EditMode edit_mode_ = EditMode::Code;
|
||||
bool edit_mode_detected_ = false; // true after initial auto-detection
|
||||
|
||||
// Syntax/highlighting state
|
||||
std::uint64_t version_ = 0; // increment on edits
|
||||
bool syntax_enabled_ = true;
|
||||
bool syntax_user_override_ = false; // true once user explicitly set syntax state via a command
|
||||
std::string filetype_;
|
||||
std::unique_ptr<kte::HighlighterEngine> highlighter_;
|
||||
// Non-owning pointer to swap recorder managed by Editor/SwapManager
|
||||
kte::SwapRecorder *swap_rec_ = nullptr;
|
||||
|
||||
mutable std::mutex buffer_mutex_;
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
**kte** (Kyle's Text Editor) is a C++20 text editor with a terminal-first design (ncurses) and optional GUI frontends (ImGui via SDL2/OpenGL/Freetype, or Qt6). It uses a WordStar/VDE-style command model. The terminal editor is `kte`; the GUI editor is `kge`.
|
||||
|
||||
## Build Commands
|
||||
|
||||
```bash
|
||||
# Configure (from project root, build dir is "build")
|
||||
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug -DBUILD_GUI=ON -DBUILD_TESTS=ON
|
||||
|
||||
# Build everything
|
||||
cmake --build build
|
||||
|
||||
# Build specific targets
|
||||
cmake --build build --target kte # terminal editor
|
||||
cmake --build build --target kge # GUI editor (requires -DBUILD_GUI=ON)
|
||||
cmake --build build --target kte_tests # test suite
|
||||
|
||||
# Run all tests
|
||||
cmake --build build --target kte_tests && ./build/kte_tests
|
||||
```
|
||||
|
||||
There is no single-test runner; the test binary runs all tests. Tests use a minimal custom framework in `tests/Test.h` with `TEST()`, `ASSERT_EQ()`, `ASSERT_TRUE()`, `EXPECT_TRUE()` macros.
|
||||
|
||||
### Key CMake Options
|
||||
|
||||
| Flag | Default | Purpose |
|
||||
|------|---------|---------|
|
||||
| `BUILD_GUI` | ON | Build `kge` (ImGui GUI) |
|
||||
| `KTE_USE_QT` | OFF | Use Qt6 instead of ImGui for GUI |
|
||||
| `BUILD_TESTS` | ON | Build test suite |
|
||||
| `ENABLE_ASAN` | OFF | AddressSanitizer |
|
||||
| `KTE_STATIC_LINK` | OFF | Static linking (Linux only) |
|
||||
| `KTE_ENABLE_TREESITTER` | OFF | Tree-sitter syntax highlighting |
|
||||
|
||||
### Nix
|
||||
|
||||
`flake.nix` provides devshells: `default` (ImGui+debug tools), `terminal`, `qt`.
|
||||
|
||||
### Docker (cross-platform Linux testing)
|
||||
|
||||
```bash
|
||||
docker build -t kte-linux . && docker run --rm -v "$(pwd):/kte" kte-linux
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
Three-layer design with strict frontend independence:
|
||||
|
||||
```
|
||||
Frontend Layer (Terminal / ImGui / Qt / Test)
|
||||
InputHandler.h, Renderer.h, Frontend.h interfaces
|
||||
↓
|
||||
Command Layer
|
||||
CommandId enum → CommandRegistry → handler functions in Command.cc
|
||||
↓
|
||||
Core Model Layer
|
||||
Editor → Buffer → PieceTable
|
||||
UndoSystem (tree-based, records at PieceTable level)
|
||||
SwapManager (crash recovery journal per buffer)
|
||||
```
|
||||
|
||||
### Core Components
|
||||
|
||||
- **PieceTable** (`PieceTable.h/.cc`) - Text storage. Lazy materialization; most ops work on the piece list directly. Line index and materialization caches must be invalidated on content changes.
|
||||
- **Buffer** (`Buffer.h/.cc`) - Wraps PieceTable. Prefer `GetLineView(row)` (zero-copy) or `GetLineString(row)` over `Rows()` (legacy, materializes all lines). All text mutations must go through PieceTable API (`insert_text`, `delete_text`) to ensure undo and swap recording work.
|
||||
- **Editor** (`Editor.h/.cc`) - Top-level state container. Primarily getters/setters; editing logic lives in commands.
|
||||
- **Command** (`Command.h/.cc`) - 120+ editing commands. This is the main place to add new editing operations. Register via `CommandRegistry::Register()` in `InstallDefaultCommands()`.
|
||||
- **UndoSystem/UndoTree/UndoNode** - Tree-based undo with branching. Group related ops with `buf.Undo()->BeginGroup()` / `EndGroup()`.
|
||||
- **Swap** (`Swap.h/.cc`) - Append-only crash recovery journal. Uses circuit breaker pattern for resilience. Files in `~/.local/state/kte/`.
|
||||
- **Syntax highlighting** (`syntax/`) - Pluggable per-language highlighters registered in `HighlighterRegistry`. Per-line caching with buffer version tracking.
|
||||
|
||||
### Frontend Implementations
|
||||
|
||||
Each frontend implements three interfaces (`Frontend.h`, `InputHandler.h`, `Renderer.h`):
|
||||
- **Terminal**: ncurses-based (always built)
|
||||
- **ImGui**: SDL2+OpenGL+Freetype (built with `-DBUILD_GUI=ON`)
|
||||
- **Qt**: Qt6 (built with `-DBUILD_GUI=ON -DKTE_USE_QT=ON`)
|
||||
- **Test**: Programmatic frontend for testing (always built, no UI deps)
|
||||
|
||||
## Code Style
|
||||
|
||||
- **C++20**, compiled with `-Wall -Wextra -Werror -pedantic`
|
||||
- **Clang** uses `-stdlib=libc++`
|
||||
- **Naming**: PascalCase for classes/methods, snake_case for variables, trailing underscore for private members (e.g., `pieces_`)
|
||||
- **Indentation**: Tabs
|
||||
- **Error handling**: Fallible ops use `bool func(args..., std::string &err)` pattern. Always clear `err` at start, capture `errno` immediately after syscall failure. Use EINTR-safe wrappers from `SyscallWrappers.h` instead of raw syscalls.
|
||||
- **ErrorHandler**: Centralized logging to `~/.local/state/kte/error.log` with severity levels (Info/Warning/Error/Critical).
|
||||
|
||||
## Testing
|
||||
|
||||
Tests live in `tests/test_*.cc`. Use `TestFrontend`/`TestInputHandler`/`TestRenderer` for integration tests that exercise the full Editor+Buffer+Command stack without UI dependencies.
|
||||
|
||||
Key test files by area:
|
||||
- PieceTable: `test_piece_table.cc`
|
||||
- Buffer I/O: `test_buffer_io.cc`
|
||||
- Commands: `test_command_semantics.cc`
|
||||
- Search/replace: `test_search.cc`, `test_search_replace_flow.cc`
|
||||
- Undo: `test_undo.cc`
|
||||
- Swap (crash recovery): `test_swap_*.cc` (7 files)
|
||||
- Reflow: `test_reflow_paragraph.cc`, `test_reflow_indented_bullets.cc`
|
||||
- Integration: `test_daily_workflows.cc`
|
||||
|
||||
## Important Caveats
|
||||
|
||||
- `Buffer::Rows()` is legacy; use `GetLineView()` / `GetLineString()` in new code
|
||||
- `GetLineView()` returns a `string_view` valid only until next buffer modification
|
||||
- After editing ops, call `ensure_cursor_visible()` to update viewport
|
||||
- All source files are in the project root (no `src/` directory); tests are in `tests/`; syntax highlighters in `syntax/`; themes in `themes/`; embedded fonts in `fonts/`
|
||||
- External deps: `ext/imgui/` (Dear ImGui), `ext/tomlplusplus/` (TOML parser)
|
||||
- GUI config: `~/.config/kte/kge.toml` (TOML preferred over legacy INI)
|
||||
+212
-58
@@ -3,20 +3,21 @@ project(kte)
|
||||
|
||||
include(GNUInstallDirs)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(KTE_VERSION "1.3.2")
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(KTE_VERSION "1.12.0")
|
||||
|
||||
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
|
||||
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
|
||||
set(BUILD_GUI ON CACHE BOOL "Enable building the graphical version.")
|
||||
set(BUILD_TESTS OFF CACHE BOOL "Enable building test programs.")
|
||||
option(KTE_USE_PIECE_TABLE "Use PieceTable instead of GapBuffer implementation" ON)
|
||||
set(KTE_USE_QT OFF CACHE BOOL "Build the QT frontend instead of ImGui.")
|
||||
set(BUILD_TESTS ON CACHE BOOL "Enable building test programs.")
|
||||
set(KTE_FONT_SIZE "18.0" CACHE STRING "Default font size for GUI")
|
||||
option(KTE_UNDO_DEBUG "Enable undo instrumentation logs" OFF)
|
||||
option(KTE_ENABLE_TREESITTER "Enable optional Tree-sitter highlighter adapter" OFF)
|
||||
option(KTE_STATIC_LINK "Enable static linking on Linux" OFF)
|
||||
|
||||
# Optionally enable AddressSanitizer (ASan)
|
||||
option(ENABLE_ASAN "Enable AddressSanitizer for builds" ON)
|
||||
option(ENABLE_ASAN "Enable AddressSanitizer for builds" OFF)
|
||||
|
||||
if (ENABLE_ASAN)
|
||||
message(STATUS "ASan enabled")
|
||||
@@ -32,14 +33,7 @@ else ()
|
||||
endif ()
|
||||
|
||||
add_compile_options(
|
||||
"-static"
|
||||
"-Wall"
|
||||
"-Wextra"
|
||||
"-Werror"
|
||||
"-Wno-unused-function"
|
||||
"-Wno-unused-parameter"
|
||||
"-g"
|
||||
"$<$<CONFIG:RELEASE>:-O2>"
|
||||
|
||||
)
|
||||
|
||||
if (MSVC)
|
||||
@@ -49,10 +43,15 @@ else ()
|
||||
"-Wall"
|
||||
"-Wextra"
|
||||
"-Werror"
|
||||
"-pedantic"
|
||||
"-Wno-unused-function"
|
||||
"-Wno-unused-parameter"
|
||||
"$<$<CONFIG:RELEASE>:-O2>"
|
||||
"$<$<CONFIG:DEBUG>:-g>"
|
||||
"$<$<CONFIG:RELEASE>:-O2>")
|
||||
)
|
||||
if ("${CMAKE_CXX_COMPILER_ID}" STREQUAL "Clang")
|
||||
add_compile_options("-stdlib=libc++")
|
||||
add_link_options("-stdlib=libc++")
|
||||
else ()
|
||||
# nothing special for gcc at the moment
|
||||
endif ()
|
||||
@@ -65,15 +64,49 @@ endif ()
|
||||
|
||||
message(STATUS "Build system: ${CMAKE_HOST_SYSTEM_NAME}")
|
||||
|
||||
if (${BUILD_GUI})
|
||||
if (BUILD_GUI)
|
||||
include(cmake/imgui.cmake)
|
||||
endif ()
|
||||
|
||||
# NCurses for terminal mode
|
||||
set(CURSES_NEED_NCURSES)
|
||||
set(CURSES_NEED_WIDE)
|
||||
set(CURSES_NEED_NCURSES TRUE)
|
||||
set(CURSES_NEED_WIDE TRUE)
|
||||
|
||||
# macOS ships only a narrow-char ncurses in the SDK, and Homebrew's wide-char
|
||||
# build is keg-only, so FindCurses can't see it without a hint.
|
||||
if (APPLE)
|
||||
find_program(BREW_EXECUTABLE brew)
|
||||
if (BREW_EXECUTABLE)
|
||||
execute_process(COMMAND "${BREW_EXECUTABLE}" --prefix ncurses
|
||||
OUTPUT_VARIABLE HOMEBREW_NCURSES_PREFIX
|
||||
OUTPUT_STRIP_TRAILING_WHITESPACE
|
||||
ERROR_QUIET)
|
||||
if (HOMEBREW_NCURSES_PREFIX AND IS_DIRECTORY "${HOMEBREW_NCURSES_PREFIX}")
|
||||
message(STATUS "Using Homebrew ncursesw: ${HOMEBREW_NCURSES_PREFIX}")
|
||||
list(PREPEND CMAKE_PREFIX_PATH "${HOMEBREW_NCURSES_PREFIX}")
|
||||
endif ()
|
||||
endif ()
|
||||
endif ()
|
||||
|
||||
find_package(Curses REQUIRED)
|
||||
include_directories(${CURSES_INCLUDE_DIR})
|
||||
|
||||
# FindCurses exports CURSES_INCLUDE_DIRS; the singular CURSES_INCLUDE_DIR was
|
||||
# never set, so this silently expanded to nothing and the compiler fell back
|
||||
# to whatever curses header happened to be on the default include path.
|
||||
include_directories(${CURSES_INCLUDE_DIRS})
|
||||
|
||||
# The wide-char entry points we rely on (get_wch, etc.) are declared only when
|
||||
# NCURSES_WIDECHAR is set; the header defaults it to 0 unless a feature-test
|
||||
# macro says otherwise. We always link the wide library, so ask for them.
|
||||
add_compile_definitions(NCURSES_WIDECHAR=1)
|
||||
|
||||
# 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
|
||||
syntax/GoHighlighter.cc
|
||||
@@ -103,23 +136,42 @@ set(FONT_SOURCES
|
||||
fonts/FontRegistry.cc
|
||||
)
|
||||
|
||||
set(GUI_SOURCES
|
||||
${FONT_SOURCES}
|
||||
GUIConfig.cc
|
||||
GUIRenderer.cc
|
||||
GUIInputHandler.cc
|
||||
GUIFrontend.cc
|
||||
)
|
||||
|
||||
if (BUILD_GUI)
|
||||
set(GUI_SOURCES
|
||||
GUIConfig.cc
|
||||
)
|
||||
if (KTE_USE_QT)
|
||||
find_package(Qt6 COMPONENTS Widgets REQUIRED)
|
||||
set(GUI_SOURCES
|
||||
${GUI_SOURCES}
|
||||
QtFrontend.cc
|
||||
QtInputHandler.cc
|
||||
QtRenderer.cc
|
||||
)
|
||||
# Expose preprocessor switch so sources can exclude ImGui-specific code
|
||||
add_compile_definitions(KTE_USE_QT)
|
||||
else ()
|
||||
set(GUI_SOURCES
|
||||
${GUI_SOURCES}
|
||||
${FONT_SOURCES}
|
||||
ImGuiFrontend.cc
|
||||
ImGuiInputHandler.cc
|
||||
ImGuiRenderer.cc
|
||||
)
|
||||
endif ()
|
||||
endif ()
|
||||
|
||||
set(COMMON_SOURCES
|
||||
GapBuffer.cc
|
||||
PieceTable.cc
|
||||
Buffer.cc
|
||||
Editor.cc
|
||||
Command.cc
|
||||
HelpText.cc
|
||||
KKeymap.cc
|
||||
Swap.cc
|
||||
ErrorHandler.cc
|
||||
SyscallWrappers.cc
|
||||
ErrorRecovery.cc
|
||||
TerminalInputHandler.cc
|
||||
TerminalRenderer.cc
|
||||
TerminalFrontend.cc
|
||||
@@ -180,6 +232,8 @@ set(FONT_HEADERS
|
||||
fonts/FontList.h
|
||||
fonts/B612Mono.h
|
||||
fonts/BrassMono.h
|
||||
fonts/CrimsonPro.h
|
||||
fonts/ETBook.h
|
||||
fonts/BrassMonoCode.h
|
||||
fonts/FiraCode.h
|
||||
fonts/Go.h
|
||||
@@ -191,20 +245,21 @@ set(FONT_HEADERS
|
||||
fonts/IosevkaExtended.h
|
||||
fonts/ShareTech.h
|
||||
fonts/SpaceMono.h
|
||||
fonts/Spectral.h
|
||||
fonts/Syne.h
|
||||
fonts/Triplicate.h
|
||||
fonts/Unispace.h
|
||||
fonts/BerkeleyMono.h
|
||||
)
|
||||
|
||||
set(COMMON_HEADERS
|
||||
GapBuffer.h
|
||||
PieceTable.h
|
||||
Buffer.h
|
||||
Editor.h
|
||||
AppendBuffer.h
|
||||
Command.h
|
||||
HelpText.h
|
||||
KKeymap.h
|
||||
Swap.h
|
||||
InputHandler.h
|
||||
TerminalInputHandler.h
|
||||
Renderer.h
|
||||
@@ -222,14 +277,30 @@ set(COMMON_HEADERS
|
||||
${SYNTAX_HEADERS}
|
||||
)
|
||||
|
||||
set(GUI_HEADERS
|
||||
${THEME_HEADERS}
|
||||
${FONT_HEADERS}
|
||||
GUIConfig.h
|
||||
GUIRenderer.h
|
||||
GUIInputHandler.h
|
||||
GUIFrontend.h
|
||||
)
|
||||
if (BUILD_GUI)
|
||||
set(GUI_HEADERS
|
||||
GUIConfig.h
|
||||
)
|
||||
|
||||
if (KTE_USE_QT)
|
||||
set(GUI_HEADERS
|
||||
${GUI_HEADERS}
|
||||
QtFrontend.h
|
||||
QtInputHandler.h
|
||||
QtRenderer.h
|
||||
)
|
||||
else ()
|
||||
set(GUI_HEADERS
|
||||
${GUI_HEADERS}
|
||||
${THEME_HEADERS}
|
||||
${FONT_HEADERS}
|
||||
ImGuiFrontend.h
|
||||
ImGuiInputHandler.h
|
||||
ImGuiRenderer.h
|
||||
fonts/BerkeleyMono.h
|
||||
)
|
||||
endif ()
|
||||
endif ()
|
||||
|
||||
# kte (terminal-first) executable
|
||||
add_executable(kte
|
||||
@@ -238,15 +309,17 @@ add_executable(kte
|
||||
${COMMON_HEADERS}
|
||||
)
|
||||
|
||||
if (KTE_USE_PIECE_TABLE)
|
||||
target_compile_definitions(kte PRIVATE KTE_USE_PIECE_TABLE=1)
|
||||
endif ()
|
||||
if (KTE_UNDO_DEBUG)
|
||||
target_compile_definitions(kte PRIVATE KTE_UNDO_DEBUG=1)
|
||||
endif ()
|
||||
|
||||
target_link_libraries(kte ${CURSES_LIBRARIES})
|
||||
|
||||
# Static linking on Linux only (macOS does not support static linking of system libraries)
|
||||
if (NOT APPLE AND KTE_STATIC_LINK)
|
||||
target_link_options(kte PRIVATE -static)
|
||||
endif ()
|
||||
|
||||
if (KTE_ENABLE_TREESITTER)
|
||||
# Users can provide their own tree-sitter include/lib via cache variables
|
||||
set(TREESITTER_INCLUDE_DIR "" CACHE PATH "Path to tree-sitter include directory")
|
||||
@@ -267,34 +340,81 @@ install(TARGETS kte
|
||||
install(FILES docs/kte.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1)
|
||||
|
||||
if (BUILD_TESTS)
|
||||
# test_undo executable for testing undo/redo system
|
||||
add_executable(test_undo
|
||||
test_undo.cc
|
||||
${COMMON_SOURCES}
|
||||
${COMMON_HEADERS}
|
||||
# Unified unit test runner
|
||||
add_executable(kte_tests
|
||||
tests/TestRunner.cc
|
||||
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_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_edge_cases.cc
|
||||
tests/test_swap_recovery_prompt.cc
|
||||
tests/test_swap_cleanup.cc
|
||||
tests/test_swap_cleanup2.cc
|
||||
tests/test_swap_git_editor.cc
|
||||
tests/test_piece_table.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
|
||||
tests/test_smart_newline.cc
|
||||
tests/test_reflow_undo.cc
|
||||
tests/test_syntax_highlighting.cc
|
||||
tests/test_paste_split.cc
|
||||
|
||||
# minimal engine sources required by Buffer
|
||||
PieceTable.cc
|
||||
Buffer.cc
|
||||
Editor.cc
|
||||
Command.cc
|
||||
HelpText.cc
|
||||
Swap.cc
|
||||
ErrorHandler.cc
|
||||
SyscallWrappers.cc
|
||||
ErrorRecovery.cc
|
||||
KKeymap.cc
|
||||
SwapRecorder.h
|
||||
OptimizedSearch.cc
|
||||
UndoNode.cc
|
||||
UndoTree.cc
|
||||
UndoSystem.cc
|
||||
${SYNTAX_SOURCES}
|
||||
)
|
||||
|
||||
if (KTE_USE_PIECE_TABLE)
|
||||
target_compile_definitions(test_undo PRIVATE KTE_USE_PIECE_TABLE=1)
|
||||
endif ()
|
||||
# Allow test-only introspection hooks (guarded in headers) without affecting production builds.
|
||||
target_compile_definitions(kte_tests PRIVATE KTE_TESTS=1)
|
||||
|
||||
if (KTE_UNDO_DEBUG)
|
||||
target_compile_definitions(test_undo PRIVATE KTE_UNDO_DEBUG=1)
|
||||
endif ()
|
||||
# Allow tests to include project headers like "Buffer.h"
|
||||
target_include_directories(kte_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
|
||||
|
||||
target_link_libraries(test_undo ${CURSES_LIBRARIES})
|
||||
# Keep tests free of ncurses/GUI deps
|
||||
if (KTE_ENABLE_TREESITTER)
|
||||
if (TREESITTER_INCLUDE_DIR)
|
||||
target_include_directories(test_undo PRIVATE ${TREESITTER_INCLUDE_DIR})
|
||||
target_include_directories(kte_tests PRIVATE ${TREESITTER_INCLUDE_DIR})
|
||||
endif ()
|
||||
if (TREESITTER_LIBRARY)
|
||||
target_link_libraries(test_undo ${TREESITTER_LIBRARY})
|
||||
target_link_libraries(kte_tests ${TREESITTER_LIBRARY})
|
||||
endif ()
|
||||
endif ()
|
||||
|
||||
# Static linking on Linux only (macOS does not support static linking of system libraries)
|
||||
if (NOT APPLE AND KTE_STATIC_LINK)
|
||||
target_link_options(kte_tests PRIVATE -static)
|
||||
endif ()
|
||||
endif ()
|
||||
|
||||
if (${BUILD_GUI})
|
||||
if (BUILD_GUI)
|
||||
# ImGui::CreateContext();
|
||||
# ImGuiIO& io = ImGui::GetIO();
|
||||
|
||||
@@ -319,10 +439,22 @@ if (${BUILD_GUI})
|
||||
|
||||
)
|
||||
target_compile_definitions(kge PRIVATE KTE_BUILD_GUI=1 KTE_DEFAULT_GUI=1 KTE_FONT_SIZE=${KTE_FONT_SIZE})
|
||||
if (KTE_USE_QT)
|
||||
target_compile_definitions(kge PRIVATE KTE_USE_QT=1)
|
||||
endif ()
|
||||
if (KTE_UNDO_DEBUG)
|
||||
target_compile_definitions(kge PRIVATE KTE_UNDO_DEBUG=1)
|
||||
endif ()
|
||||
target_link_libraries(kge ${CURSES_LIBRARIES} imgui)
|
||||
if (KTE_USE_QT)
|
||||
target_link_libraries(kge ${CURSES_LIBRARIES} Qt6::Widgets)
|
||||
else ()
|
||||
target_link_libraries(kge ${CURSES_LIBRARIES} imgui)
|
||||
endif ()
|
||||
|
||||
# Static linking on Linux only (macOS does not support static linking of system libraries)
|
||||
if (NOT APPLE AND KTE_STATIC_LINK)
|
||||
target_link_options(kge PRIVATE -static)
|
||||
endif ()
|
||||
|
||||
# On macOS, build kge as a proper .app bundle
|
||||
if (APPLE)
|
||||
@@ -342,12 +474,18 @@ if (${BUILD_GUI})
|
||||
${CMAKE_CURRENT_BINARY_DIR}/kge-Info.plist
|
||||
@ONLY)
|
||||
|
||||
# Ensure proper macOS bundle properties and RPATH so our bundled
|
||||
# frameworks are preferred over system/Homebrew ones.
|
||||
set_target_properties(kge PROPERTIES
|
||||
MACOSX_BUNDLE TRUE
|
||||
MACOSX_BUNDLE_GUI_IDENTIFIER ${KGE_BUNDLE_ID}
|
||||
MACOSX_BUNDLE_BUNDLE_NAME "kge"
|
||||
MACOSX_BUNDLE_ICON_FILE ${MACOSX_BUNDLE_ICON_FILE}
|
||||
MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_BINARY_DIR}/kge-Info.plist")
|
||||
MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_BINARY_DIR}/kge-Info.plist"
|
||||
# Prefer the app's bundled frameworks at runtime
|
||||
INSTALL_RPATH "@executable_path/../Frameworks"
|
||||
BUILD_WITH_INSTALL_RPATH TRUE
|
||||
)
|
||||
|
||||
add_dependencies(kge kte)
|
||||
add_custom_command(TARGET kge POST_BUILD
|
||||
@@ -371,4 +509,20 @@ if (${BUILD_GUI})
|
||||
# Install kge man page only when GUI is built
|
||||
install(FILES docs/kge.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1)
|
||||
install(FILES kge.png DESTINATION ${CMAKE_INSTALL_PREFIX}/share/icons)
|
||||
|
||||
# Optional post-build bundle fixup (can also be run from scripts).
|
||||
# This provides a CMake target to run BundleUtilities' fixup_bundle on the
|
||||
# built app, useful after macdeployqt to ensure non-Qt dylibs are internalized.
|
||||
if (APPLE AND TARGET kge)
|
||||
get_target_property(IS_BUNDLE kge MACOSX_BUNDLE)
|
||||
if (IS_BUNDLE)
|
||||
add_custom_target(kge_fixup_bundle ALL
|
||||
COMMAND ${CMAKE_COMMAND}
|
||||
-DAPP_BUNDLE=${CMAKE_CURRENT_BINARY_DIR}/$<TARGET_PROPERTY:kge,MACOSX_BUNDLE_BUNDLE_NAME>.app
|
||||
-P ${CMAKE_CURRENT_LIST_DIR}/cmake/fix_bundle.cmake
|
||||
COMMENT "Running fixup_bundle on kge.app to internalize non-Qt dylibs"
|
||||
VERBATIM)
|
||||
add_dependencies(kge_fixup_bundle kge)
|
||||
endif ()
|
||||
endif ()
|
||||
endif ()
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# kge Configuration
|
||||
|
||||
kge loads configuration from `~/.config/kte/kge.toml`. If no TOML file is
|
||||
found, it falls back to the legacy `kge.ini` format.
|
||||
|
||||
## TOML Format
|
||||
|
||||
```toml
|
||||
[window]
|
||||
fullscreen = false
|
||||
columns = 80
|
||||
rows = 42
|
||||
|
||||
[font]
|
||||
# Default font and size
|
||||
name = "default"
|
||||
size = 18.0
|
||||
# Font used in code mode (monospace)
|
||||
code = "default"
|
||||
# Font used in writing mode (proportional)
|
||||
writing = "crimsonpro"
|
||||
|
||||
[appearance]
|
||||
theme = "nord"
|
||||
# "dark" or "light" for themes with variants
|
||||
background = "dark"
|
||||
|
||||
[editor]
|
||||
syntax = true
|
||||
```
|
||||
|
||||
## Sections
|
||||
|
||||
### `[window]`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|--------------|------|---------|---------------------------------|
|
||||
| `fullscreen` | bool | false | Start in fullscreen mode |
|
||||
| `columns` | int | 80 | Initial window width in columns |
|
||||
| `rows` | int | 42 | Initial window height in rows |
|
||||
|
||||
### `[font]`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|-----------|--------|--------------|------------------------------------------|
|
||||
| `name` | string | "default" | Default font loaded at startup |
|
||||
| `size` | float | 18.0 | Font size in pixels |
|
||||
| `code` | string | "default" | Font for code mode (monospace) |
|
||||
| `writing` | string | "crimsonpro" | Font for writing mode (proportional) |
|
||||
|
||||
### `[appearance]`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|--------------|--------|---------|-----------------------------------------|
|
||||
| `theme` | string | "nord" | Color theme |
|
||||
| `background` | string | "dark" | Background mode: "dark" or "light" |
|
||||
|
||||
### `[editor]`
|
||||
|
||||
| Key | Type | Default | Description |
|
||||
|----------|------|---------|------------------------------|
|
||||
| `syntax` | bool | true | Enable syntax highlighting |
|
||||
|
||||
## Edit Modes
|
||||
|
||||
kge has two edit modes that control which font is used:
|
||||
|
||||
- **code** — Uses the monospace font (`font.code`). Default for source files.
|
||||
- **writing** — Uses the proportional font (`font.writing`). Auto-detected
|
||||
for `.txt`, `.md`, `.markdown`, `.rst`, `.org`, `.tex`, `.adoc`, and
|
||||
`.asciidoc` files.
|
||||
|
||||
Toggle with `C-k m` or `: mode [code|writing]`.
|
||||
|
||||
## Available Fonts
|
||||
|
||||
### Monospace
|
||||
|
||||
b612, berkeley, berkeley-bold, brassmono, brassmono-bold, brassmonocode,
|
||||
brassmonocode-bold, fira, go, ibm, idealist, inconsolata, inconsolataex,
|
||||
iosevka, iosevkaex, sharetech, space, syne, triplicate, unispace
|
||||
|
||||
### Proportional (Serif)
|
||||
|
||||
crimsonpro, etbook, spectral
|
||||
|
||||
## Available Themes
|
||||
|
||||
amber, eink, everforest, gruvbox, kanagawa-paper, lcars, leuchtturm, nord,
|
||||
old-book, orbital, plan9, solarized, tufte, weyland-yutani, zenburn
|
||||
|
||||
Themes with light/dark variants: eink, gruvbox, leuchtturm, old-book,
|
||||
solarized. Set `background = "light"` or use `: background light`.
|
||||
|
||||
## Migrating from kge.ini
|
||||
|
||||
If you have an existing `kge.ini`, kge will still read it but prints a
|
||||
notice to stderr suggesting migration. To migrate, create `kge.toml` in the
|
||||
same directory (`~/.config/kte/`) using the format above. The TOML file
|
||||
takes priority when both exist.
|
||||
|
||||
The INI keys map to TOML as follows:
|
||||
|
||||
| INI key | TOML equivalent |
|
||||
|---------------|--------------------------|
|
||||
| `fullscreen` | `window.fullscreen` |
|
||||
| `columns` | `window.columns` |
|
||||
| `rows` | `window.rows` |
|
||||
| `font` | `font.name` |
|
||||
| `font_size` | `font.size` |
|
||||
| `theme` | `appearance.theme` |
|
||||
| `background` | `appearance.background` |
|
||||
| `syntax` | `editor.syntax` |
|
||||
|
||||
New keys `font.code` and `font.writing` have no INI equivalent (the INI
|
||||
parser accepts `code_font` and `writing_font` if needed).
|
||||
+1619
-338
File diff suppressed because it is too large
Load Diff
@@ -27,14 +27,18 @@ enum class CommandId {
|
||||
SearchReplace, // begin search & replace (two-step prompt)
|
||||
OpenFileStart, // begin open-file prompt
|
||||
VisualFilePickerToggle,
|
||||
// GUI-only: toggle/show a visual font selector dialog
|
||||
VisualFontPickerToggle,
|
||||
// Buffers
|
||||
BufferSwitchStart, // begin buffer switch prompt
|
||||
BufferNew, // create a new empty, unnamed buffer (C-k i)
|
||||
BufferClose,
|
||||
BufferNext,
|
||||
BufferPrev,
|
||||
// Editing
|
||||
InsertText, // arg: text to insert at cursor (UTF-8, no newlines)
|
||||
Newline, // insert a newline at cursor
|
||||
SmartNewline, // insert a newline with auto-indent (Shift-Enter)
|
||||
Backspace, // delete char before cursor (may join lines)
|
||||
DeleteChar, // delete char at cursor (may join lines)
|
||||
KillToEOL, // delete from cursor to end of line; if at EOL, delete newline
|
||||
@@ -44,6 +48,7 @@ enum class CommandId {
|
||||
MoveFileStart, // move to beginning of file
|
||||
MoveFileEnd, // move to end of file
|
||||
ToggleMark, // toggle mark at cursor
|
||||
VisualLineModeToggle, // toggle visual-line (multicursor) mode (C-k /)
|
||||
JumpToMark, // jump to mark, set mark to previous cursor
|
||||
KillRegion, // kill region between mark and cursor (to kill ring)
|
||||
CopyRegion, // copy region to kill ring (Alt-w)
|
||||
@@ -90,6 +95,7 @@ enum class CommandId {
|
||||
ShowHelp, // open +HELP+ buffer with manual text (C-k h)
|
||||
// Meta
|
||||
UnknownKCommand, // arg: single character that was not recognized after C-k
|
||||
UnknownEscCommand, // invalid ESC (meta) command; show status and exit escape mode
|
||||
// Generic command prompt
|
||||
CommandPromptStart, // begin generic command prompt (C-k ;)
|
||||
// Theme by name
|
||||
@@ -103,6 +109,16 @@ enum class CommandId {
|
||||
// Syntax highlighting
|
||||
Syntax, // ":syntax on|off|reload"
|
||||
SetOption, // generic ":set key=value" (v1: filetype=<lang>)
|
||||
// Viewport control
|
||||
CenterOnCursor, // center the viewport on the current cursor line (C-k k)
|
||||
// GUI: open a new editor window sharing the same buffer list
|
||||
NewWindow,
|
||||
// GUI: font size controls
|
||||
FontZoomIn,
|
||||
FontZoomOut,
|
||||
FontZoomReset,
|
||||
// Edit mode (code/writing)
|
||||
ToggleEditMode,
|
||||
};
|
||||
|
||||
|
||||
@@ -128,6 +144,9 @@ struct Command {
|
||||
CommandHandler handler;
|
||||
// Public commands are exposed in the ": " prompt (C-k ;)
|
||||
bool isPublic = false;
|
||||
// Whether this command should consume and honor a universal argument repeat count.
|
||||
// Default true per issue request; authors can turn off per-command.
|
||||
bool repeatable = true;
|
||||
};
|
||||
|
||||
|
||||
|
||||
+23
@@ -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"]
|
||||
@@ -1,6 +1,7 @@
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <utility>
|
||||
|
||||
#include "Editor.h"
|
||||
#include "syntax/HighlighterRegistry.h"
|
||||
@@ -8,7 +9,45 @@
|
||||
#include "syntax/NullHighlighter.h"
|
||||
|
||||
|
||||
Editor::Editor() = default;
|
||||
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()
|
||||
{
|
||||
swap_ = std::make_unique<kte::SwapManager>();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
@@ -30,20 +69,22 @@ Editor::SetStatus(const std::string &message)
|
||||
Buffer *
|
||||
Editor::CurrentBuffer()
|
||||
{
|
||||
if (buffers_.empty() || curbuf_ >= buffers_.size()) {
|
||||
auto &bufs = Buffers();
|
||||
if (bufs.empty() || curbuf_ >= bufs.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &buffers_[curbuf_];
|
||||
return &bufs[curbuf_];
|
||||
}
|
||||
|
||||
|
||||
const Buffer *
|
||||
Editor::CurrentBuffer() const
|
||||
{
|
||||
if (buffers_.empty() || curbuf_ >= buffers_.size()) {
|
||||
const auto &bufs = Buffers();
|
||||
if (bufs.empty() || curbuf_ >= bufs.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return &buffers_[curbuf_];
|
||||
return &bufs[curbuf_];
|
||||
}
|
||||
|
||||
|
||||
@@ -78,8 +119,9 @@ Editor::DisplayNameFor(const Buffer &buf) const
|
||||
|
||||
// Prepare list of other buffer paths
|
||||
std::vector<std::vector<std::filesystem::path> > others;
|
||||
others.reserve(buffers_.size());
|
||||
for (const auto &b: buffers_) {
|
||||
const auto &bufs = Buffers();
|
||||
others.reserve(bufs.size());
|
||||
for (const auto &b: bufs) {
|
||||
if (&b == &buf)
|
||||
continue;
|
||||
if (b.Filename().empty())
|
||||
@@ -122,47 +164,89 @@ Editor::DisplayNameFor(const Buffer &buf) const
|
||||
std::size_t
|
||||
Editor::AddBuffer(const Buffer &buf)
|
||||
{
|
||||
buffers_.push_back(buf);
|
||||
if (buffers_.size() == 1) {
|
||||
auto &bufs = Buffers();
|
||||
// push_back may reallocate the vector's storage, moving every existing
|
||||
// Buffer to a new address. Drain any in-flight swap records first so the
|
||||
// writer thread never dereferences an address that's about to move, then
|
||||
// rehome each attached buffer that actually moved.
|
||||
if (swap_ && !bufs.empty())
|
||||
swap_->Flush();
|
||||
std::vector<Buffer *> old_addrs;
|
||||
old_addrs.reserve(bufs.size());
|
||||
for (auto &b: bufs)
|
||||
old_addrs.push_back(&b);
|
||||
bufs.push_back(buf);
|
||||
if (swap_) {
|
||||
for (std::size_t i = 0; i < old_addrs.size(); ++i) {
|
||||
Buffer *new_addr = &bufs[i];
|
||||
if (new_addr != old_addrs[i])
|
||||
bufs[i].SetSwapRecorder(swap_->Rehome(old_addrs[i], new_addr));
|
||||
}
|
||||
swap_->Attach(&bufs.back());
|
||||
bufs.back().SetSwapRecorder(swap_->RecorderFor(&bufs.back()));
|
||||
}
|
||||
if (bufs.size() == 1) {
|
||||
curbuf_ = 0;
|
||||
}
|
||||
return buffers_.size() - 1;
|
||||
return bufs.size() - 1;
|
||||
}
|
||||
|
||||
|
||||
std::size_t
|
||||
Editor::AddBuffer(Buffer &&buf)
|
||||
{
|
||||
buffers_.push_back(std::move(buf));
|
||||
if (buffers_.size() == 1) {
|
||||
auto &bufs = Buffers();
|
||||
if (swap_ && !bufs.empty())
|
||||
swap_->Flush();
|
||||
std::vector<Buffer *> old_addrs;
|
||||
old_addrs.reserve(bufs.size());
|
||||
for (auto &b: bufs)
|
||||
old_addrs.push_back(&b);
|
||||
bufs.push_back(std::move(buf));
|
||||
if (swap_) {
|
||||
for (std::size_t i = 0; i < old_addrs.size(); ++i) {
|
||||
Buffer *new_addr = &bufs[i];
|
||||
if (new_addr != old_addrs[i])
|
||||
bufs[i].SetSwapRecorder(swap_->Rehome(old_addrs[i], new_addr));
|
||||
}
|
||||
swap_->Attach(&bufs.back());
|
||||
bufs.back().SetSwapRecorder(swap_->RecorderFor(&bufs.back()));
|
||||
}
|
||||
if (bufs.size() == 1) {
|
||||
curbuf_ = 0;
|
||||
}
|
||||
return buffers_.size() - 1;
|
||||
return bufs.size() - 1;
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
Editor::OpenFile(const std::string &path, std::string &err)
|
||||
{
|
||||
// If there is exactly one unnamed, empty, clean buffer, reuse it instead
|
||||
// of creating a new one.
|
||||
if (buffers_.size() == 1) {
|
||||
Buffer &cur = buffers_[curbuf_];
|
||||
// If the current buffer is an unnamed, empty, clean scratch buffer, reuse
|
||||
// it instead of creating a new one.
|
||||
auto &bufs_ref = Buffers();
|
||||
if (!bufs_ref.empty() && curbuf_ < bufs_ref.size()) {
|
||||
Buffer &cur = bufs_ref[curbuf_];
|
||||
const bool unnamed = cur.Filename().empty() && !cur.IsFileBacked();
|
||||
const bool clean = !cur.Dirty();
|
||||
const auto &rows = cur.Rows();
|
||||
const bool rows_empty = rows.empty();
|
||||
const bool single_empty_line = (!rows.empty() && rows.size() == 1 && rows[0].size() == 0);
|
||||
const std::size_t nrows = cur.Nrows();
|
||||
const bool rows_empty = (nrows == 0);
|
||||
const bool single_empty_line = (nrows == 1 && cur.GetLineView(0).size() == 0);
|
||||
if (unnamed && clean && (rows_empty || single_empty_line)) {
|
||||
bool ok = cur.OpenFromFile(path, err);
|
||||
if (!ok)
|
||||
return false;
|
||||
// Ensure swap recorder is attached for this buffer
|
||||
if (swap_) {
|
||||
swap_->Attach(&cur);
|
||||
cur.SetSwapRecorder(swap_->RecorderFor(&cur));
|
||||
swap_->NotifyFilenameChanged(cur);
|
||||
}
|
||||
// Setup highlighting using registry (extension + shebang)
|
||||
cur.EnsureHighlighter();
|
||||
std::string first = "";
|
||||
const auto &rows = cur.Rows();
|
||||
if (!rows.empty())
|
||||
first = static_cast<std::string>(rows[0]);
|
||||
if (cur.Nrows() > 0)
|
||||
first = cur.GetLineString(0);
|
||||
std::string ft = kte::HighlighterRegistry::DetectForPath(path, first);
|
||||
if (!ft.empty()) {
|
||||
cur.SetFiletype(ft);
|
||||
@@ -179,6 +263,8 @@ Editor::OpenFile(const std::string &path, std::string &err)
|
||||
eng->InvalidateFrom(0);
|
||||
}
|
||||
}
|
||||
// Defensive: ensure any active prompt is closed after a successful open
|
||||
CancelPrompt();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -187,14 +273,13 @@ Editor::OpenFile(const std::string &path, std::string &err)
|
||||
if (!b.OpenFromFile(path, err)) {
|
||||
return false;
|
||||
}
|
||||
// NOTE: swap recorder/attach must happen after the buffer is stored in its
|
||||
// final location (vector) because swap manager keys off Buffer*.
|
||||
// Initialize syntax highlighting by extension + shebang via registry (v2)
|
||||
b.EnsureHighlighter();
|
||||
std::string first = "";
|
||||
{
|
||||
const auto &rows = b.Rows();
|
||||
if (!rows.empty())
|
||||
first = static_cast<std::string>(rows[0]);
|
||||
}
|
||||
if (b.Nrows() > 0)
|
||||
first = b.GetLineString(0);
|
||||
std::string ft = kte::HighlighterRegistry::DetectForPath(path, first);
|
||||
if (!ft.empty()) {
|
||||
b.SetFiletype(ft);
|
||||
@@ -213,20 +298,192 @@ Editor::OpenFile(const std::string &path, std::string &err)
|
||||
}
|
||||
// Add as a new buffer and switch to it
|
||||
std::size_t idx = AddBuffer(std::move(b));
|
||||
if (swap_) {
|
||||
swap_->NotifyFilenameChanged(Buffers()[idx]);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
Editor::SwitchTo(std::size_t index)
|
||||
{
|
||||
if (index >= buffers_.size()) {
|
||||
auto &bufs = Buffers();
|
||||
if (index >= bufs.size()) {
|
||||
return false;
|
||||
}
|
||||
curbuf_ = index;
|
||||
// Robustness: ensure a valid highlighter is installed when switching buffers
|
||||
Buffer &b = buffers_[curbuf_];
|
||||
Buffer &b = bufs[curbuf_];
|
||||
if (b.SyntaxEnabled()) {
|
||||
b.EnsureHighlighter();
|
||||
if (auto *eng = b.Highlighter()) {
|
||||
@@ -253,14 +510,36 @@ Editor::SwitchTo(std::size_t index)
|
||||
bool
|
||||
Editor::CloseBuffer(std::size_t index)
|
||||
{
|
||||
if (index >= buffers_.size()) {
|
||||
auto &bufs = Buffers();
|
||||
if (index >= bufs.size()) {
|
||||
return false;
|
||||
}
|
||||
buffers_.erase(buffers_.begin() + static_cast<std::ptrdiff_t>(index));
|
||||
if (buffers_.empty()) {
|
||||
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(&bufs[index], true);
|
||||
bufs[index].SetSwapRecorder(nullptr);
|
||||
// Drain in-flight records before the erase-shift below moves other
|
||||
// buffers to new addresses (vector::erase never reallocates, but it does
|
||||
// move-assign each trailing buffer into the previous slot).
|
||||
swap_->Flush();
|
||||
}
|
||||
bufs.erase(bufs.begin() + static_cast<std::ptrdiff_t>(index));
|
||||
if (swap_) {
|
||||
// erase() shifts every buffer after `index` down by one slot in-place
|
||||
// (no reallocation), so the buffer now at slot i used to live at slot
|
||||
// i+1 (same underlying storage, since data() doesn't move on erase).
|
||||
for (std::size_t i = index; i < bufs.size(); ++i) {
|
||||
Buffer *new_addr = &bufs[i];
|
||||
Buffer *old_addr = new_addr + 1;
|
||||
bufs[i].SetSwapRecorder(swap_->Rehome(old_addr, new_addr));
|
||||
}
|
||||
}
|
||||
if (bufs.empty()) {
|
||||
curbuf_ = 0;
|
||||
} else if (curbuf_ >= buffers_.size()) {
|
||||
curbuf_ = buffers_.size() - 1;
|
||||
} else if (curbuf_ >= bufs.size()) {
|
||||
curbuf_ = bufs.size() - 1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -278,8 +557,72 @@ Editor::Reset()
|
||||
msgtm_ = 0;
|
||||
uarg_ = 0;
|
||||
ucount_ = 0;
|
||||
repeatable_ = false;
|
||||
quit_requested_ = false;
|
||||
quit_confirm_pending_ = false;
|
||||
buffers_.clear();
|
||||
// Reset close-confirm/save state
|
||||
close_confirm_pending_ = false;
|
||||
close_after_save_ = false;
|
||||
auto &bufs = Buffers();
|
||||
if (swap_) {
|
||||
for (auto &buf : bufs)
|
||||
swap_->Detach(&buf, true);
|
||||
}
|
||||
bufs.clear();
|
||||
curbuf_ = 0;
|
||||
}
|
||||
|
||||
|
||||
// --- Universal argument helpers ---
|
||||
void
|
||||
Editor::UArgStart()
|
||||
{
|
||||
// If not active, start fresh; else multiply by 4 per ke semantics
|
||||
if (uarg_ == 0) {
|
||||
ucount_ = 0;
|
||||
} else {
|
||||
if (ucount_ == 0) {
|
||||
ucount_ = 1;
|
||||
}
|
||||
ucount_ *= 4;
|
||||
}
|
||||
uarg_ = 1;
|
||||
char buf[64];
|
||||
std::snprintf(buf, sizeof(buf), "C-u %d", ucount_);
|
||||
SetStatus(buf);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
Editor::UArgDigit(int d)
|
||||
{
|
||||
if (d < 0)
|
||||
d = 0;
|
||||
if (d > 9)
|
||||
d = 9;
|
||||
if (uarg_ == 0) {
|
||||
uarg_ = 1;
|
||||
ucount_ = 0;
|
||||
}
|
||||
ucount_ = ucount_ * 10 + d;
|
||||
char buf[64];
|
||||
std::snprintf(buf, sizeof(buf), "C-u %d", ucount_);
|
||||
SetStatus(buf);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
Editor::UArgClear()
|
||||
{
|
||||
uarg_ = 0;
|
||||
ucount_ = 0;
|
||||
}
|
||||
|
||||
|
||||
int
|
||||
Editor::UArgGet()
|
||||
{
|
||||
int n = (ucount_ > 0) ? ucount_ : 1;
|
||||
UArgClear();
|
||||
return n;
|
||||
}
|
||||
@@ -1,13 +1,52 @@
|
||||
/*
|
||||
* 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
|
||||
#include <cstddef>
|
||||
#include <ctime>
|
||||
#include <deque>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Buffer.h"
|
||||
#include "Swap.h"
|
||||
|
||||
|
||||
class Editor {
|
||||
@@ -156,6 +195,33 @@ public:
|
||||
}
|
||||
|
||||
|
||||
// --- Universal argument control (C-u) ---
|
||||
// Begin or extend a universal argument (like ke's uarg_start)
|
||||
void UArgStart();
|
||||
|
||||
// Add a digit 0..9 to the current universal argument (like ke's uarg_digit)
|
||||
void UArgDigit(int d);
|
||||
|
||||
// Clear universal-argument state (like ke's uarg_clear)
|
||||
void UArgClear();
|
||||
|
||||
// Consume the current universal argument, returning count >= 1.
|
||||
// If no universal argument active, returns 1.
|
||||
int UArgGet();
|
||||
|
||||
// Repeatable command flag: input layer can mark the next command as repeatable
|
||||
void SetRepeatable(bool on)
|
||||
{
|
||||
repeatable_ = on;
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] bool Repeatable() const
|
||||
{
|
||||
return repeatable_;
|
||||
}
|
||||
|
||||
|
||||
// Status message storage. Rendering is renderer-dependent; the editor
|
||||
// merely stores the current message and its timestamp.
|
||||
void SetStatus(const std::string &message);
|
||||
@@ -180,6 +246,18 @@ public:
|
||||
}
|
||||
|
||||
|
||||
void SetNewWindowRequested(bool on)
|
||||
{
|
||||
new_window_requested_ = on;
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] bool NewWindowRequested() const
|
||||
{
|
||||
return new_window_requested_;
|
||||
}
|
||||
|
||||
|
||||
void SetQuitConfirmPending(bool on)
|
||||
{
|
||||
quit_confirm_pending_ = on;
|
||||
@@ -192,6 +270,31 @@ public:
|
||||
}
|
||||
|
||||
|
||||
// --- Buffer close/save confirmation state ---
|
||||
void SetCloseConfirmPending(bool on)
|
||||
{
|
||||
close_confirm_pending_ = on;
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] bool CloseConfirmPending() const
|
||||
{
|
||||
return close_confirm_pending_;
|
||||
}
|
||||
|
||||
|
||||
void SetCloseAfterSave(bool on)
|
||||
{
|
||||
close_after_save_ = on;
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] bool CloseAfterSave() const
|
||||
{
|
||||
return close_after_save_;
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] std::time_t StatusTime() const
|
||||
{
|
||||
return msgtm_;
|
||||
@@ -418,7 +521,7 @@ public:
|
||||
// Buffers
|
||||
[[nodiscard]] std::size_t BufferCount() const
|
||||
{
|
||||
return buffers_.size();
|
||||
return Buffers().size();
|
||||
}
|
||||
|
||||
|
||||
@@ -428,6 +531,19 @@ public:
|
||||
}
|
||||
|
||||
|
||||
// Clamp curbuf_ to valid range. Call when the shared buffer list may
|
||||
// have been modified by another editor (e.g., buffer closed in another window).
|
||||
void ValidateBufferIndex()
|
||||
{
|
||||
const auto &bufs = Buffers();
|
||||
if (bufs.empty()) {
|
||||
curbuf_ = 0;
|
||||
} else if (curbuf_ >= bufs.size()) {
|
||||
curbuf_ = bufs.size() - 1;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Buffer *CurrentBuffer();
|
||||
|
||||
const Buffer *CurrentBuffer() const;
|
||||
@@ -444,6 +560,30 @@ public:
|
||||
|
||||
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
|
||||
bool SwitchTo(std::size_t index);
|
||||
|
||||
@@ -455,13 +595,29 @@ public:
|
||||
// Direct access when needed (try to prefer methods above)
|
||||
[[nodiscard]] const std::vector<Buffer> &Buffers() const
|
||||
{
|
||||
return buffers_;
|
||||
return shared_buffers_ ? *shared_buffers_ : buffers_;
|
||||
}
|
||||
|
||||
|
||||
std::vector<Buffer> &Buffers()
|
||||
{
|
||||
return buffers_;
|
||||
return shared_buffers_ ? *shared_buffers_ : buffers_;
|
||||
}
|
||||
|
||||
|
||||
// Share another editor's buffer list. When set, this editor operates on
|
||||
// the provided vector instead of its own. Pass nullptr to detach.
|
||||
void SetSharedBuffers(std::vector<Buffer> *shared)
|
||||
{
|
||||
shared_buffers_ = shared;
|
||||
curbuf_ = 0;
|
||||
}
|
||||
|
||||
|
||||
// Swap manager access (for advanced integrations/tests)
|
||||
[[nodiscard]] kte::SwapManager *Swap()
|
||||
{
|
||||
return swap_.get();
|
||||
}
|
||||
|
||||
|
||||
@@ -490,6 +646,11 @@ public:
|
||||
}
|
||||
|
||||
private:
|
||||
struct PendingOpen {
|
||||
std::string path;
|
||||
std::size_t line1{0}; // 1-based; 0 = none
|
||||
};
|
||||
|
||||
std::size_t rows_ = 0, cols_ = 0;
|
||||
int mode_ = 0;
|
||||
int kill_ = 0; // KILL CHAIN
|
||||
@@ -498,17 +659,25 @@ private:
|
||||
std::string msg_;
|
||||
std::time_t msgtm_ = 0;
|
||||
int uarg_ = 0, ucount_ = 0; // C-u support
|
||||
bool repeatable_ = false; // whether the next command is repeatable
|
||||
|
||||
std::vector<Buffer> buffers_;
|
||||
std::size_t curbuf_ = 0; // index into buffers_
|
||||
std::vector<Buffer> *shared_buffers_ = nullptr; // if set, use this instead of buffers_
|
||||
std::size_t curbuf_ = 0; // index into buffers_
|
||||
|
||||
// Swap journaling manager (lifetime = editor)
|
||||
std::unique_ptr<kte::SwapManager> swap_;
|
||||
|
||||
// Kill ring (Emacs-like)
|
||||
std::vector<std::string> kill_ring_;
|
||||
std::size_t kill_ring_max_ = 60;
|
||||
|
||||
// Quit state
|
||||
bool quit_requested_ = false;
|
||||
bool quit_confirm_pending_ = false;
|
||||
bool quit_requested_ = false;
|
||||
bool new_window_requested_ = false;
|
||||
bool quit_confirm_pending_ = false;
|
||||
bool close_confirm_pending_ = false; // awaiting y/N to save-before-close
|
||||
bool close_after_save_ = false; // if true, close buffer after successful Save/SaveAs
|
||||
|
||||
// Search state
|
||||
bool search_active_ = false;
|
||||
@@ -527,6 +696,13 @@ private:
|
||||
std::string prompt_text_;
|
||||
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)
|
||||
bool file_picker_visible_ = false;
|
||||
std::string file_picker_dir_;
|
||||
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
#include "ErrorHandler.h"
|
||||
#include <chrono>
|
||||
#include <ctime>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
#include <filesystem>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace kte {
|
||||
ErrorHandler::ErrorHandler()
|
||||
{
|
||||
// Determine log file path: ~/.local/state/kte/error.log
|
||||
const char *home = std::getenv("HOME");
|
||||
if (home) {
|
||||
fs::path log_dir = fs::path(home) / ".local" / "state" / "kte";
|
||||
try {
|
||||
if (!fs::exists(log_dir)) {
|
||||
fs::create_directories(log_dir);
|
||||
}
|
||||
log_file_path_ = (log_dir / "error.log").string();
|
||||
// Create the log file immediately so it exists in the state directory
|
||||
ensure_log_file();
|
||||
} catch (...) {
|
||||
// If we can't create the directory, disable file logging
|
||||
file_logging_enabled_ = false;
|
||||
}
|
||||
} else {
|
||||
// No HOME, disable file logging
|
||||
file_logging_enabled_ = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ErrorHandler::~ErrorHandler()
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
if (log_file_ && log_file_->is_open()) {
|
||||
log_file_->flush();
|
||||
log_file_->close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ErrorHandler &
|
||||
ErrorHandler::Instance()
|
||||
{
|
||||
static ErrorHandler instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
ErrorHandler::Report(ErrorSeverity severity, const std::string &component,
|
||||
const std::string &message, const std::string &context)
|
||||
{
|
||||
ErrorRecord record;
|
||||
record.timestamp_ns = now_ns();
|
||||
record.severity = severity;
|
||||
record.component = component;
|
||||
record.message = message;
|
||||
record.context = context;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
|
||||
// Add to in-memory queue
|
||||
errors_.push_back(record);
|
||||
while (errors_.size() > 100) {
|
||||
errors_.pop_front();
|
||||
}
|
||||
|
||||
++total_error_count_;
|
||||
if (severity == ErrorSeverity::Critical) {
|
||||
++critical_error_count_;
|
||||
}
|
||||
|
||||
// Write to log file if enabled
|
||||
if (file_logging_enabled_) {
|
||||
write_to_log(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
ErrorHandler::Info(const std::string &component, const std::string &message,
|
||||
const std::string &context)
|
||||
{
|
||||
Report(ErrorSeverity::Info, component, message, context);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
ErrorHandler::Warning(const std::string &component, const std::string &message,
|
||||
const std::string &context)
|
||||
{
|
||||
Report(ErrorSeverity::Warning, component, message, context);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
ErrorHandler::Error(const std::string &component, const std::string &message,
|
||||
const std::string &context)
|
||||
{
|
||||
Report(ErrorSeverity::Error, component, message, context);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
ErrorHandler::Critical(const std::string &component, const std::string &message,
|
||||
const std::string &context)
|
||||
{
|
||||
Report(ErrorSeverity::Critical, component, message, context);
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
ErrorHandler::HasErrors() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
return !errors_.empty();
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
ErrorHandler::HasCriticalErrors() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
return critical_error_count_ > 0;
|
||||
}
|
||||
|
||||
|
||||
std::string
|
||||
ErrorHandler::GetLastError() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
if (errors_.empty())
|
||||
return "";
|
||||
|
||||
const ErrorRecord &e = errors_.back();
|
||||
std::string result = "[" + severity_to_string(e.severity) + "] ";
|
||||
result += e.component;
|
||||
if (!e.context.empty()) {
|
||||
result += " (" + e.context + ")";
|
||||
}
|
||||
result += ": " + e.message;
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
std::size_t
|
||||
ErrorHandler::GetErrorCount() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
return total_error_count_;
|
||||
}
|
||||
|
||||
|
||||
std::size_t
|
||||
ErrorHandler::GetErrorCount(ErrorSeverity severity) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
std::size_t count = 0;
|
||||
for (const auto &e: errors_) {
|
||||
if (e.severity == severity) {
|
||||
++count;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
|
||||
std::vector<ErrorHandler::ErrorRecord>
|
||||
ErrorHandler::GetRecentErrors(std::size_t max_count) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
std::vector<ErrorRecord> result;
|
||||
result.reserve(std::min(max_count, errors_.size()));
|
||||
|
||||
// Return most recent first
|
||||
auto it = errors_.rbegin();
|
||||
for (std::size_t i = 0; i < max_count && it != errors_.rend(); ++i, ++it) {
|
||||
result.push_back(*it);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
ErrorHandler::ClearErrors()
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
errors_.clear();
|
||||
total_error_count_ = 0;
|
||||
critical_error_count_ = 0;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
ErrorHandler::SetFileLoggingEnabled(bool enabled)
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
file_logging_enabled_ = enabled;
|
||||
if (!enabled && log_file_ && log_file_->is_open()) {
|
||||
log_file_->flush();
|
||||
log_file_->close();
|
||||
log_file_.reset();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
std::string
|
||||
ErrorHandler::GetLogFilePath() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
return log_file_path_;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
ErrorHandler::write_to_log(const ErrorRecord &record)
|
||||
{
|
||||
// Must be called with mtx_ held
|
||||
if (log_file_path_.empty())
|
||||
return;
|
||||
|
||||
ensure_log_file();
|
||||
if (!log_file_ || !log_file_->is_open())
|
||||
return;
|
||||
|
||||
// Format: [timestamp] [SEVERITY] component (context): message
|
||||
std::string timestamp = format_timestamp(record.timestamp_ns);
|
||||
std::string severity = severity_to_string(record.severity);
|
||||
|
||||
*log_file_ << "[" << timestamp << "] [" << severity << "] " << record.component;
|
||||
if (!record.context.empty()) {
|
||||
*log_file_ << " (" << record.context << ")";
|
||||
}
|
||||
*log_file_ << ": " << record.message << "\n";
|
||||
log_file_->flush();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
ErrorHandler::ensure_log_file()
|
||||
{
|
||||
// Must be called with mtx_ held
|
||||
if (log_file_ && log_file_->is_open())
|
||||
return;
|
||||
|
||||
if (log_file_path_.empty())
|
||||
return;
|
||||
|
||||
try {
|
||||
log_file_ = std::make_unique<std::ofstream>(log_file_path_,
|
||||
std::ios::app | std::ios::out);
|
||||
if (!log_file_->is_open()) {
|
||||
log_file_.reset();
|
||||
}
|
||||
} catch (...) {
|
||||
log_file_.reset();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
std::string
|
||||
ErrorHandler::format_timestamp(std::uint64_t timestamp_ns) const
|
||||
{
|
||||
// Convert nanoseconds to time_t (seconds)
|
||||
std::time_t seconds = static_cast<std::time_t>(timestamp_ns / 1000000000ULL);
|
||||
std::uint64_t nanos = timestamp_ns % 1000000000ULL;
|
||||
|
||||
std::tm tm_buf{};
|
||||
#if defined(_WIN32)
|
||||
localtime_s(&tm_buf, &seconds);
|
||||
#else
|
||||
localtime_r(&seconds, &tm_buf);
|
||||
#endif
|
||||
|
||||
std::ostringstream oss;
|
||||
oss << std::put_time(&tm_buf, "%Y-%m-%d %H:%M:%S");
|
||||
oss << "." << std::setfill('0') << std::setw(3) << (nanos / 1000000ULL);
|
||||
return oss.str();
|
||||
}
|
||||
|
||||
|
||||
std::string
|
||||
ErrorHandler::severity_to_string(ErrorSeverity severity) const
|
||||
{
|
||||
switch (severity) {
|
||||
case ErrorSeverity::Info:
|
||||
return "INFO";
|
||||
case ErrorSeverity::Warning:
|
||||
return "WARNING";
|
||||
case ErrorSeverity::Error:
|
||||
return "ERROR";
|
||||
case ErrorSeverity::Critical:
|
||||
return "CRITICAL";
|
||||
default:
|
||||
return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
std::uint64_t
|
||||
ErrorHandler::now_ns()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
return duration_cast<nanoseconds>(system_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
} // namespace kte
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
// ErrorHandler.h - Centralized error handling and logging for kte
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <deque>
|
||||
#include <mutex>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <fstream>
|
||||
|
||||
namespace kte {
|
||||
enum class ErrorSeverity {
|
||||
Info, // Informational messages
|
||||
Warning, // Non-critical issues
|
||||
Error, // Errors that affect functionality but allow continuation
|
||||
Critical // Critical errors that may cause data loss or crashes
|
||||
};
|
||||
|
||||
// Centralized error handler with logging and in-memory error tracking
|
||||
class ErrorHandler {
|
||||
public:
|
||||
struct ErrorRecord {
|
||||
std::uint64_t timestamp_ns{0};
|
||||
ErrorSeverity severity{ErrorSeverity::Error};
|
||||
std::string component; // e.g., "SwapManager", "Buffer", "main"
|
||||
std::string message;
|
||||
std::string context; // e.g., filename, buffer name, operation
|
||||
};
|
||||
|
||||
// Get the global ErrorHandler instance
|
||||
static ErrorHandler &Instance();
|
||||
|
||||
// Report an error with severity, component, message, and optional context
|
||||
void Report(ErrorSeverity severity, const std::string &component,
|
||||
const std::string &message, const std::string &context = "");
|
||||
|
||||
// Convenience methods for common severity levels
|
||||
void Info(const std::string &component, const std::string &message,
|
||||
const std::string &context = "");
|
||||
|
||||
void Warning(const std::string &component, const std::string &message,
|
||||
const std::string &context = "");
|
||||
|
||||
void Error(const std::string &component, const std::string &message,
|
||||
const std::string &context = "");
|
||||
|
||||
void Critical(const std::string &component, const std::string &message,
|
||||
const std::string &context = "");
|
||||
|
||||
// Query error state (thread-safe)
|
||||
bool HasErrors() const;
|
||||
|
||||
bool HasCriticalErrors() const;
|
||||
|
||||
std::string GetLastError() const;
|
||||
|
||||
std::size_t GetErrorCount() const;
|
||||
|
||||
std::size_t GetErrorCount(ErrorSeverity severity) const;
|
||||
|
||||
// Get recent errors (up to max_count, most recent first)
|
||||
std::vector<ErrorRecord> GetRecentErrors(std::size_t max_count = 10) const;
|
||||
|
||||
// Clear in-memory error history (does not affect log file)
|
||||
void ClearErrors();
|
||||
|
||||
// Enable/disable file logging (enabled by default)
|
||||
void SetFileLoggingEnabled(bool enabled);
|
||||
|
||||
// Get the path to the error log file
|
||||
std::string GetLogFilePath() const;
|
||||
|
||||
private:
|
||||
ErrorHandler();
|
||||
|
||||
~ErrorHandler();
|
||||
|
||||
// Non-copyable, non-movable
|
||||
ErrorHandler(const ErrorHandler &) = delete;
|
||||
|
||||
ErrorHandler &operator=(const ErrorHandler &) = delete;
|
||||
|
||||
ErrorHandler(ErrorHandler &&) = delete;
|
||||
|
||||
ErrorHandler &operator=(ErrorHandler &&) = delete;
|
||||
|
||||
void write_to_log(const ErrorRecord &record);
|
||||
|
||||
void ensure_log_file();
|
||||
|
||||
std::string format_timestamp(std::uint64_t timestamp_ns) const;
|
||||
|
||||
std::string severity_to_string(ErrorSeverity severity) const;
|
||||
|
||||
static std::uint64_t now_ns();
|
||||
|
||||
mutable std::mutex mtx_;
|
||||
std::deque<ErrorRecord> errors_; // bounded to max 100 entries
|
||||
std::size_t total_error_count_{0};
|
||||
std::size_t critical_error_count_{0};
|
||||
bool file_logging_enabled_{true};
|
||||
std::string log_file_path_;
|
||||
std::unique_ptr<std::ofstream> log_file_;
|
||||
};
|
||||
} // namespace kte
|
||||
@@ -0,0 +1,157 @@
|
||||
// ErrorRecovery.cc - Error recovery mechanisms implementation
|
||||
#include "ErrorRecovery.h"
|
||||
#include <mutex>
|
||||
|
||||
namespace kte {
|
||||
CircuitBreaker::CircuitBreaker(const Config &cfg)
|
||||
: config_(cfg), state_(State::Closed), failure_count_(0), success_count_(0),
|
||||
last_failure_time_(std::chrono::steady_clock::time_point::min()),
|
||||
state_change_time_(std::chrono::steady_clock::now()) {}
|
||||
|
||||
|
||||
bool
|
||||
CircuitBreaker::AllowRequest()
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
|
||||
switch (state_) {
|
||||
case State::Closed:
|
||||
// Normal operation, allow all requests
|
||||
return true;
|
||||
|
||||
case State::Open: {
|
||||
// Check if timeout has elapsed to transition to HalfOpen
|
||||
const auto elapsed = std::chrono::duration_cast<std::chrono::seconds>(
|
||||
now - state_change_time_
|
||||
);
|
||||
if (elapsed >= config_.open_timeout) {
|
||||
TransitionTo(State::HalfOpen);
|
||||
return true; // Allow one request to test recovery
|
||||
}
|
||||
return false; // Circuit is open, reject request
|
||||
}
|
||||
|
||||
case State::HalfOpen:
|
||||
// Allow limited requests to test recovery
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
CircuitBreaker::RecordSuccess()
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
|
||||
switch (state_) {
|
||||
case State::Closed:
|
||||
// Reset failure count on success in normal operation
|
||||
failure_count_ = 0;
|
||||
break;
|
||||
|
||||
case State::HalfOpen:
|
||||
++success_count_;
|
||||
if (success_count_ >= config_.success_threshold) {
|
||||
// Enough successes, close the circuit
|
||||
TransitionTo(State::Closed);
|
||||
}
|
||||
break;
|
||||
|
||||
case State::Open:
|
||||
// Shouldn't happen (requests rejected), but handle gracefully
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
CircuitBreaker::RecordFailure()
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
last_failure_time_ = now;
|
||||
|
||||
switch (state_) {
|
||||
case State::Closed:
|
||||
// Check if we need to reset the failure count (window expired)
|
||||
if (IsWindowExpired()) {
|
||||
failure_count_ = 0;
|
||||
}
|
||||
|
||||
++failure_count_;
|
||||
if (failure_count_ >= config_.failure_threshold) {
|
||||
// Too many failures, open the circuit
|
||||
TransitionTo(State::Open);
|
||||
}
|
||||
break;
|
||||
|
||||
case State::HalfOpen:
|
||||
// Failure during recovery test, reopen the circuit
|
||||
TransitionTo(State::Open);
|
||||
break;
|
||||
|
||||
case State::Open:
|
||||
// Already open, just track the failure
|
||||
++failure_count_;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
CircuitBreaker::Reset()
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
TransitionTo(State::Closed);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
CircuitBreaker::TransitionTo(State new_state)
|
||||
{
|
||||
if (state_ == new_state) {
|
||||
return;
|
||||
}
|
||||
|
||||
state_ = new_state;
|
||||
state_change_time_ = std::chrono::steady_clock::now();
|
||||
|
||||
switch (new_state) {
|
||||
case State::Closed:
|
||||
failure_count_ = 0;
|
||||
success_count_ = 0;
|
||||
break;
|
||||
|
||||
case State::Open:
|
||||
success_count_ = 0;
|
||||
// Keep failure_count_ for diagnostics
|
||||
break;
|
||||
|
||||
case State::HalfOpen:
|
||||
success_count_ = 0;
|
||||
// Keep failure_count_ for diagnostics
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
CircuitBreaker::IsWindowExpired() const
|
||||
{
|
||||
if (failure_count_ == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
const auto elapsed = std::chrono::duration_cast<std::chrono::seconds>(
|
||||
now - last_failure_time_
|
||||
);
|
||||
|
||||
return elapsed >= config_.window;
|
||||
}
|
||||
} // namespace kte
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
// ErrorRecovery.h - Error recovery mechanisms for kte
|
||||
#pragma once
|
||||
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <mutex>
|
||||
#include <cerrno>
|
||||
|
||||
namespace kte {
|
||||
// Classify errno values as transient (retryable) or permanent
|
||||
inline bool
|
||||
IsTransientError(int err)
|
||||
{
|
||||
switch (err) {
|
||||
case EAGAIN:
|
||||
#if EAGAIN != EWOULDBLOCK
|
||||
case EWOULDBLOCK:
|
||||
#endif
|
||||
case EBUSY:
|
||||
case EIO: // I/O error (may be transient on network filesystems)
|
||||
case ETIMEDOUT:
|
||||
case ENOSPC: // Disk full (may become available)
|
||||
case EDQUOT: // Quota exceeded (may become available)
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// RetryPolicy defines retry behavior for transient failures
|
||||
struct RetryPolicy {
|
||||
std::size_t max_attempts{3}; // Maximum retry attempts
|
||||
std::chrono::milliseconds initial_delay{100}; // Initial delay before first retry
|
||||
double backoff_multiplier{2.0}; // Exponential backoff multiplier
|
||||
std::chrono::milliseconds max_delay{5000}; // Maximum delay between retries
|
||||
|
||||
// Default policy: 3 attempts, 100ms initial, 2x backoff, 5s max
|
||||
static RetryPolicy Default()
|
||||
{
|
||||
return RetryPolicy{};
|
||||
}
|
||||
|
||||
|
||||
// Aggressive policy for critical operations: more attempts, faster retries
|
||||
static RetryPolicy Aggressive()
|
||||
{
|
||||
return RetryPolicy{5, std::chrono::milliseconds(50), 1.5, std::chrono::milliseconds(2000)};
|
||||
}
|
||||
|
||||
|
||||
// Conservative policy for non-critical operations: fewer attempts, slower retries
|
||||
static RetryPolicy Conservative()
|
||||
{
|
||||
return RetryPolicy{2, std::chrono::milliseconds(200), 2.5, std::chrono::milliseconds(10000)};
|
||||
}
|
||||
};
|
||||
|
||||
// Retry a function with exponential backoff for transient errors
|
||||
// Returns true on success, false on permanent failure or exhausted retries
|
||||
// The function `fn` should return true on success, false on failure, and set errno on failure
|
||||
template<typename Func>
|
||||
bool
|
||||
RetryOnTransientError(Func fn, const RetryPolicy &policy, std::string &err)
|
||||
{
|
||||
std::size_t attempt = 0;
|
||||
std::chrono::milliseconds delay = policy.initial_delay;
|
||||
|
||||
while (attempt < policy.max_attempts) {
|
||||
++attempt;
|
||||
errno = 0;
|
||||
if (fn()) {
|
||||
return true; // Success
|
||||
}
|
||||
|
||||
int saved_errno = errno;
|
||||
if (!IsTransientError(saved_errno)) {
|
||||
// Permanent error, don't retry
|
||||
return false;
|
||||
}
|
||||
|
||||
if (attempt >= policy.max_attempts) {
|
||||
// Exhausted retries
|
||||
err += " (exhausted " + std::to_string(policy.max_attempts) + " retry attempts)";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Sleep before retry
|
||||
std::this_thread::sleep_for(delay);
|
||||
|
||||
// Exponential backoff
|
||||
delay = std::chrono::milliseconds(
|
||||
static_cast<long long>(delay.count() * policy.backoff_multiplier)
|
||||
);
|
||||
if (delay > policy.max_delay) {
|
||||
delay = policy.max_delay;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// CircuitBreaker prevents repeated attempts to failing operations
|
||||
// States: Closed (normal), Open (failing, reject immediately), HalfOpen (testing recovery)
|
||||
class CircuitBreaker {
|
||||
public:
|
||||
enum class State {
|
||||
Closed, // Normal operation, allow all requests
|
||||
Open, // Failing, reject requests immediately
|
||||
HalfOpen // Testing recovery, allow limited requests
|
||||
};
|
||||
|
||||
struct Config {
|
||||
std::size_t failure_threshold; // Failures before opening circuit
|
||||
std::chrono::seconds open_timeout; // Time before attempting recovery (Open → HalfOpen)
|
||||
std::size_t success_threshold; // Successes in HalfOpen before closing
|
||||
std::chrono::seconds window; // Time window for counting failures
|
||||
|
||||
Config()
|
||||
: failure_threshold(5), open_timeout(30), success_threshold(2), window(60) {}
|
||||
};
|
||||
|
||||
|
||||
explicit CircuitBreaker(const Config &cfg = Config());
|
||||
|
||||
|
||||
// Check if operation is allowed (returns false if circuit is Open)
|
||||
bool AllowRequest();
|
||||
|
||||
// Record successful operation
|
||||
void RecordSuccess();
|
||||
|
||||
// Record failed operation
|
||||
void RecordFailure();
|
||||
|
||||
// Get current state
|
||||
State GetState() const
|
||||
{
|
||||
return state_;
|
||||
}
|
||||
|
||||
|
||||
// Get failure count in current window
|
||||
std::size_t GetFailureCount() const
|
||||
{
|
||||
return failure_count_;
|
||||
}
|
||||
|
||||
|
||||
// Reset circuit to Closed state (for testing or manual intervention)
|
||||
void Reset();
|
||||
|
||||
private:
|
||||
void TransitionTo(State new_state);
|
||||
|
||||
bool IsWindowExpired() const;
|
||||
|
||||
Config config_;
|
||||
State state_;
|
||||
std::size_t failure_count_;
|
||||
std::size_t success_count_;
|
||||
std::chrono::steady_clock::time_point last_failure_time_;
|
||||
std::chrono::steady_clock::time_point state_change_time_;
|
||||
mutable std::mutex mtx_;
|
||||
};
|
||||
} // namespace kte
|
||||
+2
-2
@@ -12,11 +12,11 @@ public:
|
||||
virtual ~Frontend() = default;
|
||||
|
||||
// Initialize the frontend (create window/terminal, etc.)
|
||||
virtual bool Init(Editor &ed) = 0;
|
||||
virtual bool Init(int &argc, char **argv, Editor &ed) = 0;
|
||||
|
||||
// Execute one iteration (poll input, dispatch, draw). Set running=false to exit.
|
||||
virtual void Step(Editor &ed, bool &running) = 0;
|
||||
|
||||
// Shutdown/cleanup
|
||||
virtual void Shutdown() = 0;
|
||||
};
|
||||
};
|
||||
|
||||
+131
-10
@@ -3,9 +3,29 @@
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <algorithm>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
|
||||
#include "GUIConfig.h"
|
||||
|
||||
// toml++ for TOML config parsing
|
||||
#if defined(__clang__)
|
||||
# pragma clang diagnostic push
|
||||
# pragma clang diagnostic ignored "-Weverything"
|
||||
#elif defined(__GNUC__)
|
||||
# pragma GCC diagnostic push
|
||||
# pragma GCC diagnostic ignored "-Wall"
|
||||
# pragma GCC diagnostic ignored "-Wextra"
|
||||
#endif
|
||||
|
||||
#include "ext/tomlplusplus/toml.hpp"
|
||||
|
||||
#if defined(__clang__)
|
||||
# pragma clang diagnostic pop
|
||||
#elif defined(__GNUC__)
|
||||
# pragma GCC diagnostic pop
|
||||
#endif
|
||||
|
||||
|
||||
static void
|
||||
trim(std::string &s)
|
||||
@@ -19,37 +39,124 @@ trim(std::string &s)
|
||||
|
||||
|
||||
static std::string
|
||||
default_config_path()
|
||||
config_dir()
|
||||
{
|
||||
const char *home = std::getenv("HOME");
|
||||
if (!home || !*home)
|
||||
return {};
|
||||
std::string path(home);
|
||||
path += "/.config/kte/kge.ini";
|
||||
return path;
|
||||
return std::string(home) + "/.config/kte";
|
||||
}
|
||||
|
||||
|
||||
GUIConfig
|
||||
GUIConfig::Load()
|
||||
{
|
||||
GUIConfig cfg; // defaults already set
|
||||
const std::string path = default_config_path();
|
||||
GUIConfig cfg;
|
||||
std::string dir = config_dir();
|
||||
if (dir.empty())
|
||||
return cfg;
|
||||
|
||||
if (!path.empty()) {
|
||||
cfg.LoadFromFile(path);
|
||||
// Try TOML first
|
||||
std::string toml_path = dir + "/kge.toml";
|
||||
if (cfg.LoadFromTOML(toml_path))
|
||||
return cfg;
|
||||
|
||||
// Fall back to legacy INI
|
||||
std::string ini_path = dir + "/kge.ini";
|
||||
if (cfg.LoadFromINI(ini_path)) {
|
||||
std::cerr << "kge: loaded legacy kge.ini; consider migrating to kge.toml\n";
|
||||
return cfg;
|
||||
}
|
||||
|
||||
return cfg;
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
GUIConfig::LoadFromFile(const std::string &path)
|
||||
GUIConfig::LoadFromTOML(const std::string &path)
|
||||
{
|
||||
if (!std::filesystem::exists(path))
|
||||
return false;
|
||||
|
||||
toml::table tbl;
|
||||
try {
|
||||
tbl = toml::parse_file(path);
|
||||
} catch (const toml::parse_error &err) {
|
||||
std::cerr << "kge: TOML parse error in " << path << ": " << err.what() << "\n";
|
||||
return false;
|
||||
}
|
||||
|
||||
// [window]
|
||||
if (auto win = tbl["window"].as_table()) {
|
||||
if (auto v = (*win)["fullscreen"].value<bool>())
|
||||
fullscreen = *v;
|
||||
if (auto v = (*win)["columns"].value<int64_t>()) {
|
||||
if (*v > 0) columns = static_cast<int>(*v);
|
||||
}
|
||||
if (auto v = (*win)["rows"].value<int64_t>()) {
|
||||
if (*v > 0) rows = static_cast<int>(*v);
|
||||
}
|
||||
}
|
||||
|
||||
// [font]
|
||||
bool explicit_code_font = false;
|
||||
bool explicit_writing_font = false;
|
||||
if (auto sec = tbl["font"].as_table()) {
|
||||
if (auto v = (*sec)["name"].value<std::string>())
|
||||
font = *v;
|
||||
if (auto v = (*sec)["size"].value<double>()) {
|
||||
if (*v > 0.0) font_size = static_cast<float>(*v);
|
||||
}
|
||||
if (auto v = (*sec)["code"].value<std::string>()) {
|
||||
code_font = *v;
|
||||
explicit_code_font = true;
|
||||
}
|
||||
if (auto v = (*sec)["writing"].value<std::string>()) {
|
||||
writing_font = *v;
|
||||
explicit_writing_font = true;
|
||||
}
|
||||
}
|
||||
|
||||
// [appearance]
|
||||
if (auto sec = tbl["appearance"].as_table()) {
|
||||
if (auto v = (*sec)["theme"].value<std::string>())
|
||||
theme = *v;
|
||||
if (auto v = (*sec)["background"].value<std::string>()) {
|
||||
std::string bg = *v;
|
||||
std::transform(bg.begin(), bg.end(), bg.begin(), [](unsigned char c) {
|
||||
return (char) std::tolower(c);
|
||||
});
|
||||
if (bg == "light" || bg == "dark")
|
||||
background = bg;
|
||||
}
|
||||
}
|
||||
|
||||
// [editor]
|
||||
if (auto sec = tbl["editor"].as_table()) {
|
||||
if (auto v = (*sec)["syntax"].value<bool>())
|
||||
syntax = *v;
|
||||
}
|
||||
|
||||
// Default code_font to the main font if not explicitly set
|
||||
if (!explicit_code_font)
|
||||
code_font = font;
|
||||
if (!explicit_writing_font && writing_font == "crimsonpro" && font != "default")
|
||||
writing_font = font;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
GUIConfig::LoadFromINI(const std::string &path)
|
||||
{
|
||||
std::ifstream in(path);
|
||||
if (!in.good())
|
||||
return false;
|
||||
|
||||
bool explicit_code_font = false;
|
||||
bool explicit_writing_font = false;
|
||||
|
||||
std::string line;
|
||||
while (std::getline(in, line)) {
|
||||
// Remove comments starting with '#' or ';'
|
||||
@@ -104,6 +211,12 @@ GUIConfig::LoadFromFile(const std::string &path)
|
||||
}
|
||||
} else if (key == "font") {
|
||||
font = val;
|
||||
} else if (key == "code_font") {
|
||||
code_font = val;
|
||||
explicit_code_font = true;
|
||||
} else if (key == "writing_font") {
|
||||
writing_font = val;
|
||||
explicit_writing_font = true;
|
||||
} else if (key == "theme") {
|
||||
theme = val;
|
||||
} else if (key == "background" || key == "bg") {
|
||||
@@ -126,5 +239,13 @@ GUIConfig::LoadFromFile(const std::string &path)
|
||||
}
|
||||
}
|
||||
|
||||
// If code_font was not explicitly set, default it to the main font
|
||||
// so that the edit-mode font switcher doesn't immediately switch away
|
||||
// from the font loaded during Init.
|
||||
if (!explicit_code_font)
|
||||
code_font = font;
|
||||
if (!explicit_writing_font && writing_font == "crimsonpro" && font != "default")
|
||||
writing_font = font;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
+15
-7
@@ -1,5 +1,7 @@
|
||||
/*
|
||||
* GUIConfig - loads simple GUI configuration from $HOME/.config/kte/kge.ini
|
||||
* GUIConfig - loads GUI configuration from $HOME/.config/kte/kge.toml
|
||||
*
|
||||
* Falls back to legacy kge.ini if no TOML config is found.
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
@@ -22,12 +24,18 @@ public:
|
||||
std::string background = "dark";
|
||||
|
||||
// Default syntax highlighting state for GUI (kge): on/off
|
||||
// Accepts: on/off/true/false/yes/no/1/0 in the ini file.
|
||||
bool syntax = true; // default: enabled
|
||||
bool syntax = true;
|
||||
|
||||
// Load from default path: $HOME/.config/kte/kge.ini
|
||||
// Per-mode font defaults
|
||||
std::string code_font = "default";
|
||||
std::string writing_font = "crimsonpro";
|
||||
|
||||
// Load from default paths: try kge.toml first, fall back to kge.ini
|
||||
static GUIConfig Load();
|
||||
|
||||
// Load from explicit path. Returns true if file existed and was parsed.
|
||||
bool LoadFromFile(const std::string &path);
|
||||
};
|
||||
// Load from explicit TOML path. Returns true if file existed and was parsed.
|
||||
bool LoadFromTOML(const std::string &path);
|
||||
|
||||
// Load from explicit INI path (legacy). Returns true if file existed and was parsed.
|
||||
bool LoadFromINI(const std::string &path);
|
||||
};
|
||||
|
||||
-367
@@ -1,367 +0,0 @@
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
|
||||
#include <imgui.h>
|
||||
#include <SDL.h>
|
||||
#include <SDL_opengl.h>
|
||||
#include <backends/imgui_impl_opengl3.h>
|
||||
#include <backends/imgui_impl_sdl2.h>
|
||||
|
||||
#include "GUIFrontend.h"
|
||||
#include "Command.h"
|
||||
#include "Editor.h"
|
||||
#include "GUIConfig.h"
|
||||
#include "GUITheme.h"
|
||||
#include "fonts/Font.h" // embedded default font (DefaultFont)
|
||||
#include "fonts/FontRegistry.h"
|
||||
#include "syntax/HighlighterRegistry.h"
|
||||
#include "syntax/NullHighlighter.h"
|
||||
|
||||
|
||||
#ifndef KTE_FONT_SIZE
|
||||
#define KTE_FONT_SIZE 16.0f
|
||||
#endif
|
||||
|
||||
static auto kGlslVersion = "#version 150"; // GL 3.2 core (macOS compatible)
|
||||
|
||||
bool
|
||||
GUIFrontend::Init(Editor &ed)
|
||||
{
|
||||
(void) ed; // editor dimensions will be initialized during the first Step() frame
|
||||
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER) != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Load GUI configuration (fullscreen, columns/rows, font size, theme, background)
|
||||
GUIConfig cfg = GUIConfig::Load();
|
||||
|
||||
// GL attributes for core profile
|
||||
SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, 0);
|
||||
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE);
|
||||
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
|
||||
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 2);
|
||||
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
|
||||
SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24);
|
||||
SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8);
|
||||
|
||||
// Compute desired window size from config
|
||||
Uint32 win_flags = SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI;
|
||||
|
||||
if (cfg.fullscreen) {
|
||||
// "Fullscreen": fill the usable bounds of the primary display.
|
||||
// On macOS, do NOT use true fullscreen so the menu/status bar remains visible.
|
||||
SDL_Rect usable{};
|
||||
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
|
||||
width_ = usable.w;
|
||||
height_ = usable.h;
|
||||
}
|
||||
#if !defined(__APPLE__)
|
||||
// Non-macOS: desktop fullscreen uses the current display resolution.
|
||||
win_flags |= SDL_WINDOW_FULLSCREEN_DESKTOP;
|
||||
#endif
|
||||
} else {
|
||||
// Windowed: width = columns * font_size, height = (rows * 2) * font_size
|
||||
int w = cfg.columns * static_cast<int>(cfg.font_size);
|
||||
int h = cfg.rows * static_cast<int>(cfg.font_size * 1.2);
|
||||
|
||||
// As a safety, clamp to display usable bounds if retrievable
|
||||
SDL_Rect usable{};
|
||||
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
|
||||
w = std::min(w, usable.w);
|
||||
h = std::min(h, usable.h);
|
||||
}
|
||||
width_ = std::max(320, w);
|
||||
height_ = std::max(200, h);
|
||||
}
|
||||
|
||||
SDL_SetHint(SDL_HINT_VIDEO_ALLOW_SCREENSAVER, "1");
|
||||
window_ = SDL_CreateWindow(
|
||||
"kge - kyle's graphical editor " KTE_VERSION_STR,
|
||||
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
|
||||
width_, height_,
|
||||
win_flags);
|
||||
if (!window_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
SDL_EnableScreenSaver();
|
||||
|
||||
#if defined(__APPLE__)
|
||||
// macOS: when "fullscreen" is requested, position the window at the
|
||||
// top-left of the usable display area to mimic fullscreen while keeping
|
||||
// the system menu bar visible.
|
||||
if (cfg.fullscreen) {
|
||||
SDL_Rect usable{};
|
||||
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
|
||||
SDL_SetWindowPosition(window_, usable.x, usable.y);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
gl_ctx_ = SDL_GL_CreateContext(window_);
|
||||
if (!gl_ctx_)
|
||||
return false;
|
||||
SDL_GL_MakeCurrent(window_, gl_ctx_);
|
||||
SDL_GL_SetSwapInterval(1); // vsync
|
||||
|
||||
IMGUI_CHECKVERSION();
|
||||
ImGui::CreateContext();
|
||||
ImGuiIO &io = ImGui::GetIO();
|
||||
|
||||
// Set custom ini filename path to ~/.config/kte/imgui.ini
|
||||
if (const char *home = std::getenv("HOME")) {
|
||||
namespace fs = std::filesystem;
|
||||
fs::path config_dir = fs::path(home) / ".config" / "kte";
|
||||
|
||||
std::error_code ec;
|
||||
if (!fs::exists(config_dir)) {
|
||||
fs::create_directories(config_dir, ec);
|
||||
}
|
||||
|
||||
if (fs::exists(config_dir)) {
|
||||
static std::string ini_path = (config_dir / "imgui.ini").string();
|
||||
io.IniFilename = ini_path.c_str();
|
||||
}
|
||||
}
|
||||
|
||||
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; // Enable Keyboard Controls
|
||||
io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad; // Enable Gamepad Controls
|
||||
ImGui::StyleColorsDark();
|
||||
|
||||
// Apply background mode and selected theme (default: Nord). Can be changed at runtime via commands.
|
||||
if (cfg.background == "light")
|
||||
kte::SetBackgroundMode(kte::BackgroundMode::Light);
|
||||
else
|
||||
kte::SetBackgroundMode(kte::BackgroundMode::Dark);
|
||||
kte::ApplyThemeByName(cfg.theme);
|
||||
|
||||
// Apply default syntax highlighting preference from GUI config to the current buffer
|
||||
if (Buffer *b = ed.CurrentBuffer()) {
|
||||
if (cfg.syntax) {
|
||||
b->SetSyntaxEnabled(true);
|
||||
// Ensure a highlighter is available if possible
|
||||
b->EnsureHighlighter();
|
||||
if (auto *eng = b->Highlighter()) {
|
||||
if (!eng->HasHighlighter()) {
|
||||
// Try detect from filename and first line; fall back to cpp or existing filetype
|
||||
std::string first_line;
|
||||
const auto &rows = b->Rows();
|
||||
if (!rows.empty())
|
||||
first_line = static_cast<std::string>(rows[0]);
|
||||
std::string ft = kte::HighlighterRegistry::DetectForPath(
|
||||
b->Filename(), first_line);
|
||||
if (!ft.empty()) {
|
||||
eng->SetHighlighter(kte::HighlighterRegistry::CreateFor(ft));
|
||||
b->SetFiletype(ft);
|
||||
eng->InvalidateFrom(0);
|
||||
} else {
|
||||
// Unknown/unsupported -> install a null highlighter to keep syntax enabled
|
||||
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
||||
b->SetFiletype("");
|
||||
eng->InvalidateFrom(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
b->SetSyntaxEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!ImGui_ImplSDL2_InitForOpenGL(window_, gl_ctx_))
|
||||
return false;
|
||||
if (!ImGui_ImplOpenGL3_Init(kGlslVersion))
|
||||
return false;
|
||||
|
||||
// Cache initial window size; logical rows/cols will be computed in Step() once a valid ImGui frame exists
|
||||
int w, h;
|
||||
SDL_GetWindowSize(window_, &w, &h);
|
||||
width_ = w;
|
||||
height_ = h;
|
||||
|
||||
#if defined(__APPLE__)
|
||||
// Workaround: On macOS Retina when starting maximized, we sometimes get a
|
||||
// subtle input vs draw alignment mismatch until the first manual resize.
|
||||
// Nudge the window size by 1px and back to trigger a proper internal
|
||||
// recomputation, without visible impact.
|
||||
if (w > 1 && h > 1) {
|
||||
SDL_SetWindowSize(window_, w - 1, h - 1);
|
||||
SDL_SetWindowSize(window_, w, h);
|
||||
// Update cached size in case backend reports immediately
|
||||
SDL_GetWindowSize(window_, &w, &h);
|
||||
width_ = w;
|
||||
height_ = h;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Install embedded fonts into registry and load configured font
|
||||
kte::Fonts::InstallDefaultFonts();
|
||||
// Initialize font atlas using configured font name and size; fallback to embedded default helper
|
||||
if (!kte::Fonts::FontRegistry::Instance().LoadFont(cfg.font, (float) cfg.font_size)) {
|
||||
LoadGuiFont_(nullptr, (float) cfg.font_size);
|
||||
// Record defaults in registry so subsequent size changes have a base
|
||||
kte::Fonts::FontRegistry::Instance().RequestLoadFont("default", (float) cfg.font_size);
|
||||
std::string n;
|
||||
float s = 0.0f;
|
||||
if (kte::Fonts::FontRegistry::Instance().ConsumePendingFontRequest(n, s)) {
|
||||
kte::Fonts::FontRegistry::Instance().LoadFont(n, s);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GUIFrontend::Step(Editor &ed, bool &running)
|
||||
{
|
||||
SDL_Event e;
|
||||
while (SDL_PollEvent(&e)) {
|
||||
ImGui_ImplSDL2_ProcessEvent(&e);
|
||||
switch (e.type) {
|
||||
case SDL_QUIT:
|
||||
running = false;
|
||||
break;
|
||||
case SDL_WINDOWEVENT:
|
||||
if (e.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) {
|
||||
width_ = e.window.data1;
|
||||
height_ = e.window.data2;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
// Map input to commands
|
||||
input_.ProcessSDLEvent(e);
|
||||
}
|
||||
|
||||
// Apply pending font change before starting a new frame
|
||||
{
|
||||
std::string fname;
|
||||
float fsize = 0.0f;
|
||||
if (kte::Fonts::FontRegistry::Instance().ConsumePendingFontRequest(fname, fsize)) {
|
||||
if (!fname.empty() && fsize > 0.0f) {
|
||||
kte::Fonts::FontRegistry::Instance().LoadFont(fname, fsize);
|
||||
// Recreate backend font texture
|
||||
ImGui_ImplOpenGL3_DestroyFontsTexture();
|
||||
ImGui_ImplOpenGL3_CreateFontsTexture();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start a new ImGui frame BEFORE processing commands so dimensions are correct
|
||||
ImGui_ImplOpenGL3_NewFrame();
|
||||
ImGui_ImplSDL2_NewFrame(window_);
|
||||
ImGui::NewFrame();
|
||||
|
||||
// Update editor logical rows/cols using current ImGui metrics and display size
|
||||
{
|
||||
ImGuiIO &io = ImGui::GetIO();
|
||||
float line_h = ImGui::GetTextLineHeightWithSpacing();
|
||||
float ch_w = ImGui::CalcTextSize("M").x;
|
||||
if (line_h <= 0.0f)
|
||||
line_h = 16.0f;
|
||||
if (ch_w <= 0.0f)
|
||||
ch_w = 8.0f;
|
||||
// Prefer ImGui IO display size; fall back to cached SDL window size
|
||||
float disp_w = io.DisplaySize.x > 0 ? io.DisplaySize.x : static_cast<float>(width_);
|
||||
float disp_h = io.DisplaySize.y > 0 ? io.DisplaySize.y : static_cast<float>(height_);
|
||||
|
||||
// Account for the GUI window padding and the status bar height used in GUIRenderer
|
||||
const ImGuiStyle &style = ImGui::GetStyle();
|
||||
float pad_x = style.WindowPadding.x;
|
||||
float pad_y = style.WindowPadding.y;
|
||||
// Status bar reserves one frame height (with spacing) inside the window
|
||||
float status_h = ImGui::GetFrameHeightWithSpacing();
|
||||
|
||||
float avail_w = std::max(0.0f, disp_w - 2.0f * pad_x);
|
||||
float avail_h = std::max(0.0f, disp_h - 2.0f * pad_y - status_h);
|
||||
|
||||
// Visible content rows inside the scroll child
|
||||
auto content_rows = static_cast<std::size_t>(std::floor(avail_h / line_h));
|
||||
// Editor::Rows includes the status line; add 1 back for it.
|
||||
std::size_t rows = std::max<std::size_t>(1, content_rows + 1);
|
||||
std::size_t cols = static_cast<std::size_t>(std::max(1.0f, std::floor(avail_w / ch_w)));
|
||||
|
||||
// Only update if changed to avoid churn
|
||||
if (rows != ed.Rows() || cols != ed.Cols()) {
|
||||
ed.SetDimensions(rows, cols);
|
||||
}
|
||||
}
|
||||
|
||||
// Execute pending mapped inputs (drain queue) AFTER dimensions are updated
|
||||
for (;;) {
|
||||
MappedInput mi;
|
||||
if (!input_.Poll(mi))
|
||||
break;
|
||||
if (mi.hasCommand) {
|
||||
// Track kill ring before and after to sync GUI clipboard when it changes
|
||||
const std::string before = ed.KillRingHead();
|
||||
Execute(ed, mi.id, mi.arg, mi.count);
|
||||
const std::string after = ed.KillRingHead();
|
||||
if (after != before && !after.empty()) {
|
||||
// Update the system clipboard to mirror the kill ring head in GUI
|
||||
SDL_SetClipboardText(after.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ed.QuitRequested()) {
|
||||
running = false;
|
||||
}
|
||||
|
||||
// No runtime font UI; always use embedded font.
|
||||
|
||||
// Draw editor UI
|
||||
renderer_.Draw(ed);
|
||||
|
||||
// Render
|
||||
ImGui::Render();
|
||||
int display_w, display_h;
|
||||
SDL_GL_GetDrawableSize(window_, &display_w, &display_h);
|
||||
glViewport(0, 0, display_w, display_h);
|
||||
glClearColor(0.1f, 0.1f, 0.11f, 1.0f);
|
||||
glClear(GL_COLOR_BUFFER_BIT);
|
||||
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
|
||||
SDL_GL_SwapWindow(window_);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GUIFrontend::Shutdown()
|
||||
{
|
||||
ImGui_ImplOpenGL3_Shutdown();
|
||||
ImGui_ImplSDL2_Shutdown();
|
||||
ImGui::DestroyContext();
|
||||
|
||||
if (gl_ctx_) {
|
||||
SDL_GL_DeleteContext(gl_ctx_);
|
||||
gl_ctx_ = nullptr;
|
||||
}
|
||||
if (window_) {
|
||||
SDL_DestroyWindow(window_);
|
||||
window_ = nullptr;
|
||||
}
|
||||
SDL_Quit();
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
GUIFrontend::LoadGuiFont_(const char * /*path*/, const float size_px)
|
||||
{
|
||||
const ImGuiIO &io = ImGui::GetIO();
|
||||
io.Fonts->Clear();
|
||||
const ImFont *font = io.Fonts->AddFontFromMemoryCompressedTTF(
|
||||
kte::Fonts::DefaultFontData,
|
||||
kte::Fonts::DefaultFontSize,
|
||||
size_px);
|
||||
if (!font) {
|
||||
font = io.Fonts->AddFontDefault();
|
||||
}
|
||||
(void) font;
|
||||
io.Fonts->Build();
|
||||
return true;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
/*
|
||||
* GUIFrontend - couples GUIInputHandler + GUIRenderer and owns SDL2/ImGui lifecycle
|
||||
*/
|
||||
#pragma once
|
||||
#include "Frontend.h"
|
||||
#include "GUIConfig.h"
|
||||
#include "GUIInputHandler.h"
|
||||
#include "GUIRenderer.h"
|
||||
|
||||
|
||||
struct SDL_Window;
|
||||
typedef void *SDL_GLContext;
|
||||
|
||||
class GUIFrontend final : public Frontend {
|
||||
public:
|
||||
GUIFrontend() = default;
|
||||
|
||||
~GUIFrontend() override = default;
|
||||
|
||||
bool Init(Editor &ed) override;
|
||||
|
||||
void Step(Editor &ed, bool &running) override;
|
||||
|
||||
void Shutdown() override;
|
||||
|
||||
private:
|
||||
static bool LoadGuiFont_(const char *path, float size_px);
|
||||
|
||||
GUIConfig config_{};
|
||||
GUIInputHandler input_{};
|
||||
GUIRenderer renderer_{};
|
||||
SDL_Window *window_ = nullptr;
|
||||
SDL_GLContext gl_ctx_ = nullptr;
|
||||
int width_ = 1280;
|
||||
int height_ = 800;
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
/*
|
||||
* GUIRenderer - ImGui-based renderer for GUI mode
|
||||
*/
|
||||
#pragma once
|
||||
#include "Renderer.h"
|
||||
|
||||
class GUIRenderer final : public Renderer {
|
||||
public:
|
||||
GUIRenderer() = default;
|
||||
|
||||
~GUIRenderer() override = default;
|
||||
|
||||
void Draw(Editor &ed) override;
|
||||
};
|
||||
+488
-22
@@ -1,11 +1,307 @@
|
||||
// GUITheme.h — ImGui theming helpers and background mode
|
||||
// GUITheme.h — theming helpers and background mode
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
#include "Highlight.h"
|
||||
|
||||
// Cross-frontend theme change request hook: declared here, defined in Command.cc
|
||||
namespace kte {
|
||||
extern bool gThemeChangePending;
|
||||
extern std::string gThemeChangeRequest; // raw user-provided name
|
||||
// Qt GUI: cross-frontend font change hooks and current font state
|
||||
extern bool gFontChangePending;
|
||||
extern std::string gFontFamilyRequest; // requested family (case-insensitive)
|
||||
extern float gFontSizeRequest; // <= 0 means keep size
|
||||
extern std::string gCurrentFontFamily; // last applied family (Qt)
|
||||
extern float gCurrentFontSize; // last applied size (Qt)
|
||||
// Qt GUI: request to show a visual font dialog (set by command handler)
|
||||
extern bool gFontDialogRequested;
|
||||
}
|
||||
|
||||
#if defined(KTE_USE_QT)
|
||||
// Qt build: avoid hard dependency on ImGui headers/types.
|
||||
// Provide a lightweight color vector matching ImVec4 fields used by renderers.
|
||||
struct KteColor {
|
||||
float x{0}, y{0}, z{0}, w{1};
|
||||
};
|
||||
|
||||
static inline KteColor
|
||||
RGBA(unsigned int rgb, float a = 1.0f)
|
||||
{
|
||||
const float r = static_cast<float>((rgb >> 16) & 0xFF) / 255.0f;
|
||||
const float g = static_cast<float>((rgb >> 8) & 0xFF) / 255.0f;
|
||||
const float b = static_cast<float>(rgb & 0xFF) / 255.0f;
|
||||
return {r, g, b, a};
|
||||
}
|
||||
|
||||
namespace kte {
|
||||
// Background mode selection for light/dark palettes
|
||||
enum class BackgroundMode { Light, Dark };
|
||||
|
||||
// Global background mode; default to Dark to match prior defaults
|
||||
static inline auto gBackgroundMode = BackgroundMode::Dark;
|
||||
|
||||
|
||||
static inline void
|
||||
SetBackgroundMode(const BackgroundMode m)
|
||||
{
|
||||
gBackgroundMode = m;
|
||||
}
|
||||
|
||||
|
||||
static inline BackgroundMode
|
||||
GetBackgroundMode()
|
||||
{
|
||||
return gBackgroundMode;
|
||||
}
|
||||
|
||||
|
||||
// Minimal GUI palette for Qt builds. This mirrors the defaults used in the ImGui
|
||||
// frontend (Nord-ish) and switches for light/dark background mode.
|
||||
struct Palette {
|
||||
KteColor bg; // editor background
|
||||
KteColor fg; // default foreground text
|
||||
KteColor sel_bg; // selection background
|
||||
KteColor cur_bg; // cursor cell background
|
||||
KteColor status_bg; // status bar background
|
||||
KteColor status_fg; // status bar foreground
|
||||
};
|
||||
|
||||
// Optional theme override (Qt): when set, GetPalette() will return this instead
|
||||
// of the generic light/dark defaults. This allows honoring theme names in kge.ini.
|
||||
static inline bool gPaletteOverride = false;
|
||||
static inline Palette gOverridePalette{};
|
||||
static inline std::string gOverrideThemeName = ""; // lowercased name
|
||||
|
||||
static inline Palette
|
||||
GetPalette()
|
||||
{
|
||||
const bool dark = (GetBackgroundMode() == BackgroundMode::Dark);
|
||||
if (gPaletteOverride) {
|
||||
return gOverridePalette;
|
||||
}
|
||||
if (dark) {
|
||||
return Palette{
|
||||
/*bg*/ RGBA(0x1C1C1E),
|
||||
/*fg*/ RGBA(0xDCDCDC),
|
||||
/*sel_bg*/ RGBA(0xC8C800, 0.35f),
|
||||
/*cur_bg*/ RGBA(0xC8C8FF, 0.50f),
|
||||
/*status_bg*/ RGBA(0x28282C),
|
||||
/*status_fg*/ RGBA(0xB4B48C)
|
||||
};
|
||||
} else {
|
||||
// Light palette tuned for readability
|
||||
return Palette{
|
||||
/*bg*/ RGBA(0xFBFBFC),
|
||||
/*fg*/ RGBA(0x30343A),
|
||||
/*sel_bg*/ RGBA(0x268BD2, 0.22f),
|
||||
/*cur_bg*/ RGBA(0x000000, 0.15f),
|
||||
/*status_bg*/ RGBA(0xE6E8EA),
|
||||
/*status_fg*/ RGBA(0x50555A)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// A few named palettes to provide visible differences between themes in Qt.
|
||||
// These are approximate and palette-based (no widget style changes like ImGuiStyle).
|
||||
static inline Palette
|
||||
NordDark()
|
||||
{
|
||||
return {
|
||||
/*bg*/RGBA(0x2E3440), /*fg*/RGBA(0xD8DEE9), /*sel_bg*/RGBA(0x88C0D0, 0.25f),
|
||||
/*cur_bg*/RGBA(0x81A1C1, 0.35f), /*status_bg*/RGBA(0x3B4252), /*status_fg*/RGBA(0xE5E9F0)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
static inline Palette
|
||||
NordLight()
|
||||
{
|
||||
return {
|
||||
/*bg*/RGBA(0xECEFF4), /*fg*/RGBA(0x2E3440), /*sel_bg*/RGBA(0x5E81AC, 0.22f),
|
||||
/*cur_bg*/RGBA(0x000000, 0.12f), /*status_bg*/RGBA(0xE5E9F0), /*status_fg*/RGBA(0x4C566A)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
static inline Palette
|
||||
SolarizedDark()
|
||||
{
|
||||
return {
|
||||
/*bg*/RGBA(0x002b36), /*fg*/RGBA(0x93a1a1), /*sel_bg*/RGBA(0x586e75, 0.40f),
|
||||
/*cur_bg*/RGBA(0x657b83, 0.35f), /*status_bg*/RGBA(0x073642), /*status_fg*/RGBA(0xeee8d5)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
static inline Palette
|
||||
SolarizedLight()
|
||||
{
|
||||
return {
|
||||
/*bg*/RGBA(0xfdf6e3), /*fg*/RGBA(0x586e75), /*sel_bg*/RGBA(0x268bd2, 0.25f),
|
||||
/*cur_bg*/RGBA(0x000000, 0.10f), /*status_bg*/RGBA(0xeee8d5), /*status_fg*/RGBA(0x657b83)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
static inline Palette
|
||||
GruvboxDark()
|
||||
{
|
||||
return {
|
||||
/*bg*/RGBA(0x282828), /*fg*/RGBA(0xebdbb2), /*sel_bg*/RGBA(0xd79921, 0.35f),
|
||||
/*cur_bg*/RGBA(0x458588, 0.40f), /*status_bg*/RGBA(0x3c3836), /*status_fg*/RGBA(0xd5c4a1)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
static inline Palette
|
||||
GruvboxLight()
|
||||
{
|
||||
return {
|
||||
/*bg*/RGBA(0xfbf1c7), /*fg*/RGBA(0x3c3836), /*sel_bg*/RGBA(0x076678, 0.22f),
|
||||
/*cur_bg*/RGBA(0x000000, 0.10f), /*status_bg*/RGBA(0xebdbb2), /*status_fg*/RGBA(0x504945)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
static inline Palette
|
||||
EInk()
|
||||
{
|
||||
return {
|
||||
/*bg*/RGBA(0xffffff), /*fg*/RGBA(0x000000), /*sel_bg*/RGBA(0x000000, 0.10f),
|
||||
/*cur_bg*/RGBA(0x000000, 0.12f), /*status_bg*/RGBA(0x000000), /*status_fg*/RGBA(0xffffff)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Apply a Qt theme by name. Returns true on success. Name matching is case-insensitive and
|
||||
// supports common aliases (e.g., "solarized-light" or "solarized light"). If the name conveys
|
||||
// a background (light/dark), BackgroundMode is updated to keep SyntaxInk consistent.
|
||||
static inline bool
|
||||
ApplyQtThemeByName(std::string name)
|
||||
{
|
||||
// normalize
|
||||
std::transform(name.begin(), name.end(), name.begin(), [](unsigned char c) {
|
||||
return (char) std::tolower(c);
|
||||
});
|
||||
auto has = [&](const std::string &s) {
|
||||
return name.find(s) != std::string::npos;
|
||||
};
|
||||
|
||||
if (name.empty() || name == "default" || name == "nord") {
|
||||
// Choose variant by current background mode
|
||||
if (GetBackgroundMode() == BackgroundMode::Dark) {
|
||||
gOverridePalette = NordDark();
|
||||
} else {
|
||||
gOverridePalette = NordLight();
|
||||
}
|
||||
gPaletteOverride = true;
|
||||
gOverrideThemeName = "nord";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (has("solarized")) {
|
||||
if (has("light")) {
|
||||
SetBackgroundMode(BackgroundMode::Light);
|
||||
gOverridePalette = SolarizedLight();
|
||||
} else if (has("dark")) {
|
||||
SetBackgroundMode(BackgroundMode::Dark);
|
||||
gOverridePalette = SolarizedDark();
|
||||
} else {
|
||||
// pick from current background
|
||||
gOverridePalette = (GetBackgroundMode() == BackgroundMode::Dark)
|
||||
? SolarizedDark()
|
||||
: SolarizedLight();
|
||||
}
|
||||
gPaletteOverride = true;
|
||||
gOverrideThemeName = "solarized";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (has("gruvbox")) {
|
||||
if (has("light")) {
|
||||
SetBackgroundMode(BackgroundMode::Light);
|
||||
gOverridePalette = GruvboxLight();
|
||||
} else if (has("dark")) {
|
||||
SetBackgroundMode(BackgroundMode::Dark);
|
||||
gOverridePalette = GruvboxDark();
|
||||
} else {
|
||||
gOverridePalette = (GetBackgroundMode() == BackgroundMode::Dark)
|
||||
? GruvboxDark()
|
||||
: GruvboxLight();
|
||||
}
|
||||
gPaletteOverride = true;
|
||||
gOverrideThemeName = "gruvbox";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (has("eink") || has("e-ink") || has("paper")) {
|
||||
SetBackgroundMode(BackgroundMode::Light);
|
||||
gOverridePalette = EInk();
|
||||
gPaletteOverride = true;
|
||||
gOverrideThemeName = "eink";
|
||||
return true;
|
||||
}
|
||||
|
||||
// Unknown -> clear override so default light/dark applies; return false.
|
||||
gPaletteOverride = false;
|
||||
gOverrideThemeName.clear();
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Minimal SyntaxInk mapping for Qt builds, returning KteColor
|
||||
[[maybe_unused]] static KteColor
|
||||
SyntaxInk(const TokenKind k)
|
||||
{
|
||||
const bool dark = (GetBackgroundMode() == BackgroundMode::Dark);
|
||||
const KteColor def = dark ? RGBA(0xD8DEE9) : RGBA(0x2E3440);
|
||||
switch (k) {
|
||||
case TokenKind::Keyword:
|
||||
return dark ? RGBA(0x81A1C1) : RGBA(0x5E81AC);
|
||||
case TokenKind::Type:
|
||||
return dark ? RGBA(0x8FBCBB) : RGBA(0x4C566A);
|
||||
case TokenKind::String:
|
||||
return dark ? RGBA(0xA3BE8C) : RGBA(0x6C8E5E);
|
||||
case TokenKind::Char:
|
||||
return dark ? RGBA(0xA3BE8C) : RGBA(0x6C8E5E);
|
||||
case TokenKind::Comment:
|
||||
return dark ? RGBA(0x616E88) : RGBA(0x7A869A);
|
||||
case TokenKind::Number:
|
||||
return dark ? RGBA(0xEBCB8B) : RGBA(0xB58900);
|
||||
case TokenKind::Preproc:
|
||||
return dark ? RGBA(0xD08770) : RGBA(0xAF3A03);
|
||||
case TokenKind::Constant:
|
||||
return dark ? RGBA(0xB48EAD) : RGBA(0x7B4B7F);
|
||||
case TokenKind::Function:
|
||||
return dark ? RGBA(0x88C0D0) : RGBA(0x3465A4);
|
||||
case TokenKind::Operator:
|
||||
return dark ? RGBA(0x2E3440) : RGBA(0x2E3440);
|
||||
case TokenKind::Punctuation:
|
||||
return dark ? RGBA(0xECEFF4) : RGBA(0x2E3440);
|
||||
case TokenKind::Identifier:
|
||||
return def;
|
||||
case TokenKind::Whitespace:
|
||||
return def;
|
||||
case TokenKind::Error:
|
||||
return dark ? RGBA(0xBF616A) : RGBA(0xCC0000);
|
||||
case TokenKind::Default: default:
|
||||
return def;
|
||||
}
|
||||
}
|
||||
} // namespace kte
|
||||
|
||||
#else
|
||||
|
||||
#include <imgui.h>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <cstddef>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
@@ -16,7 +312,7 @@ namespace kte {
|
||||
enum class BackgroundMode { Light, Dark };
|
||||
|
||||
// Global background mode; default to Dark to match prior defaults
|
||||
static inline auto gBackgroundMode = BackgroundMode::Dark;
|
||||
inline auto gBackgroundMode = BackgroundMode::Dark;
|
||||
|
||||
// Basic theme identifier (kept minimal; some ids are aliases)
|
||||
enum class ThemeId {
|
||||
@@ -34,11 +330,13 @@ enum class ThemeId {
|
||||
Amber = 10,
|
||||
WeylandYutani = 11,
|
||||
Orbital = 12,
|
||||
Tufte = 13,
|
||||
Leuchtturm = 14,
|
||||
};
|
||||
|
||||
// Current theme tracking
|
||||
static inline auto gCurrentTheme = ThemeId::Nord;
|
||||
static inline std::size_t gCurrentThemeIndex = 0;
|
||||
inline auto gCurrentTheme = ThemeId::Nord;
|
||||
inline std::size_t gCurrentThemeIndex = 7; // Nord index
|
||||
|
||||
// Forward declarations for helpers used below
|
||||
static size_t ThemeIndexFromId(ThemeId id);
|
||||
@@ -76,11 +374,13 @@ BackgroundModeName()
|
||||
#include "themes/Everforest.h"
|
||||
#include "themes/KanagawaPaper.h"
|
||||
#include "themes/LCARS.h"
|
||||
#include "themes/Leuchtturm.h"
|
||||
#include "themes/OldBook.h"
|
||||
#include "themes/Amber.h"
|
||||
#include "themes/WeylandYutani.h"
|
||||
#include "themes/Zenburn.h"
|
||||
#include "themes/Orbital.h"
|
||||
#include "themes/Tufte.h"
|
||||
|
||||
|
||||
// Theme abstraction and registry (generalized theme system)
|
||||
@@ -113,6 +413,28 @@ struct LCARSTheme final : Theme {
|
||||
}
|
||||
};
|
||||
|
||||
struct LeuchtturmTheme final : Theme {
|
||||
[[nodiscard]] const char *Name() const override
|
||||
{
|
||||
return "leuchtturm";
|
||||
}
|
||||
|
||||
|
||||
void Apply() const override
|
||||
{
|
||||
if (gBackgroundMode == BackgroundMode::Dark)
|
||||
ApplyLeuchtturmDarkTheme();
|
||||
else
|
||||
ApplyLeuchtturmLightTheme();
|
||||
}
|
||||
|
||||
|
||||
ThemeId Id() override
|
||||
{
|
||||
return ThemeId::Leuchtturm;
|
||||
}
|
||||
};
|
||||
|
||||
struct EverforestTheme final : Theme {
|
||||
[[nodiscard]] const char *Name() const override
|
||||
{
|
||||
@@ -192,6 +514,28 @@ struct OrbitalTheme final : Theme {
|
||||
}
|
||||
};
|
||||
|
||||
struct TufteTheme final : Theme {
|
||||
[[nodiscard]] const char *Name() const override
|
||||
{
|
||||
return "tufte";
|
||||
}
|
||||
|
||||
|
||||
void Apply() const override
|
||||
{
|
||||
if (gBackgroundMode == BackgroundMode::Dark)
|
||||
ApplyTufteDarkTheme();
|
||||
else
|
||||
ApplyTufteLightTheme();
|
||||
}
|
||||
|
||||
|
||||
ThemeId Id() override
|
||||
{
|
||||
return ThemeId::Tufte;
|
||||
}
|
||||
};
|
||||
|
||||
struct ZenburnTheme final : Theme {
|
||||
[[nodiscard]] const char *Name() const override
|
||||
{
|
||||
@@ -361,18 +705,20 @@ ThemeRegistry()
|
||||
static std::vector<std::unique_ptr<Theme> > reg;
|
||||
if (reg.empty()) {
|
||||
// Alphabetical by canonical name:
|
||||
// amber, eink, everforest, gruvbox, kanagawa-paper, lcars, nord, old-book, orbital, plan9, solarized, weyland-yutani, zenburn
|
||||
// amber, eink, everforest, gruvbox, kanagawa-paper, lcars, leuchtturm, nord, old-book, orbital, plan9, solarized, tufte, weyland-yutani, zenburn
|
||||
reg.emplace_back(std::make_unique<detail::AmberTheme>());
|
||||
reg.emplace_back(std::make_unique<detail::EInkTheme>());
|
||||
reg.emplace_back(std::make_unique<detail::EverforestTheme>());
|
||||
reg.emplace_back(std::make_unique<detail::GruvboxTheme>());
|
||||
reg.emplace_back(std::make_unique<detail::KanagawaPaperTheme>());
|
||||
reg.emplace_back(std::make_unique<detail::LCARSTheme>());
|
||||
reg.emplace_back(std::make_unique<detail::LeuchtturmTheme>());
|
||||
reg.emplace_back(std::make_unique<detail::NordTheme>());
|
||||
reg.emplace_back(std::make_unique<detail::OldBookTheme>());
|
||||
reg.emplace_back(std::make_unique<detail::OrbitalTheme>());
|
||||
reg.emplace_back(std::make_unique<detail::Plan9Theme>());
|
||||
reg.emplace_back(std::make_unique<detail::SolarizedTheme>());
|
||||
reg.emplace_back(std::make_unique<detail::TufteTheme>());
|
||||
reg.emplace_back(std::make_unique<detail::WeylandYutaniTheme>());
|
||||
reg.emplace_back(std::make_unique<detail::ZenburnTheme>());
|
||||
}
|
||||
@@ -549,20 +895,24 @@ ThemeIndexFromId(const ThemeId id)
|
||||
return 4;
|
||||
case ThemeId::LCARS:
|
||||
return 5;
|
||||
case ThemeId::Nord:
|
||||
case ThemeId::Leuchtturm:
|
||||
return 6;
|
||||
case ThemeId::OldBook:
|
||||
case ThemeId::Nord:
|
||||
return 7;
|
||||
case ThemeId::Orbital:
|
||||
case ThemeId::OldBook:
|
||||
return 8;
|
||||
case ThemeId::Plan9:
|
||||
case ThemeId::Orbital:
|
||||
return 9;
|
||||
case ThemeId::Solarized:
|
||||
case ThemeId::Plan9:
|
||||
return 10;
|
||||
case ThemeId::WeylandYutani:
|
||||
case ThemeId::Solarized:
|
||||
return 11;
|
||||
case ThemeId::Zenburn:
|
||||
case ThemeId::Tufte:
|
||||
return 12;
|
||||
case ThemeId::WeylandYutani:
|
||||
return 13;
|
||||
case ThemeId::Zenburn:
|
||||
return 14;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -586,30 +936,144 @@ ThemeIdFromIndex(const size_t idx)
|
||||
case 5:
|
||||
return ThemeId::LCARS;
|
||||
case 6:
|
||||
return ThemeId::Nord;
|
||||
return ThemeId::Leuchtturm;
|
||||
case 7:
|
||||
return ThemeId::OldBook;
|
||||
return ThemeId::Nord;
|
||||
case 8:
|
||||
return ThemeId::Orbital;
|
||||
return ThemeId::OldBook;
|
||||
case 9:
|
||||
return ThemeId::Plan9;
|
||||
return ThemeId::Orbital;
|
||||
case 10:
|
||||
return ThemeId::Solarized;
|
||||
return ThemeId::Plan9;
|
||||
case 11:
|
||||
return ThemeId::WeylandYutani;
|
||||
return ThemeId::Solarized;
|
||||
case 12:
|
||||
return ThemeId::Tufte;
|
||||
case 13:
|
||||
return ThemeId::WeylandYutani;
|
||||
case 14:
|
||||
return ThemeId::Zenburn;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Syntax palette (v1): map TokenKind to ink color per current theme/background ---
|
||||
|
||||
// Tufte palette: high-contrast, restrained color. Body text is true black on
|
||||
// cream; only keywords and links get subtle color to avoid a "christmas tree."
|
||||
static ImVec4
|
||||
SyntaxInkTufte(const TokenKind k, const bool dark)
|
||||
{
|
||||
const ImVec4 ink = dark ? RGBA(0xEAE6DE) : RGBA(0x111111); // body text
|
||||
const ImVec4 dim = dark ? RGBA(0x8A8680) : RGBA(0x555555); // comments
|
||||
const ImVec4 red = dark ? RGBA(0xD06060) : RGBA(0x8B0000); // keywords/preproc
|
||||
const ImVec4 navy = dark ? RGBA(0x7098C0) : RGBA(0x1A3A5C); // functions/links
|
||||
const ImVec4 grn = dark ? RGBA(0x8AAA6E) : RGBA(0x2E5E2E); // strings
|
||||
switch (k) {
|
||||
case TokenKind::Keyword:
|
||||
case TokenKind::Preproc:
|
||||
return red;
|
||||
case TokenKind::String:
|
||||
case TokenKind::Char:
|
||||
return grn;
|
||||
case TokenKind::Comment:
|
||||
return dim;
|
||||
case TokenKind::Function:
|
||||
return navy;
|
||||
case TokenKind::Number:
|
||||
case TokenKind::Constant:
|
||||
return dark ? RGBA(0xC8A85A) : RGBA(0x6B4C00);
|
||||
case TokenKind::Type:
|
||||
return dark ? RGBA(0xBBAA90) : RGBA(0x333333);
|
||||
case TokenKind::Error:
|
||||
return dark ? RGBA(0xD06060) : RGBA(0xCC0000);
|
||||
default:
|
||||
return ink;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Leuchtturm palette: blue-black fountain pen ink with brass and bronze accents.
|
||||
// Body text is ink-colored; accents drawn from the pen metals.
|
||||
static ImVec4
|
||||
SyntaxInkLeuchtturm(const TokenKind k, const bool dark)
|
||||
{
|
||||
const ImVec4 ink = dark ? RGBA(0xE5DDD0) : RGBA(0x040720); // fountain pen ink
|
||||
const ImVec4 dim = dark ? RGBA(0x7A7060) : RGBA(0x6A6558); // comments
|
||||
const ImVec4 brass = dark ? RGBA(0xB8A060) : RGBA(0x504518); // patinated brass
|
||||
const ImVec4 bronze= dark ? RGBA(0xC08050) : RGBA(0x5C3010); // dark bronze
|
||||
const ImVec4 navy = dark ? RGBA(0x8898B0) : RGBA(0x1C2E4A); // deep navy
|
||||
switch (k) {
|
||||
case TokenKind::Keyword:
|
||||
case TokenKind::Preproc:
|
||||
return brass;
|
||||
case TokenKind::String:
|
||||
case TokenKind::Char:
|
||||
return bronze;
|
||||
case TokenKind::Comment:
|
||||
return dim;
|
||||
case TokenKind::Function:
|
||||
return navy;
|
||||
case TokenKind::Number:
|
||||
case TokenKind::Constant:
|
||||
return dark ? RGBA(0xA89060) : RGBA(0x483C10);
|
||||
case TokenKind::Type:
|
||||
return dark ? RGBA(0xC0B898) : RGBA(0x222238);
|
||||
case TokenKind::Error:
|
||||
return dark ? RGBA(0xD06060) : RGBA(0xA02020);
|
||||
default:
|
||||
return ink;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Everforest: warm forest palette on dark green-gray (bg 0x2B3339).
|
||||
// Default comment color (0x616E88) is too dim; boost it and tune others.
|
||||
static ImVec4
|
||||
SyntaxInkEverforest(const TokenKind k)
|
||||
{
|
||||
switch (k) {
|
||||
case TokenKind::Keyword:
|
||||
return RGBA(0xE67E80); // everforest red
|
||||
case TokenKind::Type:
|
||||
return RGBA(0xD699B6); // everforest purple
|
||||
case TokenKind::String:
|
||||
case TokenKind::Char:
|
||||
return RGBA(0xA7C080); // everforest green
|
||||
case TokenKind::Comment:
|
||||
return RGBA(0x859289); // boosted from 0x616E88 for contrast
|
||||
case TokenKind::Number:
|
||||
case TokenKind::Constant:
|
||||
return RGBA(0xD8A657); // everforest yellow/orange
|
||||
case TokenKind::Preproc:
|
||||
return RGBA(0xE69875); // everforest orange
|
||||
case TokenKind::Function:
|
||||
return RGBA(0x83C092); // everforest aqua
|
||||
case TokenKind::Operator:
|
||||
case TokenKind::Punctuation:
|
||||
return RGBA(0xD3C6AA); // everforest fg
|
||||
case TokenKind::Error:
|
||||
return RGBA(0xE67E80);
|
||||
default:
|
||||
return RGBA(0xD3C6AA); // everforest fg
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[[maybe_unused]] static ImVec4
|
||||
SyntaxInk(const TokenKind k)
|
||||
{
|
||||
// Basic palettes for dark/light backgrounds; tuned for Nord-ish defaults
|
||||
const bool dark = (GetBackgroundMode() == BackgroundMode::Dark);
|
||||
// Base text
|
||||
|
||||
// Per-theme syntax palettes
|
||||
if (gCurrentTheme == ThemeId::Tufte)
|
||||
return SyntaxInkTufte(k, dark);
|
||||
if (gCurrentTheme == ThemeId::Leuchtturm)
|
||||
return SyntaxInkLeuchtturm(k, dark);
|
||||
if (gCurrentTheme == ThemeId::Everforest)
|
||||
return SyntaxInkEverforest(k);
|
||||
|
||||
// Default palettes tuned for Nord-ish themes
|
||||
const ImVec4 def = dark ? RGBA(0xD8DEE9) : RGBA(0x2E3440);
|
||||
switch (k) {
|
||||
case TokenKind::Keyword:
|
||||
@@ -644,4 +1108,6 @@ SyntaxInk(const TokenKind k)
|
||||
return def;
|
||||
}
|
||||
}
|
||||
} // namespace kte
|
||||
} // namespace kte
|
||||
|
||||
#endif // KTE_USE_QT
|
||||
|
||||
-204
@@ -1,204 +0,0 @@
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <cstring>
|
||||
|
||||
#include "GapBuffer.h"
|
||||
|
||||
|
||||
GapBuffer::GapBuffer() = default;
|
||||
|
||||
|
||||
GapBuffer::GapBuffer(std::size_t initialCapacity)
|
||||
: buffer_(nullptr), size_(0), capacity_(0)
|
||||
{
|
||||
if (initialCapacity > 0) {
|
||||
Reserve(initialCapacity);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
GapBuffer::GapBuffer(const GapBuffer &other)
|
||||
: buffer_(nullptr), size_(0), capacity_(0)
|
||||
{
|
||||
if (other.capacity_ > 0) {
|
||||
Reserve(other.capacity_);
|
||||
if (other.size_ > 0) {
|
||||
std::memcpy(buffer_, other.buffer_, other.size_);
|
||||
size_ = other.size_;
|
||||
}
|
||||
setTerminator();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
GapBuffer &
|
||||
GapBuffer::operator=(const GapBuffer &other)
|
||||
{
|
||||
if (this == &other)
|
||||
return *this;
|
||||
if (other.capacity_ > capacity_) {
|
||||
Reserve(other.capacity_);
|
||||
}
|
||||
if (other.size_ > 0) {
|
||||
std::memcpy(buffer_, other.buffer_, other.size_);
|
||||
}
|
||||
size_ = other.size_;
|
||||
setTerminator();
|
||||
return *this;
|
||||
}
|
||||
|
||||
|
||||
GapBuffer::GapBuffer(GapBuffer &&other) noexcept
|
||||
: buffer_(other.buffer_), size_(other.size_), capacity_(other.capacity_)
|
||||
{
|
||||
other.buffer_ = nullptr;
|
||||
other.size_ = 0;
|
||||
other.capacity_ = 0;
|
||||
}
|
||||
|
||||
|
||||
GapBuffer &
|
||||
GapBuffer::operator=(GapBuffer &&other) noexcept
|
||||
{
|
||||
if (this == &other)
|
||||
return *this;
|
||||
delete[] buffer_;
|
||||
buffer_ = other.buffer_;
|
||||
size_ = other.size_;
|
||||
capacity_ = other.capacity_;
|
||||
other.buffer_ = nullptr;
|
||||
other.size_ = 0;
|
||||
other.capacity_ = 0;
|
||||
return *this;
|
||||
}
|
||||
|
||||
|
||||
GapBuffer::~GapBuffer()
|
||||
{
|
||||
delete[] buffer_;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GapBuffer::Reserve(const std::size_t newCapacity)
|
||||
{
|
||||
if (newCapacity <= capacity_) [[likely]]
|
||||
return;
|
||||
// Allocate space for terminator as well
|
||||
char *nb = new char[newCapacity + 1];
|
||||
if (size_ > 0 && buffer_) {
|
||||
std::memcpy(nb, buffer_, size_);
|
||||
}
|
||||
delete[] buffer_;
|
||||
buffer_ = nb;
|
||||
capacity_ = newCapacity;
|
||||
setTerminator();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GapBuffer::AppendChar(const char c)
|
||||
{
|
||||
ensureCapacityFor(1);
|
||||
buffer_[size_++] = c;
|
||||
setTerminator();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GapBuffer::Append(const char *s, const std::size_t len)
|
||||
{
|
||||
if (!s || len == 0) [[unlikely]]
|
||||
return;
|
||||
ensureCapacityFor(len);
|
||||
std::memcpy(buffer_ + size_, s, len);
|
||||
size_ += len;
|
||||
setTerminator();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GapBuffer::Append(const GapBuffer &other)
|
||||
{
|
||||
if (other.size_ == 0)
|
||||
return;
|
||||
Append(other.buffer_, other.size_);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GapBuffer::PrependChar(char c)
|
||||
{
|
||||
ensureCapacityFor(1);
|
||||
// shift right by 1
|
||||
if (size_ > 0) [[likely]] {
|
||||
std::memmove(buffer_ + 1, buffer_, size_);
|
||||
}
|
||||
buffer_[0] = c;
|
||||
++size_;
|
||||
setTerminator();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GapBuffer::Prepend(const char *s, std::size_t len)
|
||||
{
|
||||
if (!s || len == 0) [[unlikely]]
|
||||
return;
|
||||
ensureCapacityFor(len);
|
||||
if (size_ > 0) [[likely]] {
|
||||
std::memmove(buffer_ + len, buffer_, size_);
|
||||
}
|
||||
std::memcpy(buffer_, s, len);
|
||||
size_ += len;
|
||||
setTerminator();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GapBuffer::Prepend(const GapBuffer &other)
|
||||
{
|
||||
if (other.size_ == 0)
|
||||
return;
|
||||
Prepend(other.buffer_, other.size_);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GapBuffer::Clear()
|
||||
{
|
||||
size_ = 0;
|
||||
setTerminator();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GapBuffer::ensureCapacityFor(std::size_t delta)
|
||||
{
|
||||
if (capacity_ - size_ >= delta) [[likely]]
|
||||
return;
|
||||
auto required = size_ + delta;
|
||||
Reserve(growCapacity(capacity_, required));
|
||||
}
|
||||
|
||||
|
||||
std::size_t
|
||||
GapBuffer::growCapacity(std::size_t current, std::size_t required)
|
||||
{
|
||||
// geometric growth, at least required
|
||||
std::size_t newCap = current ? current : 8;
|
||||
while (newCap < required)
|
||||
newCap = newCap + (newCap >> 1); // 1.5x growth
|
||||
return newCap;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GapBuffer::setTerminator() const
|
||||
{
|
||||
if (!buffer_) {
|
||||
return;
|
||||
}
|
||||
|
||||
buffer_[size_] = '\0';
|
||||
}
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
/*
|
||||
* GapBuffer.h - C++ replacement for abuf append/prepend buffer utilities
|
||||
*/
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
|
||||
|
||||
class GapBuffer {
|
||||
public:
|
||||
GapBuffer();
|
||||
|
||||
explicit GapBuffer(std::size_t initialCapacity);
|
||||
|
||||
GapBuffer(const GapBuffer &other);
|
||||
|
||||
GapBuffer &operator=(const GapBuffer &other);
|
||||
|
||||
GapBuffer(GapBuffer &&other) noexcept;
|
||||
|
||||
GapBuffer &operator=(GapBuffer &&other) noexcept;
|
||||
|
||||
~GapBuffer();
|
||||
|
||||
void Reserve(std::size_t newCapacity);
|
||||
|
||||
|
||||
void AppendChar(char c);
|
||||
|
||||
void Append(const char *s, std::size_t len);
|
||||
|
||||
void Append(const GapBuffer &other);
|
||||
|
||||
void PrependChar(char c);
|
||||
|
||||
void Prepend(const char *s, std::size_t len);
|
||||
|
||||
void Prepend(const GapBuffer &other);
|
||||
|
||||
// Content management
|
||||
void Clear();
|
||||
|
||||
// Accessors
|
||||
char *Data()
|
||||
{
|
||||
return buffer_;
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] const char *Data() const
|
||||
{
|
||||
return buffer_;
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] std::size_t Size() const
|
||||
{
|
||||
return size_;
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] std::size_t Capacity() const
|
||||
{
|
||||
return capacity_;
|
||||
}
|
||||
|
||||
private:
|
||||
void ensureCapacityFor(std::size_t delta);
|
||||
|
||||
static std::size_t growCapacity(std::size_t current, std::size_t required);
|
||||
|
||||
void setTerminator() const;
|
||||
|
||||
char *buffer_ = nullptr;
|
||||
std::size_t size_ = 0; // number of valid bytes (excluding terminator)
|
||||
std::size_t capacity_ = 0; // capacity of buffer_ excluding space for terminator
|
||||
};
|
||||
+35
-5
@@ -22,9 +22,12 @@ HelpText::Text()
|
||||
" C-k ' Toggle read-only\n"
|
||||
" C-k - Unindent 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 SPACE Toggle mark\n"
|
||||
" C-k C-d Kill entire line\n"
|
||||
" C-k C-q Quit now (no confirm)\n"
|
||||
" C-k C-s Save\n"
|
||||
" C-k C-x Save and quit\n"
|
||||
" C-k a Mark start of file, jump to end\n"
|
||||
" C-k b Switch buffer\n"
|
||||
@@ -34,8 +37,11 @@ HelpText::Text()
|
||||
" C-k f Flush kill ring\n"
|
||||
" C-k g Jump to line\n"
|
||||
" C-k h Show this help\n"
|
||||
" C-k i New empty buffer\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 m Toggle edit mode (code/writing)\n"
|
||||
" C-k n Previous buffer\n"
|
||||
" C-k o Change working directory (prompt)\n"
|
||||
" C-k p Next buffer\n"
|
||||
@@ -59,6 +65,10 @@ HelpText::Text()
|
||||
" ESC BACKSPACE Delete previous word (Alt-Backspace)\n"
|
||||
" ESC q Reflow paragraph\n"
|
||||
"\n"
|
||||
"Universal argument:\n"
|
||||
" C-u Begin repeat count (then type digits); C-u alone multiplies by 4\n"
|
||||
" C-u N <cmd> Repeat <cmd> N times (e.g., C-u 8 C-f moves right 8 chars)\n"
|
||||
"\n"
|
||||
"Control keys:\n"
|
||||
" C-a C-e Line start / end\n"
|
||||
" C-b C-f Move left / right\n"
|
||||
@@ -70,12 +80,32 @@ HelpText::Text()
|
||||
" C-t Regex search & replace\n"
|
||||
" C-h Search & replace\n"
|
||||
" C-l / C-g Refresh / Cancel\n"
|
||||
" C-u [digits] Universal argument (repeat count)\n"
|
||||
"\n"
|
||||
"Buffers:\n +HELP+ is read-only. Press C-k ' to toggle; C-k h restores it.\n"
|
||||
"\n"
|
||||
"GUI appearance (command prompt):\n"
|
||||
" : theme NAME Set GUI theme (amber, eink, everforest, gruvbox, kanagawa-paper, lcars, nord, old-book, plan9, solarized, weyland-yutani, zenburn)\n"
|
||||
" : background MODE Set background: light | dark (affects eink, gruvbox, old-book, solarized)\n"
|
||||
"Edit modes:\n"
|
||||
" code Monospace font (default for source files)\n"
|
||||
" writing Proportional font (auto for .txt, .md, .rst, .org, .tex)\n"
|
||||
" C-k m or : mode [code|writing] to toggle\n"
|
||||
"\n"
|
||||
"GUI commands (command prompt):\n"
|
||||
" : theme NAME Set theme (amber, eink, everforest, gruvbox,\n"
|
||||
" kanagawa-paper, lcars, leuchtturm, nord, old-book,\n"
|
||||
" orbital, plan9, solarized, tufte, weyland-yutani,\n"
|
||||
" zenburn)\n"
|
||||
" : background MODE Background: light | dark\n"
|
||||
" : font NAME Set font (tab completes)\n"
|
||||
" : font-size NUM Set font size in pixels\n"
|
||||
" : mode [code|writing] Toggle or set edit mode\n"
|
||||
"\n"
|
||||
"Configuration:\n"
|
||||
" Config file: ~/.config/kte/kge.toml (see CONFIG.md)\n"
|
||||
" Legacy kge.ini is also supported.\n"
|
||||
"\n"
|
||||
"GUI window management:\n"
|
||||
" Cmd+N (macOS) Open a new editor window sharing the same buffers\n"
|
||||
" Ctrl+Shift+N (Linux) Open a new editor window sharing the same buffers\n"
|
||||
" Close window Secondary windows close independently; closing the\n"
|
||||
" primary window quits the editor\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -10,4 +10,4 @@ public:
|
||||
// Project maintainers can customize the returned string below
|
||||
// (in HelpText.cc) without touching the help command logic.
|
||||
static std::string Text();
|
||||
};
|
||||
};
|
||||
|
||||
+1
-1
@@ -34,4 +34,4 @@ struct LineHighlight {
|
||||
std::vector<HighlightSpan> spans;
|
||||
std::uint64_t version{0}; // buffer version used for this line
|
||||
};
|
||||
} // namespace kte
|
||||
} // namespace kte
|
||||
|
||||
@@ -0,0 +1,692 @@
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
|
||||
#include <imgui.h>
|
||||
#include <SDL.h>
|
||||
#include <SDL_opengl.h>
|
||||
#include <backends/imgui_impl_opengl3.h>
|
||||
#include <backends/imgui_impl_sdl2.h>
|
||||
|
||||
#include "ImGuiFrontend.h"
|
||||
#include "Command.h"
|
||||
#include "Editor.h"
|
||||
#include "GUIConfig.h"
|
||||
#include "GUITheme.h"
|
||||
#include "fonts/Font.h" // embedded default font (DefaultFont)
|
||||
#include "fonts/FontRegistry.h"
|
||||
#include "fonts/IosevkaExtended.h"
|
||||
#include "syntax/HighlighterRegistry.h"
|
||||
#include "syntax/NullHighlighter.h"
|
||||
|
||||
|
||||
#ifndef KTE_FONT_SIZE
|
||||
#define KTE_FONT_SIZE 16.0f
|
||||
#endif
|
||||
|
||||
static auto kGlslVersion = "#version 150"; // GL 3.2 core (macOS compatible)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static void
|
||||
apply_syntax_to_buffer(Buffer *b, const GUIConfig &cfg)
|
||||
{
|
||||
if (!b)
|
||||
return;
|
||||
|
||||
// Auto-detect edit mode from file extension once per buffer so that
|
||||
// manual toggles (C-k m / : mode) are not overridden every frame.
|
||||
if (!b->EditModeDetected() && !b->Filename().empty())
|
||||
b->SetEditMode(DetectEditMode(b->Filename()));
|
||||
|
||||
// If the user explicitly set syntax state via a command (:syntax on/off,
|
||||
// :set filetype=...), leave it alone - otherwise this runs every frame
|
||||
// and silently undoes a manual ":syntax off" on the very next frame.
|
||||
if (b->SyntaxUserOverride())
|
||||
return;
|
||||
|
||||
// Writing mode disables syntax; otherwise follow the global config.
|
||||
if (cfg.syntax && b->GetEditMode() != EditMode::Writing) {
|
||||
b->SetSyntaxEnabled(true);
|
||||
b->EnsureHighlighter();
|
||||
if (auto *eng = b->Highlighter()) {
|
||||
if (!eng->HasHighlighter()) {
|
||||
std::string first_line;
|
||||
const auto &rows = b->Rows();
|
||||
if (!rows.empty())
|
||||
first_line = static_cast<std::string>(rows[0]);
|
||||
std::string ft = kte::HighlighterRegistry::DetectForPath(
|
||||
b->Filename(), first_line);
|
||||
if (!ft.empty()) {
|
||||
eng->SetHighlighter(kte::HighlighterRegistry::CreateFor(ft));
|
||||
b->SetFiletype(ft);
|
||||
eng->InvalidateFrom(0);
|
||||
} else {
|
||||
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
||||
b->SetFiletype("");
|
||||
eng->InvalidateFrom(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
b->SetSyntaxEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Update editor logical rows/cols from current ImGui metrics for a given display size.
|
||||
static void
|
||||
update_editor_dimensions(Editor &ed, float disp_w, float disp_h)
|
||||
{
|
||||
float row_h = ImGui::GetTextLineHeightWithSpacing();
|
||||
// Use average character width rather than "M" (the widest character)
|
||||
// so that column count is reasonable for proportional fonts too.
|
||||
float ch_w = ImGui::CalcTextSize("abcdefghijklmnopqrstuvwxyz").x / 26.0f;
|
||||
if (row_h <= 0.0f)
|
||||
row_h = 16.0f;
|
||||
if (ch_w <= 0.0f)
|
||||
ch_w = 8.0f;
|
||||
|
||||
const float pad_x = 6.0f;
|
||||
const float pad_y = 6.0f;
|
||||
|
||||
float wanted_bar_h = ImGui::GetFrameHeight();
|
||||
float total_avail_h = std::max(0.0f, disp_h - 2.0f * pad_y);
|
||||
float actual_avail_h = std::floor((total_avail_h - wanted_bar_h) / row_h) * row_h;
|
||||
|
||||
auto content_rows = static_cast<std::size_t>(std::max(0.0f, std::floor(actual_avail_h / row_h)));
|
||||
std::size_t rows = content_rows + 1;
|
||||
|
||||
float avail_w = std::max(0.0f, disp_w - 2.0f * pad_x);
|
||||
std::size_t cols = static_cast<std::size_t>(std::max(1.0f, std::floor(avail_w / ch_w)));
|
||||
|
||||
if (rows != ed.Rows() || cols != ed.Cols()) {
|
||||
ed.SetDimensions(rows, cols);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SetupImGuiStyle_ — apply theme, fonts, and flags to the current ImGui context
|
||||
// ---------------------------------------------------------------------------
|
||||
void
|
||||
GUIFrontend::SetupImGuiStyle_()
|
||||
{
|
||||
ImGuiIO &io = ImGui::GetIO();
|
||||
|
||||
// Disable imgui.ini for secondary windows (primary sets its own path in Init)
|
||||
io.IniFilename = nullptr;
|
||||
|
||||
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
|
||||
io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad;
|
||||
ImGui::StyleColorsDark();
|
||||
|
||||
if (config_.background == "light")
|
||||
kte::SetBackgroundMode(kte::BackgroundMode::Light);
|
||||
else
|
||||
kte::SetBackgroundMode(kte::BackgroundMode::Dark);
|
||||
kte::ApplyThemeByName(config_.theme);
|
||||
|
||||
// Load fonts into this context's font atlas.
|
||||
// Font registry is global and already populated by Init; just load into this atlas.
|
||||
if (!kte::Fonts::FontRegistry::Instance().LoadFont(config_.font, (float) config_.font_size)) {
|
||||
LoadGuiFont_(nullptr, (float) config_.font_size);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Destroy a single window's ImGui context + SDL/GL resources
|
||||
// ---------------------------------------------------------------------------
|
||||
void
|
||||
GUIFrontend::DestroyWindowResources_(WindowState &ws)
|
||||
{
|
||||
if (ws.imgui_ctx) {
|
||||
// Must activate this window's GL context before shutting down the
|
||||
// OpenGL3 backend, otherwise it deletes another context's resources.
|
||||
if (ws.window && ws.gl_ctx)
|
||||
SDL_GL_MakeCurrent(ws.window, ws.gl_ctx);
|
||||
ImGui::SetCurrentContext(ws.imgui_ctx);
|
||||
ImGui_ImplOpenGL3_Shutdown();
|
||||
ImGui_ImplSDL2_Shutdown();
|
||||
ImGui::DestroyContext(ws.imgui_ctx);
|
||||
ws.imgui_ctx = nullptr;
|
||||
}
|
||||
if (ws.gl_ctx) {
|
||||
SDL_GL_DeleteContext(ws.gl_ctx);
|
||||
ws.gl_ctx = nullptr;
|
||||
}
|
||||
if (ws.window) {
|
||||
SDL_DestroyWindow(ws.window);
|
||||
ws.window = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
GUIFrontend::Init(int &argc, char **argv, Editor &ed)
|
||||
{
|
||||
(void) argc;
|
||||
(void) argv;
|
||||
|
||||
// Load GUI configuration (fullscreen, columns/rows, font size, theme, background)
|
||||
config_ = GUIConfig::Load();
|
||||
|
||||
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER) != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// GL attributes for core profile
|
||||
SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, 0);
|
||||
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE);
|
||||
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
|
||||
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 2);
|
||||
SDL_GL_SetAttribute(SDL_GL_DOUBLEBUFFER, 1);
|
||||
SDL_GL_SetAttribute(SDL_GL_DEPTH_SIZE, 24);
|
||||
SDL_GL_SetAttribute(SDL_GL_STENCIL_SIZE, 8);
|
||||
|
||||
// Compute desired window size from config
|
||||
Uint32 win_flags = SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI;
|
||||
|
||||
int init_w = 1280, init_h = 800;
|
||||
if (config_.fullscreen) {
|
||||
SDL_Rect usable{};
|
||||
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
|
||||
init_w = usable.w;
|
||||
init_h = usable.h;
|
||||
}
|
||||
#if !defined(__APPLE__)
|
||||
win_flags |= SDL_WINDOW_FULLSCREEN_DESKTOP;
|
||||
#endif
|
||||
} else {
|
||||
int w = config_.columns * static_cast<int>(config_.font_size);
|
||||
int h = config_.rows * static_cast<int>(config_.font_size * 1.2);
|
||||
SDL_Rect usable{};
|
||||
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
|
||||
w = std::min(w, usable.w);
|
||||
h = std::min(h, usable.h);
|
||||
}
|
||||
init_w = std::max(320, w);
|
||||
init_h = std::max(200, h);
|
||||
}
|
||||
|
||||
SDL_SetHint(SDL_HINT_VIDEO_ALLOW_SCREENSAVER, "1");
|
||||
SDL_Window *win = SDL_CreateWindow(
|
||||
"kge - kyle's graphical editor " KTE_VERSION_STR,
|
||||
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
|
||||
init_w, init_h,
|
||||
win_flags);
|
||||
if (!win) {
|
||||
return false;
|
||||
}
|
||||
|
||||
SDL_EnableScreenSaver();
|
||||
|
||||
#if defined(__APPLE__)
|
||||
if (config_.fullscreen) {
|
||||
SDL_Rect usable{};
|
||||
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
|
||||
SDL_SetWindowPosition(win, usable.x, usable.y);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
SDL_GLContext gl_ctx = SDL_GL_CreateContext(win);
|
||||
if (!gl_ctx) {
|
||||
SDL_DestroyWindow(win);
|
||||
return false;
|
||||
}
|
||||
SDL_GL_MakeCurrent(win, gl_ctx);
|
||||
SDL_GL_SetSwapInterval(1); // vsync
|
||||
|
||||
// Create primary ImGui context
|
||||
IMGUI_CHECKVERSION();
|
||||
ImGuiContext *imgui_ctx = ImGui::CreateContext();
|
||||
ImGuiIO &io = ImGui::GetIO();
|
||||
|
||||
// Set custom ini filename path to ~/.config/kte/imgui.ini
|
||||
if (const char *home = std::getenv("HOME")) {
|
||||
namespace fs = std::filesystem;
|
||||
fs::path config_dir = fs::path(home) / ".config" / "kte";
|
||||
std::error_code ec;
|
||||
if (!fs::exists(config_dir)) {
|
||||
fs::create_directories(config_dir, ec);
|
||||
}
|
||||
if (fs::exists(config_dir)) {
|
||||
static std::string ini_path = (config_dir / "imgui.ini").string();
|
||||
io.IniFilename = ini_path.c_str();
|
||||
}
|
||||
}
|
||||
|
||||
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
|
||||
io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad;
|
||||
ImGui::StyleColorsDark();
|
||||
|
||||
if (config_.background == "light")
|
||||
kte::SetBackgroundMode(kte::BackgroundMode::Light);
|
||||
else
|
||||
kte::SetBackgroundMode(kte::BackgroundMode::Dark);
|
||||
kte::ApplyThemeByName(config_.theme);
|
||||
|
||||
apply_syntax_to_buffer(ed.CurrentBuffer(), config_);
|
||||
|
||||
if (!ImGui_ImplSDL2_InitForOpenGL(win, gl_ctx))
|
||||
return false;
|
||||
if (!ImGui_ImplOpenGL3_Init(kGlslVersion))
|
||||
return false;
|
||||
|
||||
// Cache initial window size
|
||||
int w, h;
|
||||
SDL_GetWindowSize(win, &w, &h);
|
||||
init_w = w;
|
||||
init_h = h;
|
||||
|
||||
#if defined(__APPLE__)
|
||||
if (w > 1 && h > 1) {
|
||||
SDL_SetWindowSize(win, w - 1, h - 1);
|
||||
SDL_SetWindowSize(win, w, h);
|
||||
SDL_GetWindowSize(win, &w, &h);
|
||||
init_w = w;
|
||||
init_h = h;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Install embedded fonts
|
||||
kte::Fonts::InstallDefaultFonts();
|
||||
if (!kte::Fonts::FontRegistry::Instance().LoadFont(config_.font, (float) config_.font_size)) {
|
||||
LoadGuiFont_(nullptr, (float) config_.font_size);
|
||||
kte::Fonts::FontRegistry::Instance().RequestLoadFont("default", (float) config_.font_size);
|
||||
std::string n;
|
||||
float s = 0.0f;
|
||||
if (kte::Fonts::FontRegistry::Instance().ConsumePendingFontRequest(n, s)) {
|
||||
kte::Fonts::FontRegistry::Instance().LoadFont(n, s);
|
||||
}
|
||||
}
|
||||
|
||||
// Build primary WindowState
|
||||
auto ws = std::make_unique<WindowState>();
|
||||
ws->window = win;
|
||||
ws->gl_ctx = gl_ctx;
|
||||
ws->imgui_ctx = imgui_ctx;
|
||||
ws->width = init_w;
|
||||
ws->height = init_h;
|
||||
// The primary window's editor IS the editor passed in from main; we don't
|
||||
// use ws->editor for the primary — instead we keep a pointer to &ed.
|
||||
// We store a sentinel: window index 0 uses the external editor reference.
|
||||
// To keep things simple, attach input to the passed-in editor.
|
||||
ws->input.Attach(&ed);
|
||||
windows_.push_back(std::move(ws));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
GUIFrontend::OpenNewWindow_(Editor &primary)
|
||||
{
|
||||
Uint32 win_flags = SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI;
|
||||
int w = windows_[0]->width;
|
||||
int h = windows_[0]->height;
|
||||
|
||||
SDL_Window *win = SDL_CreateWindow(
|
||||
"kge - kyle's graphical editor " KTE_VERSION_STR,
|
||||
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
|
||||
w, h,
|
||||
win_flags);
|
||||
if (!win)
|
||||
return false;
|
||||
|
||||
SDL_GLContext gl_ctx = SDL_GL_CreateContext(win);
|
||||
if (!gl_ctx) {
|
||||
SDL_DestroyWindow(win);
|
||||
return false;
|
||||
}
|
||||
SDL_GL_MakeCurrent(win, gl_ctx);
|
||||
SDL_GL_SetSwapInterval(1);
|
||||
|
||||
// Each window gets its own ImGui context — ImGui requires exactly one
|
||||
// NewFrame/Render cycle per context per frame.
|
||||
ImGuiContext *imgui_ctx = ImGui::CreateContext();
|
||||
ImGui::SetCurrentContext(imgui_ctx);
|
||||
|
||||
SetupImGuiStyle_();
|
||||
|
||||
if (!ImGui_ImplSDL2_InitForOpenGL(win, gl_ctx)) {
|
||||
ImGui::DestroyContext(imgui_ctx);
|
||||
SDL_GL_DeleteContext(gl_ctx);
|
||||
SDL_DestroyWindow(win);
|
||||
return false;
|
||||
}
|
||||
if (!ImGui_ImplOpenGL3_Init(kGlslVersion)) {
|
||||
ImGui_ImplSDL2_Shutdown();
|
||||
ImGui::DestroyContext(imgui_ctx);
|
||||
SDL_GL_DeleteContext(gl_ctx);
|
||||
SDL_DestroyWindow(win);
|
||||
return false;
|
||||
}
|
||||
|
||||
auto ws = std::make_unique<WindowState>();
|
||||
ws->window = win;
|
||||
ws->gl_ctx = gl_ctx;
|
||||
ws->imgui_ctx = imgui_ctx;
|
||||
ws->width = w;
|
||||
ws->height = h;
|
||||
|
||||
// Secondary editor shares the primary's buffer list
|
||||
ws->editor.SetSharedBuffers(&primary.Buffers());
|
||||
ws->editor.SetDimensions(primary.Rows(), primary.Cols());
|
||||
|
||||
// Open a new untitled buffer and switch to it in the new window.
|
||||
ws->editor.AddBuffer(Buffer());
|
||||
ws->editor.SwitchTo(ws->editor.BufferCount() - 1);
|
||||
|
||||
ws->input.Attach(&ws->editor);
|
||||
|
||||
windows_.push_back(std::move(ws));
|
||||
|
||||
// Restore primary context
|
||||
ImGui::SetCurrentContext(windows_[0]->imgui_ctx);
|
||||
SDL_GL_MakeCurrent(windows_[0]->window, windows_[0]->gl_ctx);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GUIFrontend::Step(Editor &ed, bool &running)
|
||||
{
|
||||
// --- Event processing ---
|
||||
// SDL events carry a window ID. Route each event to the correct window's
|
||||
// ImGui context (for ImGui_ImplSDL2_ProcessEvent) and input handler.
|
||||
SDL_Event e;
|
||||
while (SDL_PollEvent(&e)) {
|
||||
// Determine which window this event belongs to
|
||||
Uint32 event_win_id = 0;
|
||||
switch (e.type) {
|
||||
case SDL_WINDOWEVENT:
|
||||
event_win_id = e.window.windowID;
|
||||
break;
|
||||
case SDL_KEYDOWN:
|
||||
case SDL_KEYUP:
|
||||
event_win_id = e.key.windowID;
|
||||
break;
|
||||
case SDL_TEXTINPUT:
|
||||
event_win_id = e.text.windowID;
|
||||
break;
|
||||
case SDL_MOUSEBUTTONDOWN:
|
||||
case SDL_MOUSEBUTTONUP:
|
||||
event_win_id = e.button.windowID;
|
||||
break;
|
||||
case SDL_MOUSEWHEEL:
|
||||
event_win_id = e.wheel.windowID;
|
||||
break;
|
||||
case SDL_MOUSEMOTION:
|
||||
event_win_id = e.motion.windowID;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (e.type == SDL_QUIT) {
|
||||
running = false;
|
||||
break;
|
||||
}
|
||||
|
||||
// Find the target window and route the event to its ImGui context
|
||||
WindowState *target = nullptr;
|
||||
std::size_t target_idx = 0;
|
||||
if (event_win_id != 0) {
|
||||
for (std::size_t i = 0; i < windows_.size(); ++i) {
|
||||
if (SDL_GetWindowID(windows_[i]->window) == event_win_id) {
|
||||
target = windows_[i].get();
|
||||
target_idx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (target && target->imgui_ctx) {
|
||||
// Set this window's ImGui context so ImGui_ImplSDL2_ProcessEvent
|
||||
// updates the correct IO state.
|
||||
ImGui::SetCurrentContext(target->imgui_ctx);
|
||||
ImGui_ImplSDL2_ProcessEvent(&e);
|
||||
}
|
||||
|
||||
if (e.type == SDL_WINDOWEVENT) {
|
||||
if (e.window.event == SDL_WINDOWEVENT_CLOSE) {
|
||||
if (target) {
|
||||
if (target_idx == 0) {
|
||||
running = false;
|
||||
} else {
|
||||
target->alive = false;
|
||||
}
|
||||
}
|
||||
} else if (e.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) {
|
||||
if (target) {
|
||||
target->width = e.window.data1;
|
||||
target->height = e.window.data2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Route input events to the correct window's input handler
|
||||
if (target) {
|
||||
Editor &tgt_ed = (target_idx == 0) ? ed : target->editor;
|
||||
if (tgt_ed.FilePickerVisible()) {
|
||||
// Modal: don't let keystrokes fall through as edit commands
|
||||
// to the buffer underneath. Escape closes the picker;
|
||||
// everything else (navigation, filtering, double-click) is
|
||||
// handled by the picker's own ImGui widgets via
|
||||
// ImGui_ImplSDL2_ProcessEvent above.
|
||||
if (e.type == SDL_KEYDOWN && e.key.keysym.sym == SDLK_ESCAPE) {
|
||||
tgt_ed.SetFilePickerVisible(false);
|
||||
}
|
||||
} else {
|
||||
target->input.ProcessSDLEvent(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!running)
|
||||
return;
|
||||
|
||||
// --- Apply pending font change (to all contexts) ---
|
||||
{
|
||||
std::string fname;
|
||||
float fsize = 0.0f;
|
||||
if (kte::Fonts::FontRegistry::Instance().ConsumePendingFontRequest(fname, fsize)) {
|
||||
if (!fname.empty() && fsize > 0.0f) {
|
||||
for (auto &ws : windows_) {
|
||||
if (!ws->alive || !ws->imgui_ctx)
|
||||
continue;
|
||||
ImGui::SetCurrentContext(ws->imgui_ctx);
|
||||
SDL_GL_MakeCurrent(ws->window, ws->gl_ctx);
|
||||
kte::Fonts::FontRegistry::Instance().LoadFont(fname, fsize);
|
||||
ImGui_ImplOpenGL3_DestroyFontsTexture();
|
||||
ImGui_ImplOpenGL3_CreateFontsTexture();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Step each window ---
|
||||
// We iterate by index because OpenNewWindow_ may append to windows_.
|
||||
for (std::size_t wi = 0; wi < windows_.size(); ++wi) {
|
||||
WindowState &ws = *windows_[wi];
|
||||
if (!ws.alive)
|
||||
continue;
|
||||
|
||||
Editor &wed = (wi == 0) ? ed : ws.editor;
|
||||
|
||||
// Shared buffer list may have been modified by another window.
|
||||
wed.ValidateBufferIndex();
|
||||
|
||||
// Activate this window's GL and ImGui contexts
|
||||
SDL_GL_MakeCurrent(ws.window, ws.gl_ctx);
|
||||
ImGui::SetCurrentContext(ws.imgui_ctx);
|
||||
|
||||
// Start a new ImGui frame
|
||||
ImGui_ImplOpenGL3_NewFrame();
|
||||
ImGui_ImplSDL2_NewFrame(ws.window);
|
||||
ImGui::NewFrame();
|
||||
|
||||
// Update editor dimensions
|
||||
{
|
||||
ImGuiIO &io = ImGui::GetIO();
|
||||
float disp_w = io.DisplaySize.x > 0 ? io.DisplaySize.x : static_cast<float>(ws.width);
|
||||
float disp_h = io.DisplaySize.y > 0 ? io.DisplaySize.y : static_cast<float>(ws.height);
|
||||
update_editor_dimensions(wed, disp_w, disp_h);
|
||||
}
|
||||
|
||||
// Allow deferred opens
|
||||
wed.ProcessPendingOpens();
|
||||
|
||||
// Ensure newly opened buffers get syntax + edit mode detection
|
||||
apply_syntax_to_buffer(wed.CurrentBuffer(), config_);
|
||||
|
||||
// Drain input queue
|
||||
for (;;) {
|
||||
MappedInput mi;
|
||||
if (!ws.input.Poll(mi))
|
||||
break;
|
||||
if (mi.hasCommand) {
|
||||
if (mi.id == CommandId::NewWindow) {
|
||||
// Open a new window; handled after this loop
|
||||
wed.SetNewWindowRequested(true);
|
||||
} else if (mi.id == CommandId::FontZoomIn ||
|
||||
mi.id == CommandId::FontZoomOut ||
|
||||
mi.id == CommandId::FontZoomReset) {
|
||||
auto &fr = kte::Fonts::FontRegistry::Instance();
|
||||
float cur = fr.CurrentFontSize();
|
||||
if (cur <= 0.0f) cur = config_.font_size;
|
||||
float next = cur;
|
||||
if (mi.id == CommandId::FontZoomIn)
|
||||
next = std::min(cur + 2.0f, 72.0f);
|
||||
else if (mi.id == CommandId::FontZoomOut)
|
||||
next = std::max(cur - 2.0f, 8.0f);
|
||||
else
|
||||
next = config_.font_size; // reset to config default
|
||||
if (next != cur)
|
||||
fr.RequestLoadFont(fr.CurrentFontName(), next);
|
||||
} else {
|
||||
const std::string before = wed.KillRingHead();
|
||||
Execute(wed, mi.id, mi.arg, mi.count);
|
||||
const std::string after = wed.KillRingHead();
|
||||
if (after != before && !after.empty()) {
|
||||
SDL_SetClipboardText(after.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (wi == 0 && wed.QuitRequested()) {
|
||||
running = false;
|
||||
}
|
||||
|
||||
// Switch font based on current buffer's edit mode (deferred to next frame)
|
||||
{
|
||||
Buffer *cur = wed.CurrentBuffer();
|
||||
if (cur) {
|
||||
auto &fr = kte::Fonts::FontRegistry::Instance();
|
||||
const std::string &expected =
|
||||
(cur->GetEditMode() == EditMode::Writing)
|
||||
? config_.writing_font
|
||||
: config_.code_font;
|
||||
if (fr.CurrentFontName() != expected && fr.HasFont(expected)) {
|
||||
float sz = fr.CurrentFontSize();
|
||||
if (sz <= 0.0f) sz = config_.font_size;
|
||||
fr.RequestLoadFont(expected, sz);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw
|
||||
ws.renderer.Draw(wed);
|
||||
|
||||
// Render
|
||||
ImGui::Render();
|
||||
int display_w, display_h;
|
||||
SDL_GL_GetDrawableSize(ws.window, &display_w, &display_h);
|
||||
glViewport(0, 0, display_w, display_h);
|
||||
glClearColor(0.1f, 0.1f, 0.11f, 1.0f);
|
||||
glClear(GL_COLOR_BUFFER_BIT);
|
||||
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
|
||||
SDL_GL_SwapWindow(ws.window);
|
||||
}
|
||||
|
||||
// Handle deferred new-window requests (must happen outside the render loop
|
||||
// to avoid corrupting an in-progress ImGui frame).
|
||||
for (std::size_t wi = 0; wi < windows_.size(); ++wi) {
|
||||
Editor &wed = (wi == 0) ? ed : windows_[wi]->editor;
|
||||
if (wed.NewWindowRequested()) {
|
||||
wed.SetNewWindowRequested(false);
|
||||
OpenNewWindow_(ed);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove dead secondary windows
|
||||
for (auto it = windows_.begin() + 1; it != windows_.end();) {
|
||||
if (!(*it)->alive) {
|
||||
DestroyWindowResources_(**it);
|
||||
it = windows_.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore primary context
|
||||
if (!windows_.empty()) {
|
||||
ImGui::SetCurrentContext(windows_[0]->imgui_ctx);
|
||||
SDL_GL_MakeCurrent(windows_[0]->window, windows_[0]->gl_ctx);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GUIFrontend::Shutdown()
|
||||
{
|
||||
// Destroy all windows (secondary first, then primary)
|
||||
for (auto it = windows_.rbegin(); it != windows_.rend(); ++it) {
|
||||
DestroyWindowResources_(**it);
|
||||
}
|
||||
windows_.clear();
|
||||
SDL_Quit();
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
GUIFrontend::LoadGuiFont_(const char * /*path*/, const float size_px)
|
||||
{
|
||||
const ImGuiIO &io = ImGui::GetIO();
|
||||
io.Fonts->Clear();
|
||||
|
||||
ImFontConfig config;
|
||||
config.MergeMode = false;
|
||||
|
||||
io.Fonts->AddFontFromMemoryCompressedTTF(
|
||||
kte::Fonts::DefaultFontData,
|
||||
kte::Fonts::DefaultFontSize,
|
||||
size_px,
|
||||
&config,
|
||||
io.Fonts->GetGlyphRangesDefault());
|
||||
|
||||
config.MergeMode = true;
|
||||
static const ImWchar extended_ranges[] = {
|
||||
0x0370, 0x03FF, // Greek and Coptic
|
||||
0x2200, 0x22FF, // Mathematical Operators
|
||||
0,
|
||||
};
|
||||
io.Fonts->AddFontFromMemoryCompressedTTF(
|
||||
kte::Fonts::IosevkaExtended::DefaultFontRegularCompressedData,
|
||||
kte::Fonts::IosevkaExtended::DefaultFontRegularCompressedSize,
|
||||
size_px,
|
||||
&config,
|
||||
extended_ranges);
|
||||
|
||||
io.Fonts->Build();
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* GUIFrontend - couples ImGuiInputHandler + GUIRenderer and owns SDL2/ImGui lifecycle
|
||||
*/
|
||||
#pragma once
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include "Frontend.h"
|
||||
#include "GUIConfig.h"
|
||||
#include "ImGuiInputHandler.h"
|
||||
#include "ImGuiRenderer.h"
|
||||
#include "Editor.h"
|
||||
|
||||
|
||||
struct SDL_Window;
|
||||
struct ImGuiContext;
|
||||
typedef void *SDL_GLContext;
|
||||
|
||||
class GUIFrontend final : public Frontend {
|
||||
public:
|
||||
GUIFrontend() = default;
|
||||
|
||||
~GUIFrontend() override = default;
|
||||
|
||||
bool Init(int &argc, char **argv, Editor &ed) override;
|
||||
|
||||
void Step(Editor &ed, bool &running) override;
|
||||
|
||||
void Shutdown() override;
|
||||
|
||||
private:
|
||||
// Per-window state — each window owns its own ImGui context so that
|
||||
// NewFrame/Render cycles are fully independent (ImGui requires exactly
|
||||
// one NewFrame per Render per context).
|
||||
struct WindowState {
|
||||
SDL_Window *window = nullptr;
|
||||
SDL_GLContext gl_ctx = nullptr;
|
||||
ImGuiContext *imgui_ctx = nullptr;
|
||||
ImGuiInputHandler input{};
|
||||
ImGuiRenderer renderer{};
|
||||
Editor editor{};
|
||||
int width = 1280;
|
||||
int height = 800;
|
||||
bool alive = true;
|
||||
};
|
||||
|
||||
// Open a new secondary window sharing the primary editor's buffer list.
|
||||
// Returns false if window creation fails.
|
||||
bool OpenNewWindow_(Editor &primary);
|
||||
|
||||
// Initialize fonts and theme for a given ImGui context (must be current).
|
||||
void SetupImGuiStyle_();
|
||||
static void DestroyWindowResources_(WindowState &ws);
|
||||
static bool LoadGuiFont_(const char *path, float size_px);
|
||||
|
||||
GUIConfig config_{};
|
||||
// Primary window (index 0 in windows_); created during Init.
|
||||
std::vector<std::unique_ptr<WindowState> > windows_;
|
||||
};
|
||||
@@ -5,8 +5,23 @@
|
||||
#include <SDL.h>
|
||||
#include <imgui.h>
|
||||
|
||||
#include "GUIInputHandler.h"
|
||||
#include "ImGuiInputHandler.h"
|
||||
#include "KKeymap.h"
|
||||
#include "Editor.h"
|
||||
#include "PasteSplit.h"
|
||||
|
||||
// Verbose k-prefix suffix logging for debugging macOS/SDL key translation
|
||||
// issues. Default to off; enable by defining IMGUI_IH_DEBUG=1 at compile
|
||||
// time. Mirrors QtInputHandler.cc's QT_IH_DEBUG gate.
|
||||
#ifndef IMGUI_IH_DEBUG
|
||||
#define IMGUI_IH_DEBUG 0
|
||||
#endif
|
||||
|
||||
#if IMGUI_IH_DEBUG
|
||||
#define IH_LOGF(...) do { std::fprintf(stderr, __VA_ARGS__); std::fflush(stderr); } while (0)
|
||||
#else
|
||||
#define IH_LOGF(...) ((void) 0)
|
||||
#endif
|
||||
|
||||
|
||||
static bool
|
||||
@@ -14,20 +29,17 @@ map_key(const SDL_Keycode key,
|
||||
const SDL_Keymod mod,
|
||||
bool &k_prefix,
|
||||
bool &esc_meta,
|
||||
// universal-argument state (by ref)
|
||||
bool &uarg_active,
|
||||
bool &uarg_collecting,
|
||||
bool &uarg_negative,
|
||||
bool &uarg_had_digits,
|
||||
int &uarg_value,
|
||||
std::string &uarg_text,
|
||||
MappedInput &out)
|
||||
bool &k_ctrl_pending,
|
||||
Editor *ed,
|
||||
MappedInput &out,
|
||||
bool &suppress_textinput_once)
|
||||
{
|
||||
// Ctrl handling
|
||||
const bool is_ctrl = (mod & KMOD_CTRL) != 0;
|
||||
const bool is_alt = (mod & (KMOD_ALT | KMOD_LALT | KMOD_RALT)) != 0;
|
||||
|
||||
// If previous key was ESC, interpret this as Meta via ESC keymap
|
||||
// If previous key was ESC, interpret this as Meta via ESC keymap.
|
||||
// Prefer KEYDOWN when we can derive a printable ASCII; otherwise defer to TEXTINPUT.
|
||||
if (esc_meta) {
|
||||
int ascii_key = 0;
|
||||
if (key == SDLK_BACKSPACE) {
|
||||
@@ -45,17 +57,18 @@ map_key(const SDL_Keycode key,
|
||||
ascii_key = '>';
|
||||
}
|
||||
if (ascii_key != 0) {
|
||||
esc_meta = false; // consume if we can decide on KEYDOWN
|
||||
ascii_key = KLowerAscii(ascii_key);
|
||||
CommandId id;
|
||||
if (KLookupEscCommand(ascii_key, id)) {
|
||||
// Only consume the ESC-meta prefix if we actually mapped a command
|
||||
esc_meta = false;
|
||||
out = {true, id, "", 0};
|
||||
out = {true, id, "", 0};
|
||||
return true;
|
||||
}
|
||||
// Known printable but unmapped ESC sequence: report invalid
|
||||
out = {true, CommandId::UnknownEscCommand, "", 0};
|
||||
return true;
|
||||
}
|
||||
// Unhandled meta chord at KEYDOWN: do not clear esc_meta here.
|
||||
// Leave it set so SDL_TEXTINPUT fallback can translate and suppress insertion.
|
||||
// No usable ASCII from KEYDOWN → keep esc_meta set and let TEXTINPUT handle it
|
||||
out.hasCommand = false;
|
||||
return true;
|
||||
}
|
||||
@@ -65,43 +78,53 @@ map_key(const SDL_Keycode key,
|
||||
switch (key) {
|
||||
case SDLK_LEFT:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::MoveLeft, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveLeft, "", 0};
|
||||
return true;
|
||||
case SDLK_RIGHT:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::MoveRight, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveRight, "", 0};
|
||||
return true;
|
||||
case SDLK_UP:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::MoveUp, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveUp, "", 0};
|
||||
return true;
|
||||
case SDLK_DOWN:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::MoveDown, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveDown, "", 0};
|
||||
return true;
|
||||
case SDLK_HOME:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::MoveHome, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveHome, "", 0};
|
||||
return true;
|
||||
case SDLK_END:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::MoveEnd, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveEnd, "", 0};
|
||||
return true;
|
||||
case SDLK_PAGEUP:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::PageUp, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::PageUp, "", 0};
|
||||
return true;
|
||||
case SDLK_PAGEDOWN:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::PageDown, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::PageDown, "", 0};
|
||||
return true;
|
||||
case SDLK_DELETE:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::DeleteChar, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::DeleteChar, "", 0};
|
||||
return true;
|
||||
case SDLK_BACKSPACE:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::Backspace, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::Backspace, "", 0};
|
||||
return true;
|
||||
case SDLK_TAB:
|
||||
// Insert a literal tab character when not interpreting a k-prefix suffix.
|
||||
@@ -114,10 +137,17 @@ map_key(const SDL_Keycode key,
|
||||
break; // fall through so k-prefix handler can process
|
||||
case SDLK_RETURN:
|
||||
case SDLK_KP_ENTER:
|
||||
out = {true, CommandId::Newline, "", 0};
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
if (mod & KMOD_SHIFT) {
|
||||
out = {true, CommandId::SmartNewline, "", 0};
|
||||
} else {
|
||||
out = {true, CommandId::Newline, "", 0};
|
||||
}
|
||||
return true;
|
||||
case SDLK_ESCAPE:
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
esc_meta = true; // next key will be treated as Meta
|
||||
out.hasCommand = false; // no immediate command for bare ESC in GUI
|
||||
return true;
|
||||
@@ -127,7 +157,6 @@ map_key(const SDL_Keycode key,
|
||||
|
||||
// If we are in k-prefix, interpret the very next key via the C-k keymap immediately.
|
||||
if (k_prefix) {
|
||||
k_prefix = false;
|
||||
esc_meta = false;
|
||||
// Normalize to ASCII; preserve case for letters using Shift
|
||||
int ascii_key = 0;
|
||||
@@ -147,74 +176,75 @@ map_key(const SDL_Keycode key,
|
||||
ascii_key = static_cast<int>(key);
|
||||
}
|
||||
bool ctrl2 = (mod & KMOD_CTRL) != 0;
|
||||
// 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).
|
||||
if (ascii_key == 'C' || ascii_key == '^') {
|
||||
k_ctrl_pending = true;
|
||||
// Keep waiting for the next suffix; show status and suppress ensuing TEXTINPUT
|
||||
if (ed)
|
||||
ed->SetStatus("C-k C _");
|
||||
suppress_textinput_once = true;
|
||||
out.hasCommand = false;
|
||||
return true;
|
||||
}
|
||||
// Otherwise, consume the k-prefix now for the actual suffix
|
||||
k_prefix = false;
|
||||
if (ascii_key != 0) {
|
||||
int lower = KLowerAscii(ascii_key);
|
||||
bool ctrl_suffix_supported = (lower == 'd' || lower == 'x' || lower == 'q');
|
||||
bool pass_ctrl = ctrl2 && ctrl_suffix_supported;
|
||||
bool pass_ctrl = (ctrl2 || k_ctrl_pending) && ctrl_suffix_supported;
|
||||
k_ctrl_pending = false;
|
||||
CommandId id;
|
||||
bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id);
|
||||
#if IMGUI_IH_DEBUG
|
||||
// Diagnostics for u/U
|
||||
if (lower == 'u') {
|
||||
char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e)
|
||||
? static_cast<char>(ascii_key)
|
||||
: '?';
|
||||
std::fprintf(stderr,
|
||||
"[kge] k-prefix suffix: sym=%d mods=0x%x ascii=%d '%c' ctrl2=%d pass_ctrl=%d mapped=%d id=%d\n",
|
||||
static_cast<int>(key), static_cast<unsigned int>(mod), ascii_key, disp,
|
||||
ctrl2 ? 1 : 0, pass_ctrl ? 1 : 0, mapped ? 1 : 0,
|
||||
mapped ? static_cast<int>(id) : -1);
|
||||
std::fflush(stderr);
|
||||
IH_LOGF(
|
||||
"[kge] k-prefix suffix: sym=%d mods=0x%x ascii=%d '%c' ctrl2=%d pass_ctrl=%d mapped=%d id=%d\n",
|
||||
static_cast<int>(key), static_cast<unsigned int>(mod), ascii_key, disp,
|
||||
ctrl2 ? 1 : 0, pass_ctrl ? 1 : 0, mapped ? 1 : 0,
|
||||
mapped ? static_cast<int>(id) : -1);
|
||||
}
|
||||
#endif
|
||||
if (mapped) {
|
||||
out = {true, id, "", 0};
|
||||
if (ed)
|
||||
ed->SetStatus(""); // clear "C-k _" hint after suffix
|
||||
return true;
|
||||
}
|
||||
int shown = KLowerAscii(ascii_key);
|
||||
char c = (shown >= 0x20 && shown <= 0x7e) ? static_cast<char>(shown) : '?';
|
||||
std::string arg(1, c);
|
||||
out = {true, CommandId::UnknownKCommand, arg, 0};
|
||||
if (ed)
|
||||
ed->SetStatus(""); // clear hint; handler will set unknown status
|
||||
return true;
|
||||
}
|
||||
out.hasCommand = false;
|
||||
// Non-printable/unmappable key as k-suffix (e.g., F-keys): report unknown and exit k-mode
|
||||
out = {true, CommandId::UnknownKCommand, std::string("?"), 0};
|
||||
if (ed)
|
||||
ed->SetStatus("");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (is_ctrl) {
|
||||
// Universal argument: C-u
|
||||
if (key == SDLK_u) {
|
||||
if (!uarg_active) {
|
||||
uarg_active = true;
|
||||
uarg_collecting = true;
|
||||
uarg_negative = false;
|
||||
uarg_had_digits = false;
|
||||
uarg_value = 4; // default
|
||||
uarg_text.clear();
|
||||
out = {true, CommandId::UArgStatus, uarg_text, 0};
|
||||
return true;
|
||||
} else if (uarg_collecting && !uarg_had_digits && !uarg_negative) {
|
||||
if (uarg_value <= 0)
|
||||
uarg_value = 4;
|
||||
else
|
||||
uarg_value *= 4; // repeated C-u multiplies by 4
|
||||
out = {true, CommandId::UArgStatus, uarg_text, 0};
|
||||
return true;
|
||||
} else {
|
||||
// End collection if already started with digits or '-'
|
||||
uarg_collecting = false;
|
||||
if (!uarg_had_digits && !uarg_negative && uarg_value <= 0)
|
||||
uarg_value = 4;
|
||||
}
|
||||
if (ed)
|
||||
ed->UArgStart();
|
||||
out.hasCommand = false;
|
||||
return true;
|
||||
}
|
||||
// Cancel universal arg on C-g as well (it maps to Refresh via ctrl map)
|
||||
if (key == SDLK_g) {
|
||||
uarg_active = false;
|
||||
uarg_collecting = false;
|
||||
uarg_negative = false;
|
||||
uarg_had_digits = false;
|
||||
uarg_value = 0;
|
||||
uarg_text.clear();
|
||||
if (ed)
|
||||
ed->UArgClear();
|
||||
// Also cancel any pending k-prefix qualifier
|
||||
k_ctrl_pending = false;
|
||||
k_prefix = false; // treat as cancel of prefix
|
||||
}
|
||||
if (key == SDLK_k || key == SDLK_KP_EQUALS) {
|
||||
k_prefix = true;
|
||||
@@ -258,29 +288,17 @@ map_key(const SDL_Keycode key,
|
||||
}
|
||||
}
|
||||
|
||||
// If collecting universal argument, allow digits/minus on KEYDOWN path too
|
||||
if (uarg_active && uarg_collecting) {
|
||||
// If collecting universal argument, allow digits on KEYDOWN path too
|
||||
if (ed && ed->UArg() != 0) {
|
||||
if ((key >= SDLK_0 && key <= SDLK_9) && !(mod & KMOD_SHIFT)) {
|
||||
int d = static_cast<int>(key - SDLK_0);
|
||||
if (!uarg_had_digits) {
|
||||
uarg_value = 0;
|
||||
uarg_had_digits = true;
|
||||
}
|
||||
if (uarg_value < 100000000) {
|
||||
uarg_value = uarg_value * 10 + d;
|
||||
}
|
||||
uarg_text.push_back(static_cast<char>('0' + d));
|
||||
out = {true, CommandId::UArgStatus, uarg_text, 0};
|
||||
ed->UArgDigit(d);
|
||||
out.hasCommand = false;
|
||||
// We consumed a digit on KEYDOWN; SDL will often also emit TEXTINPUT for it.
|
||||
// Request suppression of the very next TEXTINPUT to avoid double-counting.
|
||||
suppress_textinput_once = true;
|
||||
return true;
|
||||
}
|
||||
if (key == SDLK_MINUS && !uarg_had_digits && !uarg_negative) {
|
||||
uarg_negative = true;
|
||||
uarg_text = "-";
|
||||
out = {true, CommandId::UArgStatus, uarg_text, 0};
|
||||
return true;
|
||||
}
|
||||
// Any other key will end collection; process it normally
|
||||
uarg_collecting = false;
|
||||
}
|
||||
|
||||
// k_prefix handled earlier
|
||||
@@ -290,31 +308,40 @@ map_key(const SDL_Keycode key,
|
||||
|
||||
|
||||
bool
|
||||
GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
||||
ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
||||
{
|
||||
MappedInput mi;
|
||||
bool produced = false;
|
||||
switch (e.type) {
|
||||
case SDL_MOUSEWHEEL: {
|
||||
// Let ImGui handle mouse wheel when it wants to capture the mouse
|
||||
// (e.g., when hovering the editor child window with scrollbars).
|
||||
// This enables native vertical and horizontal scrolling behavior in GUI.
|
||||
if (ImGui::GetIO().WantCaptureMouse)
|
||||
return false;
|
||||
// Otherwise, fallback to mapping vertical wheel to editor scroll commands.
|
||||
int dy = e.wheel.y;
|
||||
// High-resolution trackpads can deliver fractional wheel deltas. Accumulate
|
||||
// precise values and emit one scroll step per whole unit.
|
||||
float dy = 0.0f;
|
||||
#if SDL_VERSION_ATLEAST(2, 0, 18)
|
||||
dy = e.wheel.preciseY;
|
||||
#else
|
||||
dy = static_cast<float>(e.wheel.y);
|
||||
#endif
|
||||
#ifdef SDL_MOUSEWHEEL_FLIPPED
|
||||
if (e.wheel.direction == SDL_MOUSEWHEEL_FLIPPED)
|
||||
dy = -dy;
|
||||
#endif
|
||||
if (dy != 0) {
|
||||
int repeat = dy > 0 ? dy : -dy;
|
||||
CommandId id = dy > 0 ? CommandId::ScrollUp : CommandId::ScrollDown;
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
for (int i = 0; i < repeat; ++i) {
|
||||
q_.push(MappedInput{true, id, std::string(), 0});
|
||||
if (dy != 0.0f) {
|
||||
wheel_accum_y_ += dy;
|
||||
float abs_accum = wheel_accum_y_ >= 0.0f ? wheel_accum_y_ : -wheel_accum_y_;
|
||||
int steps = static_cast<int>(abs_accum);
|
||||
if (steps > 0) {
|
||||
CommandId id = (wheel_accum_y_ > 0.0f) ? CommandId::ScrollUp : CommandId::ScrollDown;
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
for (int i = 0; i < steps; ++i) {
|
||||
q_.push(MappedInput{true, id, std::string(), 0});
|
||||
}
|
||||
// remove the whole steps, keep fractional remainder
|
||||
wheel_accum_y_ += (wheel_accum_y_ > 0.0f)
|
||||
? -static_cast<float>(steps)
|
||||
: static_cast<float>(steps);
|
||||
return true; // consumed
|
||||
}
|
||||
return true; // consumed
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -325,6 +352,38 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
||||
SDL_Keymod mods = SDL_Keymod(e.key.keysym.mod);
|
||||
const SDL_Keycode key = e.key.keysym.sym;
|
||||
|
||||
// New window: Cmd+N (macOS) or Ctrl+Shift+N (Linux/Windows)
|
||||
{
|
||||
const bool gui_n = (mods & KMOD_GUI) && !(mods & KMOD_CTRL) && (key == SDLK_n);
|
||||
const bool ctrl_sn = (mods & KMOD_CTRL) && (mods & KMOD_SHIFT) && (key == SDLK_n);
|
||||
if (gui_n || ctrl_sn) {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
q_.push(MappedInput{true, CommandId::NewWindow, std::string(), 0});
|
||||
suppress_text_input_once_ = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Font zoom: Cmd+=/Cmd+-/Cmd+0 (macOS) or Ctrl+=/Ctrl+-/Ctrl+0
|
||||
if ((mods & (KMOD_CTRL | KMOD_GUI)) && !(mods & KMOD_SHIFT)) {
|
||||
bool is_zoom = true;
|
||||
CommandId zoom_cmd = CommandId::FontZoomIn;
|
||||
if (key == SDLK_EQUALS || key == SDLK_PLUS)
|
||||
zoom_cmd = CommandId::FontZoomIn;
|
||||
else if (key == SDLK_MINUS)
|
||||
zoom_cmd = CommandId::FontZoomOut;
|
||||
else if (key == SDLK_0)
|
||||
zoom_cmd = CommandId::FontZoomReset;
|
||||
else
|
||||
is_zoom = false;
|
||||
if (is_zoom) {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
q_.push(MappedInput{true, zoom_cmd, std::string(), 0});
|
||||
suppress_text_input_once_ = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Paste: Ctrl+V (Windows/Linux) or Cmd+V (macOS)
|
||||
// Note: SDL defines letter keycodes in lowercase only (e.g., SDLK_v). Shift does not change keycode.
|
||||
if ((mods & (KMOD_CTRL | KMOD_GUI)) && (key == SDLK_v)) {
|
||||
@@ -332,59 +391,40 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
||||
if (clip) {
|
||||
std::string text(clip);
|
||||
SDL_free(clip);
|
||||
// Split on '\n' and enqueue as InsertText/Newline commands
|
||||
// Turn line breaks (\n, \r\n, or bare \r) into Newline
|
||||
// commands and the rest into InsertText; InsertText itself
|
||||
// rejects any embedded '\r'/'\n'.
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
std::size_t start = 0;
|
||||
while (start <= text.size()) {
|
||||
std::size_t pos = text.find('\n', start);
|
||||
std::string_view segment;
|
||||
bool has_nl = (pos != std::string::npos);
|
||||
if (has_nl) {
|
||||
segment = std::string_view(text).substr(start, pos - start);
|
||||
} else {
|
||||
segment = std::string_view(text).substr(start);
|
||||
}
|
||||
if (!segment.empty()) {
|
||||
MappedInput ins{true, CommandId::InsertText, std::string(segment), 0};
|
||||
q_.push(ins);
|
||||
}
|
||||
if (has_nl) {
|
||||
MappedInput nl{true, CommandId::Newline, std::string(), 0};
|
||||
q_.push(nl);
|
||||
start = pos + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (const auto &cmd : SplitPasteIntoCommands(text))
|
||||
q_.push(cmd);
|
||||
// Suppress the corresponding TEXTINPUT that may follow
|
||||
suppress_text_input_once_ = true;
|
||||
return true; // consumed
|
||||
}
|
||||
}
|
||||
|
||||
produced = map_key(key, mods,
|
||||
k_prefix_, esc_meta_,
|
||||
uarg_active_, uarg_collecting_, uarg_negative_, uarg_had_digits_, uarg_value_,
|
||||
uarg_text_,
|
||||
mi);
|
||||
|
||||
// If we inserted a TAB on KEYDOWN, suppress any subsequent SDL_TEXTINPUT
|
||||
// for this keystroke to avoid double insertion on platforms that emit it.
|
||||
if (produced && mi.hasCommand && mi.id == CommandId::InsertText && mi.arg == "\t") {
|
||||
suppress_text_input_once_ = true;
|
||||
}
|
||||
|
||||
// If we just consumed a universal-argument digit or '-' on KEYDOWN and emitted UArgStatus,
|
||||
// suppress the subsequent SDL_TEXTINPUT for this keystroke to avoid duplicating the digit in status.
|
||||
if (produced && mi.hasCommand && mi.id == CommandId::UArgStatus) {
|
||||
// Digits without shift, or a plain '-'
|
||||
const bool is_digit_key = (key >= SDLK_0 && key <= SDLK_9) && !(mods & KMOD_SHIFT);
|
||||
const bool is_minus_key = (key == SDLK_MINUS);
|
||||
if (uarg_active_ && uarg_collecting_ &&(is_digit_key || is_minus_key)) {
|
||||
{
|
||||
bool suppress_req = false;
|
||||
produced = map_key(key, mods,
|
||||
k_prefix_, esc_meta_,
|
||||
k_ctrl_pending_,
|
||||
ed_,
|
||||
mi,
|
||||
suppress_req);
|
||||
if (suppress_req) {
|
||||
// Prevent the corresponding TEXTINPUT from delivering the same digit again
|
||||
suppress_text_input_once_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Do NOT suppress SDL_TEXTINPUT after inserting a TAB. Most platforms
|
||||
// do not emit TEXTINPUT for Tab, and suppressing here would incorrectly
|
||||
// eat the next character typed if no TEXTINPUT follows the Tab press.
|
||||
|
||||
// If we just consumed a universal-argument digit or '-' on KEYDOWN and emitted UArgStatus,
|
||||
// suppress the subsequent SDL_TEXTINPUT for this keystroke to avoid duplicating the digit in status.
|
||||
// Additional suppression handled above when KEYDOWN consumed a uarg digit
|
||||
|
||||
// Suppress the immediate following SDL_TEXTINPUT when a printable KEYDOWN was used as a
|
||||
// k-prefix suffix or Meta (Alt/ESC) chord, regardless of whether a command was produced.
|
||||
const bool is_alt = (mods & (KMOD_ALT | KMOD_LALT | KMOD_RALT)) != 0;
|
||||
@@ -404,7 +444,8 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
||||
}
|
||||
// Alt/Meta + letter can also generate TEXTINPUT on some platforms
|
||||
const bool is_meta_symbol = (
|
||||
key == SDLK_COMMA || key == SDLK_PERIOD || key == SDLK_LESS || key == SDLK_GREATER);
|
||||
key == SDLK_COMMA || key == SDLK_PERIOD || key == SDLK_LESS || key ==
|
||||
SDLK_GREATER);
|
||||
if (is_alt && ((key >= SDLK_a && key <= SDLK_z) || is_meta_symbol)) {
|
||||
should_suppress = true;
|
||||
}
|
||||
@@ -428,35 +469,26 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
||||
break;
|
||||
}
|
||||
|
||||
// If universal argument collection is active, consume digit/minus TEXTINPUT
|
||||
if (uarg_active_ && uarg_collecting_) {
|
||||
// If editor universal argument is active, consume digit TEXTINPUT
|
||||
if (ed_ &&ed_
|
||||
|
||||
|
||||
|
||||
->
|
||||
UArg() != 0
|
||||
)
|
||||
{
|
||||
const char *txt = e.text.text;
|
||||
if (txt && *txt) {
|
||||
unsigned char c0 = static_cast<unsigned char>(txt[0]);
|
||||
if (c0 >= '0' && c0 <= '9') {
|
||||
int d = c0 - '0';
|
||||
if (!uarg_had_digits_) {
|
||||
uarg_value_ = 0;
|
||||
uarg_had_digits_ = true;
|
||||
}
|
||||
if (uarg_value_ < 100000000) {
|
||||
uarg_value_ = uarg_value_ * 10 + d;
|
||||
}
|
||||
uarg_text_.push_back(static_cast<char>(c0));
|
||||
mi = {true, CommandId::UArgStatus, uarg_text_, 0};
|
||||
produced = true; // consumed and enqueued status update
|
||||
break;
|
||||
}
|
||||
if (c0 == '-' && !uarg_had_digits_ && !uarg_negative_) {
|
||||
uarg_negative_ = true;
|
||||
uarg_text_ = "-";
|
||||
mi = {true, CommandId::UArgStatus, uarg_text_, 0};
|
||||
produced = true;
|
||||
ed_->UArgDigit(d);
|
||||
produced = true; // consumed to update status
|
||||
break;
|
||||
}
|
||||
}
|
||||
// End collection and allow this TEXTINPUT to be processed normally below
|
||||
uarg_collecting_ = false;
|
||||
// Non-digit ends collection; allow processing normally below
|
||||
}
|
||||
|
||||
// If we are still in k-prefix and KEYDOWN path didn't handle the suffix,
|
||||
@@ -472,20 +504,36 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
||||
ascii_key = static_cast<int>(c0);
|
||||
}
|
||||
if (ascii_key != 0) {
|
||||
// Qualifier via TEXTINPUT: uppercase 'C' or '^' only
|
||||
if (ascii_key == 'C' || ascii_key == '^') {
|
||||
k_ctrl_pending_ = true;
|
||||
if (ed_)
|
||||
ed_->SetStatus("C-k C _");
|
||||
// Keep k-prefix active; do not emit a command
|
||||
k_prefix_ = true;
|
||||
produced = true;
|
||||
break;
|
||||
}
|
||||
// Map via k-prefix table; do not pass Ctrl for TEXTINPUT case
|
||||
CommandId id;
|
||||
bool mapped = KLookupKCommand(ascii_key, false, id);
|
||||
bool pass_ctrl = k_ctrl_pending_;
|
||||
k_ctrl_pending_ = false;
|
||||
bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id);
|
||||
#if IMGUI_IH_DEBUG
|
||||
// Diagnostics: log any k-prefix TEXTINPUT suffix mapping
|
||||
char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e)
|
||||
? static_cast<char>(ascii_key)
|
||||
: '?';
|
||||
std::fprintf(stderr,
|
||||
"[kge] k-prefix TEXTINPUT suffix: ascii=%d '%c' mapped=%d id=%d\n",
|
||||
ascii_key, disp, mapped ? 1 : 0,
|
||||
mapped ? static_cast<int>(id) : -1);
|
||||
std::fflush(stderr);
|
||||
{
|
||||
char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e)
|
||||
? static_cast<char>(ascii_key)
|
||||
: '?';
|
||||
IH_LOGF("[kge] k-prefix TEXTINPUT suffix: ascii=%d '%c' mapped=%d id=%d\n",
|
||||
ascii_key, disp, mapped ? 1 : 0,
|
||||
mapped ? static_cast<int>(id) : -1);
|
||||
}
|
||||
#endif
|
||||
if (mapped) {
|
||||
mi = {true, id, "", 0};
|
||||
mi = {true, id, "", 0};
|
||||
if (ed_)
|
||||
ed_->SetStatus(""); // clear "C-k _" hint after suffix
|
||||
produced = true;
|
||||
break; // handled; do not insert text
|
||||
} else {
|
||||
@@ -495,13 +543,18 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
||||
? static_cast<char>(shown)
|
||||
: '?';
|
||||
std::string arg(1, c);
|
||||
mi = {true, CommandId::UnknownKCommand, arg, 0};
|
||||
mi = {true, CommandId::UnknownKCommand, arg, 0};
|
||||
if (ed_)
|
||||
ed_->SetStatus("");
|
||||
produced = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Consume even if no usable ascii was found
|
||||
// If no usable ASCII was found, still report an unknown k-command and exit k-mode
|
||||
mi = {true, CommandId::UnknownKCommand, std::string("?"), 0};
|
||||
if (ed_)
|
||||
ed_->SetStatus("");
|
||||
produced = true;
|
||||
break;
|
||||
}
|
||||
@@ -541,7 +594,8 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
||||
}
|
||||
}
|
||||
}
|
||||
// If we get here, swallow the TEXTINPUT (do not insert stray char)
|
||||
// If we get here, unmapped ESC sequence via TEXTINPUT: report invalid
|
||||
mi = {true, CommandId::UnknownEscCommand, "", 0};
|
||||
produced = true;
|
||||
break;
|
||||
}
|
||||
@@ -571,31 +625,6 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
||||
}
|
||||
|
||||
if (produced && mi.hasCommand) {
|
||||
// Attach universal-argument count if present, then clear the state
|
||||
if (uarg_active_ &&mi
|
||||
|
||||
.
|
||||
id != CommandId::UArgStatus
|
||||
)
|
||||
{
|
||||
int count = 0;
|
||||
if (!uarg_had_digits_ && !uarg_negative_) {
|
||||
// No explicit digits: use current value (default 4 or 4^n)
|
||||
count = (uarg_value_ > 0) ? uarg_value_ : 4;
|
||||
} else {
|
||||
count = uarg_value_;
|
||||
if (uarg_negative_)
|
||||
count = -count;
|
||||
}
|
||||
mi.count = count;
|
||||
// Clear universal-argument state after applying it
|
||||
uarg_active_ = false;
|
||||
uarg_collecting_ = false;
|
||||
uarg_negative_ = false;
|
||||
uarg_had_digits_ = false;
|
||||
uarg_value_ = 0;
|
||||
uarg_text_.clear();
|
||||
}
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
q_.push(mi);
|
||||
}
|
||||
@@ -604,7 +633,7 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
||||
|
||||
|
||||
bool
|
||||
GUIInputHandler::Poll(MappedInput &out)
|
||||
ImGuiInputHandler::Poll(MappedInput &out)
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
if (q_.empty())
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* GUIInputHandler - ImGui/SDL2-based input mapping for GUI mode
|
||||
* ImGuiInputHandler - ImGui/SDL2-based input mapping for GUI mode
|
||||
*/
|
||||
#pragma once
|
||||
#include <mutex>
|
||||
@@ -10,11 +10,18 @@
|
||||
|
||||
union SDL_Event; // fwd decl to avoid including SDL here (SDL defines SDL_Event as a union)
|
||||
|
||||
class GUIInputHandler final : public InputHandler {
|
||||
class ImGuiInputHandler final : public InputHandler {
|
||||
public:
|
||||
GUIInputHandler() = default;
|
||||
ImGuiInputHandler() = default;
|
||||
|
||||
~ImGuiInputHandler() override = default;
|
||||
|
||||
|
||||
void Attach(Editor *ed) override
|
||||
{
|
||||
ed_ = ed;
|
||||
}
|
||||
|
||||
~GUIInputHandler() override = default;
|
||||
|
||||
// Translate an SDL event to editor command and enqueue if applicable.
|
||||
// Returns true if it produced a mapped command or consumed input.
|
||||
@@ -25,18 +32,18 @@ public:
|
||||
private:
|
||||
std::mutex mu_;
|
||||
std::queue<MappedInput> q_;
|
||||
bool k_prefix_ = false;
|
||||
bool k_prefix_ = false;
|
||||
bool k_ctrl_pending_ = false; // if true, next k-suffix is treated as Ctrl- (qualifier via literal 'C' or '^')
|
||||
// Treat ESC as a Meta prefix: next key is looked up via ESC keymap
|
||||
bool esc_meta_ = false;
|
||||
// When a printable keydown generated a non-text command, suppress the very next SDL_TEXTINPUT
|
||||
// event produced by SDL for the same keystroke to avoid inserting stray characters.
|
||||
bool suppress_text_input_once_ = false;
|
||||
|
||||
// Universal argument (C-u) state for GUI
|
||||
bool uarg_active_ = false; // an argument is pending for the next command
|
||||
bool uarg_collecting_ = false; // collecting digits / '-' right now
|
||||
bool uarg_negative_ = false; // whether a leading '-' was supplied
|
||||
bool uarg_had_digits_ = false; // whether any digits were supplied
|
||||
int uarg_value_ = 0; // current absolute value (>=0)
|
||||
std::string uarg_text_; // raw digits/minus typed for status display
|
||||
};
|
||||
Editor *ed_ = nullptr; // attached editor for editor-owned uarg handling
|
||||
|
||||
// Accumulators for high-resolution (trackpad) scrolling. We emit one scroll
|
||||
// command per whole step and keep the fractional remainder.
|
||||
float wheel_accum_y_ = 0.0f;
|
||||
float wheel_accum_x_ = 0.0f; // reserved for future horizontal scrolling
|
||||
};
|
||||
+463
-255
@@ -9,7 +9,7 @@
|
||||
#include <imgui.h>
|
||||
#include <regex>
|
||||
|
||||
#include "GUIRenderer.h"
|
||||
#include "ImGuiRenderer.h"
|
||||
#include "Highlight.h"
|
||||
#include "GUITheme.h"
|
||||
#include "Buffer.h"
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
|
||||
void
|
||||
GUIRenderer::Draw(Editor &ed)
|
||||
ImGuiRenderer::Draw(Editor &ed)
|
||||
{
|
||||
// Make the editor window occupy the entire GUI container/viewport
|
||||
ImGuiViewport *vp = ImGui::GetMainViewport();
|
||||
@@ -76,236 +76,299 @@ GUIRenderer::Draw(Editor &ed)
|
||||
// Two-way sync between Buffer::Rowoffs and ImGui scroll position:
|
||||
// - If command layer changed Buffer::Rowoffs since last frame, drive ImGui scroll from it.
|
||||
// - Otherwise, propagate ImGui scroll to Buffer::Rowoffs so command layer has an up-to-date view.
|
||||
static long prev_buf_rowoffs = -1; // previous frame's Buffer::Rowoffs
|
||||
static long prev_buf_coloffs = -1; // previous frame's Buffer::Coloffs
|
||||
|
||||
const long buf_rowoffs = static_cast<long>(buf->Rowoffs());
|
||||
const long buf_coloffs = static_cast<long>(buf->Coloffs());
|
||||
|
||||
// Detect programmatic change (e.g., page_down command changed rowoffs)
|
||||
// Use SetNextWindowScroll BEFORE BeginChild to set initial scroll position
|
||||
if (prev_buf_rowoffs >= 0 && buf_rowoffs != prev_buf_rowoffs) {
|
||||
if (prev_buf_rowoffs_ >= 0 && buf_rowoffs != prev_buf_rowoffs_) {
|
||||
float target_y = static_cast<float>(buf_rowoffs) * row_h;
|
||||
ImGui::SetNextWindowScroll(ImVec2(-1.0f, target_y));
|
||||
}
|
||||
if (prev_buf_coloffs >= 0 && buf_coloffs != prev_buf_coloffs) {
|
||||
float target_x = static_cast<float>(buf_coloffs) * space_w;
|
||||
float target_y = static_cast<float>(buf_rowoffs) * row_h;
|
||||
ImGui::SetNextWindowScroll(ImVec2(target_x, target_y));
|
||||
}
|
||||
// Horizontal scroll is handled purely in pixel space (see
|
||||
// cursor-visibility block after the line loop) so we don't
|
||||
// convert the character-based coloffs to an ImGui scroll here.
|
||||
|
||||
// Reserve space for status bar at bottom
|
||||
ImGui::BeginChild("scroll", ImVec2(0, -ImGui::GetFrameHeightWithSpacing()), false,
|
||||
// Reserve space for status bar at bottom.
|
||||
// We calculate a height that is an exact multiple of the line height
|
||||
// to avoid partial lines and "scroll past end" jitter.
|
||||
float total_avail_h = ImGui::GetContentRegionAvail().y;
|
||||
float wanted_bar_h = ImGui::GetFrameHeight();
|
||||
float child_h_plan = std::max(0.0f, std::floor((total_avail_h - wanted_bar_h) / row_h) * row_h);
|
||||
float real_bar_h = total_avail_h - child_h_plan;
|
||||
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0));
|
||||
ImGui::BeginChild("scroll", ImVec2(0, child_h_plan), false,
|
||||
ImGuiWindowFlags_HorizontalScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
||||
|
||||
// Get child window position and scroll for click handling
|
||||
ImVec2 child_window_pos = ImGui::GetWindowPos();
|
||||
float scroll_y = ImGui::GetScrollY();
|
||||
float scroll_x = ImGui::GetScrollX();
|
||||
std::size_t rowoffs = 0; // we render from the first line; scrolling is handled by ImGui
|
||||
|
||||
// Synchronize buffer offsets from ImGui scroll if user scrolled manually
|
||||
bool forced_scroll = false;
|
||||
{
|
||||
static float prev_scroll_y = -1.0f; // previous frame's ImGui scroll Y in pixels
|
||||
static float prev_scroll_x = -1.0f; // previous frame's ImGui scroll X in pixels
|
||||
|
||||
const long scroll_top = static_cast<long>(scroll_y / row_h);
|
||||
const long scroll_left = static_cast<long>(scroll_x / space_w);
|
||||
|
||||
// Check if rowoffs was programmatically changed this frame
|
||||
if (prev_buf_rowoffs >= 0 && buf_rowoffs != prev_buf_rowoffs) {
|
||||
if (prev_buf_rowoffs_ >= 0 && buf_rowoffs != prev_buf_rowoffs_) {
|
||||
forced_scroll = true;
|
||||
}
|
||||
|
||||
// If user scrolled (not programmatic), update buffer offsets accordingly
|
||||
if (prev_scroll_y >= 0.0f && scroll_y != prev_scroll_y && !forced_scroll) {
|
||||
// If user scrolled vertically (not programmatic), update buffer row offset
|
||||
if (prev_scroll_y_ >= 0.0f && scroll_y != prev_scroll_y_ && !forced_scroll) {
|
||||
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
|
||||
mbuf->SetOffsets(static_cast<std::size_t>(std::max(0L, scroll_top)),
|
||||
mbuf->Coloffs());
|
||||
}
|
||||
}
|
||||
if (prev_scroll_x >= 0.0f && scroll_x != prev_scroll_x && !forced_scroll) {
|
||||
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
|
||||
mbuf->SetOffsets(mbuf->Rowoffs(),
|
||||
static_cast<std::size_t>(std::max(0L, scroll_left)));
|
||||
}
|
||||
}
|
||||
// Horizontal scroll is pixel-based and managed by the cursor
|
||||
// visibility block below; we don't sync it back to coloffs.
|
||||
|
||||
// Update trackers for next frame
|
||||
prev_scroll_y = scroll_y;
|
||||
prev_scroll_x = scroll_x;
|
||||
prev_scroll_y_ = scroll_y;
|
||||
prev_scroll_x_ = scroll_x;
|
||||
}
|
||||
prev_buf_rowoffs = buf_rowoffs;
|
||||
prev_buf_coloffs = buf_coloffs;
|
||||
prev_buf_rowoffs_ = buf_rowoffs;
|
||||
prev_buf_coloffs_ = buf_coloffs;
|
||||
|
||||
// Synchronize cursor and scrolling.
|
||||
// Ensure the cursor is visible even on the first frame or when it didn't move.
|
||||
// Max-line-width cache: reset when the buffer or font changes. We only
|
||||
// measure visible lines per frame and maintain a running max, so the
|
||||
// scrollbar may be slightly conservative on first view of a file until
|
||||
// the user scrolls, but idle CPU drops dramatically on large files.
|
||||
// Edits bump the buffer version but we intentionally do NOT reset the
|
||||
// max on version changes — keeping a conservative (possibly too-wide)
|
||||
// scrollbar is preferable to per-keystroke jitter.
|
||||
{
|
||||
// Compute visible row range using the child window height
|
||||
float child_h = ImGui::GetWindowHeight();
|
||||
long first_row = static_cast<long>(scroll_y / row_h);
|
||||
long vis_rows = static_cast<long>(child_h / row_h);
|
||||
if (vis_rows < 1)
|
||||
vis_rows = 1;
|
||||
long last_row = first_row + vis_rows - 1;
|
||||
|
||||
long cyr = static_cast<long>(cy);
|
||||
if (cyr < first_row || cyr > last_row) {
|
||||
float target = (static_cast<float>(cyr) - std::max(0L, vis_rows / 2)) * row_h;
|
||||
float max_y = ImGui::GetScrollMaxY();
|
||||
if (target < 0.f)
|
||||
target = 0.f;
|
||||
if (max_y >= 0.f && target > max_y)
|
||||
target = max_y;
|
||||
ImGui::SetScrollY(target);
|
||||
// refresh local variables
|
||||
scroll_y = ImGui::GetScrollY();
|
||||
first_row = static_cast<long>(scroll_y / row_h);
|
||||
last_row = first_row + vis_rows - 1;
|
||||
ImFont *cur_font = ImGui::GetFont();
|
||||
float cur_fsize = ImGui::GetFontSize();
|
||||
if (buf != max_width_buf_
|
||||
|| cur_font != max_width_font_
|
||||
|| cur_fsize != max_width_font_size_) {
|
||||
max_width_buf_ = buf;
|
||||
max_width_font_ = cur_font;
|
||||
max_width_font_size_ = cur_fsize;
|
||||
max_width_px_ = 0.0f;
|
||||
}
|
||||
max_width_version_ = buf->Version();
|
||||
}
|
||||
|
||||
// Horizontal scroll: ensure cursor column is visible
|
||||
float child_w = ImGui::GetWindowWidth();
|
||||
long vis_cols = static_cast<long>(child_w / space_w);
|
||||
if (vis_cols < 1)
|
||||
vis_cols = 1;
|
||||
long first_col = static_cast<long>(scroll_x / space_w);
|
||||
long last_col = first_col + vis_cols - 1;
|
||||
|
||||
// Compute cursor's rendered X position (accounting for tabs)
|
||||
std::size_t cursor_rx = 0;
|
||||
if (cy < lines.size()) {
|
||||
std::string cur_line = static_cast<std::string>(lines[cy]);
|
||||
const std::size_t tabw = 8;
|
||||
for (std::size_t i = 0; i < cx && i < cur_line.size(); ++i) {
|
||||
if (cur_line[i] == '\t') {
|
||||
cursor_rx += tabw - (cursor_rx % tabw);
|
||||
} else {
|
||||
cursor_rx += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
long cxr = static_cast<long>(cursor_rx);
|
||||
if (cxr < first_col || cxr > last_col) {
|
||||
float target_x = static_cast<float>(cxr) * space_w;
|
||||
// Center horizontally if possible
|
||||
target_x -= (child_w / 2.0f);
|
||||
if (target_x < 0.f)
|
||||
target_x = 0.f;
|
||||
float max_x = ImGui::GetScrollMaxX();
|
||||
if (max_x >= 0.f && target_x > max_x)
|
||||
target_x = max_x;
|
||||
ImGui::SetScrollX(target_x);
|
||||
scroll_x = ImGui::GetScrollX();
|
||||
}
|
||||
// Phase 3: prefetch visible viewport highlights and warm around in background
|
||||
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
|
||||
int fr = static_cast<int>(std::max(0L, first_row));
|
||||
int rc = static_cast<int>(std::max(1L, vis_rows));
|
||||
buf->Highlighter()->PrefetchViewport(*buf, fr, rc, buf->Version());
|
||||
// Hoist the search-regex compilation out of the per-line loop. Compiling
|
||||
// std::regex per line per frame was a large source of idle CPU on macOS.
|
||||
const bool search_mode = ed.SearchActive() && !ed.SearchQuery().empty();
|
||||
const bool regex_mode = search_mode && ed.PromptActive() && (
|
||||
ed.CurrentPromptKind() == Editor::PromptKind::RegexSearch ||
|
||||
ed.CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind);
|
||||
std::regex search_rx;
|
||||
bool search_rx_valid = false;
|
||||
if (regex_mode) {
|
||||
try {
|
||||
search_rx = std::regex(ed.SearchQuery());
|
||||
search_rx_valid = true;
|
||||
} catch (const std::regex_error &) {
|
||||
search_rx_valid = false;
|
||||
}
|
||||
}
|
||||
// Cache current horizontal offset in rendered columns for click handling
|
||||
const std::size_t coloffs_now = buf->Coloffs();
|
||||
|
||||
// Handle mouse click before rendering to avoid dependent on drawn items
|
||||
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
||||
// Compute the visible row range and skip rendering work for off-screen
|
||||
// lines. ImGui clips drawing, but string allocation, tab expansion,
|
||||
// syntax-highlight lookups, and width measurement are not free.
|
||||
const std::size_t total_rows = lines.size();
|
||||
std::size_t first_vis = 0;
|
||||
std::size_t last_vis = total_rows; // exclusive
|
||||
if (row_h > 0.0f && total_rows > 0) {
|
||||
const long margin = 4; // render a few extra rows above/below for smoother scrolling
|
||||
long fv = static_cast<long>(std::floor(scroll_y / row_h)) - margin;
|
||||
long lv = static_cast<long>(
|
||||
std::ceil((scroll_y + child_h_plan) / row_h)) + margin;
|
||||
if (fv < 0) fv = 0;
|
||||
if (lv < 0) lv = 0;
|
||||
if (static_cast<std::size_t>(fv) > total_rows)
|
||||
fv = static_cast<long>(total_rows);
|
||||
if (static_cast<std::size_t>(lv) > total_rows)
|
||||
lv = static_cast<long>(total_rows);
|
||||
first_vis = static_cast<std::size_t>(fv);
|
||||
last_vis = static_cast<std::size_t>(lv);
|
||||
}
|
||||
|
||||
// Mark selection state (mark -> cursor), in source coordinates
|
||||
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;
|
||||
|
||||
// (mouse_selecting__ is a member variable)
|
||||
auto mouse_pos_to_buf = [&]() -> std::pair<std::size_t, std::size_t> {
|
||||
ImVec2 mp = ImGui::GetIO().MousePos;
|
||||
// Compute content-relative position accounting for scroll
|
||||
// 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)
|
||||
// Convert mouse pos to buffer row
|
||||
float content_y = (mp.y - child_window_pos.y) + scroll_y;
|
||||
long by_l = static_cast<long>(content_y / row_h);
|
||||
if (by_l < 0)
|
||||
by_l = 0;
|
||||
|
||||
// Convert to buffer row
|
||||
std::size_t by = static_cast<std::size_t>(by_l);
|
||||
if (by >= lines.size()) {
|
||||
if (!lines.empty())
|
||||
by = lines.size() - 1;
|
||||
else
|
||||
by = 0;
|
||||
}
|
||||
if (by >= lines.size())
|
||||
by = lines.empty() ? 0 : (lines.size() - 1);
|
||||
|
||||
// Compute click X position relative to left edge of child window (in pixels)
|
||||
// This gives us the visual offset from the start of displayed content
|
||||
if (lines.empty())
|
||||
return {0, 0};
|
||||
|
||||
// Expand tabs for the clicked line
|
||||
std::string line_clicked = static_cast<std::string>(lines[by]);
|
||||
const std::size_t tabw = 8;
|
||||
std::string click_expanded;
|
||||
click_expanded.reserve(line_clicked.size() + 16);
|
||||
std::size_t click_rx = 0;
|
||||
// Map: source column -> expanded column
|
||||
std::vector<std::size_t> src_to_exp;
|
||||
src_to_exp.reserve(line_clicked.size() + 1);
|
||||
for (std::size_t ci = 0; ci < line_clicked.size(); ++ci) {
|
||||
src_to_exp.push_back(click_rx);
|
||||
if (line_clicked[ci] == '\t') {
|
||||
std::size_t adv = (tabw - (click_rx % tabw));
|
||||
click_expanded.append(adv, ' ');
|
||||
click_rx += adv;
|
||||
} else {
|
||||
click_expanded.push_back(line_clicked[ci]);
|
||||
click_rx += 1;
|
||||
}
|
||||
}
|
||||
src_to_exp.push_back(click_rx); // past-end position
|
||||
|
||||
// Pixel x relative to the line start (accounting for scroll)
|
||||
float visual_x = mp.x - child_window_pos.x;
|
||||
if (visual_x < 0.0f)
|
||||
visual_x = 0.0f;
|
||||
// Add scroll offset in pixels
|
||||
visual_x += scroll_x;
|
||||
|
||||
// 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;
|
||||
|
||||
// Empty buffer guard: if there are no lines yet, just move to 0:0
|
||||
if (lines.empty()) {
|
||||
Execute(ed, CommandId::MoveCursorTo, std::string("0:0"));
|
||||
} else {
|
||||
// Convert rendered column (clicked_rx) to source column accounting for tabs
|
||||
std::string line_clicked = static_cast<std::string>(lines[by]);
|
||||
const std::size_t tabw = 8;
|
||||
|
||||
// Iterate through source columns, computing rendered position, to find closest match
|
||||
std::size_t rx = 0; // rendered column position
|
||||
std::size_t best_col = 0;
|
||||
float best_dist = std::numeric_limits<float>::infinity();
|
||||
float clicked_rx_f = static_cast<float>(clicked_rx);
|
||||
|
||||
for (std::size_t i = 0; i <= line_clicked.size(); ++i) {
|
||||
// Check current position
|
||||
float dist = std::fabs(clicked_rx_f - static_cast<float>(rx));
|
||||
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;
|
||||
}
|
||||
}
|
||||
// Find the source column whose expanded position is closest
|
||||
// to the click pixel, using actual text measurement.
|
||||
std::size_t best_col = 0;
|
||||
float best_dist = std::numeric_limits<float>::infinity();
|
||||
for (std::size_t ci = 0; ci <= line_clicked.size(); ++ci) {
|
||||
std::size_t exp_col = src_to_exp[ci];
|
||||
float px = 0.0f;
|
||||
if (exp_col > 0 && !click_expanded.empty()) {
|
||||
std::size_t end = std::min(click_expanded.size(), exp_col);
|
||||
px = ImGui::CalcTextSize(click_expanded.c_str(),
|
||||
click_expanded.c_str() + end).x;
|
||||
}
|
||||
float dist = std::fabs(visual_x - px);
|
||||
if (dist < best_dist) {
|
||||
best_dist = dist;
|
||||
best_col = ci;
|
||||
}
|
||||
}
|
||||
return {by, best_col};
|
||||
};
|
||||
|
||||
// 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));
|
||||
// Mouse-driven selection: set mark on double-click or drag, update cursor on any press/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));
|
||||
|
||||
// Only set mark on double click.
|
||||
// Dragging will also set the mark if not already set (handled below).
|
||||
if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) {
|
||||
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
|
||||
mbuf->SetMark(bx, by);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (std::size_t i = rowoffs; i < lines.size(); ++i) {
|
||||
if (mouse_selecting_ && ImGui::IsWindowHovered() && ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
|
||||
auto [by, bx] = mouse_pos_to_buf();
|
||||
// If we are dragging (mouse moved while down), ensure mark is set to start selection
|
||||
if (ImGui::IsMouseDragging(ImGuiMouseButton_Left, 1.0f)) {
|
||||
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
|
||||
if (!mbuf->MarkSet()) {
|
||||
// We'd need to convert click_pos to buf coords, but it's complex here.
|
||||
// Setting it to where the cursor was *before* we started moving it
|
||||
// in this frame is a good approximation, or just using current.
|
||||
mbuf->SetMark(mbuf->Curx(), mbuf->Cury());
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
// Advance the cursor Y so the first visible line draws at its correct
|
||||
// scroll position. Skipped rows simply leave the layout cursor untouched.
|
||||
if (first_vis > 0) {
|
||||
ImGui::SetCursorPosY(ImGui::GetCursorPosY() +
|
||||
static_cast<float>(first_vis) * row_h);
|
||||
}
|
||||
for (std::size_t i = first_vis; i < last_vis; ++i) {
|
||||
// Capture the screen position before drawing the line
|
||||
ImVec2 line_pos = ImGui::GetCursorScreenPos();
|
||||
std::string line = static_cast<std::string>(lines[i]);
|
||||
|
||||
// Expand tabs to spaces with width=8 and apply horizontal scroll offset
|
||||
// Expand tabs to spaces with width=8
|
||||
const std::size_t tabw = 8;
|
||||
std::string expanded;
|
||||
expanded.reserve(line.size() + 16);
|
||||
std::size_t rx_abs_draw = 0; // rendered column for drawing
|
||||
std::size_t rx_abs_draw = 0;
|
||||
for (std::size_t src = 0; src < line.size(); ++src) {
|
||||
char c = line[src];
|
||||
if (c == '\t') {
|
||||
std::size_t adv = (tabw - (rx_abs_draw % tabw));
|
||||
expanded.append(adv, ' ');
|
||||
rx_abs_draw += adv;
|
||||
} else {
|
||||
expanded.push_back(c);
|
||||
rx_abs_draw += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: convert a rendered column position to an absolute
|
||||
// pixel x offset from the start of the line. ImGui's scroll
|
||||
// handles viewport clipping so we measure from column 0.
|
||||
auto rx_to_px = [&](std::size_t rx_col) -> float {
|
||||
std::size_t end = std::min(expanded.size(), rx_col);
|
||||
if (end == 0)
|
||||
return 0.0f;
|
||||
return ImGui::CalcTextSize(expanded.c_str(),
|
||||
expanded.c_str() + end).x;
|
||||
};
|
||||
|
||||
// Compute search highlight ranges for this line in source indices
|
||||
bool search_mode = ed.SearchActive() && !ed.SearchQuery().empty();
|
||||
std::vector<std::pair<std::size_t, std::size_t> > hl_src_ranges;
|
||||
if (search_mode) {
|
||||
// If we're in RegexSearch or RegexReplaceFind mode, compute ranges using regex; otherwise plain substring
|
||||
if (ed.PromptActive() && (
|
||||
ed.CurrentPromptKind() == Editor::PromptKind::RegexSearch || ed.
|
||||
CurrentPromptKind() == Editor::PromptKind::RegexReplaceFind)) {
|
||||
try {
|
||||
std::regex rx(ed.SearchQuery());
|
||||
for (auto it = std::sregex_iterator(line.begin(), line.end(), rx);
|
||||
it != std::sregex_iterator(); ++it) {
|
||||
const auto &m = *it;
|
||||
std::size_t sx = static_cast<std::size_t>(m.position());
|
||||
std::size_t ex = sx + static_cast<std::size_t>(m.length());
|
||||
hl_src_ranges.emplace_back(sx, ex);
|
||||
// In regex mode, reuse the compiled regex hoisted above the loop.
|
||||
if (regex_mode) {
|
||||
if (search_rx_valid) {
|
||||
try {
|
||||
for (auto it = std::sregex_iterator(line.begin(), line.end(), search_rx);
|
||||
it != std::sregex_iterator(); ++it) {
|
||||
const auto &m = *it;
|
||||
std::size_t sx = static_cast<std::size_t>(m.position());
|
||||
std::size_t ex = sx + static_cast<std::size_t>(m.length());
|
||||
hl_src_ranges.emplace_back(sx, ex);
|
||||
}
|
||||
} catch (const std::regex_error &) {
|
||||
// ignore invalid patterns here; status line already shows the error
|
||||
}
|
||||
} catch (const std::regex_error &) {
|
||||
// ignore invalid patterns here; status line already shows the error
|
||||
}
|
||||
} else {
|
||||
const std::string &q = ed.SearchQuery();
|
||||
@@ -338,13 +401,8 @@ GUIRenderer::Draw(Editor &ed)
|
||||
std::size_t sx = rg.first, ex = rg.second;
|
||||
std::size_t rx_start = src_to_rx(sx);
|
||||
std::size_t rx_end = src_to_rx(ex);
|
||||
// Apply horizontal scroll offset
|
||||
if (rx_end <= coloffs_now)
|
||||
continue; // fully left of view
|
||||
std::size_t vx0 = (rx_start > coloffs_now) ? (rx_start - coloffs_now) : 0;
|
||||
std::size_t vx1 = rx_end - coloffs_now;
|
||||
ImVec2 p0 = ImVec2(line_pos.x + static_cast<float>(vx0) * space_w, line_pos.y);
|
||||
ImVec2 p1 = ImVec2(line_pos.x + static_cast<float>(vx1) * space_w,
|
||||
ImVec2 p0 = ImVec2(line_pos.x + rx_to_px(rx_start), line_pos.y);
|
||||
ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end),
|
||||
line_pos.y + line_h);
|
||||
// Choose color: current match stronger
|
||||
bool is_current = has_current && sx == cur_x && ex == cur_end;
|
||||
@@ -354,23 +412,89 @@ GUIRenderer::Draw(Editor &ed)
|
||||
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
||||
}
|
||||
}
|
||||
// Emit entire line to an expanded buffer (tabs -> spaces)
|
||||
for (std::size_t src = 0; src < line.size(); ++src) {
|
||||
char c = line[src];
|
||||
if (c == '\t') {
|
||||
std::size_t adv = (tabw - (rx_abs_draw % tabw));
|
||||
expanded.append(adv, ' ');
|
||||
rx_abs_draw += adv;
|
||||
|
||||
// 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 {
|
||||
expanded.push_back(c);
|
||||
rx_abs_draw += 1;
|
||||
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);
|
||||
ImVec2 p0 = ImVec2(line_pos.x + rx_to_px(rx_start),
|
||||
line_pos.y);
|
||||
ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end),
|
||||
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;
|
||||
}
|
||||
ImVec2 p0 = ImVec2(line_pos.x + rx_to_px(rx_start),
|
||||
line_pos.y);
|
||||
ImVec2 p1 = ImVec2(line_pos.x + rx_to_px(rx_end),
|
||||
line_pos.y + line_h);
|
||||
ImU32 col = ImGui::GetColorU32(ImGuiCol_TextSelectedBg);
|
||||
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
||||
}
|
||||
// Draw syntax-colored runs (text above background highlights)
|
||||
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
|
||||
const kte::LineHighlight &lh = buf->Highlighter()->GetLine(
|
||||
kte::LineHighlight lh = buf->Highlighter()->GetLine(
|
||||
*buf, static_cast<int>(i), buf->Version());
|
||||
// Sanitize spans defensively: clamp to [0, line.size()], ensure end>=start, drop empties
|
||||
struct SSpan {
|
||||
std::size_t s;
|
||||
std::size_t e;
|
||||
kte::TokenKind k;
|
||||
};
|
||||
std::vector<SSpan> spans;
|
||||
spans.reserve(lh.spans.size());
|
||||
const std::size_t line_len = line.size();
|
||||
for (const auto &sp: lh.spans) {
|
||||
int s_raw = sp.col_start;
|
||||
int e_raw = sp.col_end;
|
||||
if (e_raw < s_raw)
|
||||
std::swap(e_raw, s_raw);
|
||||
std::size_t s = static_cast<std::size_t>(std::max(
|
||||
0, std::min(s_raw, static_cast<int>(line_len))));
|
||||
std::size_t e = static_cast<std::size_t>(std::max(
|
||||
static_cast<int>(s), std::min(e_raw, static_cast<int>(line_len))));
|
||||
if (e <= s)
|
||||
continue;
|
||||
spans.push_back(SSpan{s, e, sp.kind});
|
||||
}
|
||||
std::sort(spans.begin(), spans.end(), [](const SSpan &a, const SSpan &b) {
|
||||
return a.s < b.s;
|
||||
});
|
||||
|
||||
// Helper to convert a src column to expanded rx position
|
||||
auto src_to_rx_full = [&](std::size_t sidx) -> std::size_t {
|
||||
std::size_t rx = 0;
|
||||
@@ -379,25 +503,18 @@ GUIRenderer::Draw(Editor &ed)
|
||||
}
|
||||
return rx;
|
||||
};
|
||||
for (const auto &sp: lh.spans) {
|
||||
std::size_t rx_s = src_to_rx_full(
|
||||
static_cast<std::size_t>(std::max(0, sp.col_start)));
|
||||
std::size_t rx_e = src_to_rx_full(
|
||||
static_cast<std::size_t>(std::max(sp.col_start, sp.col_end)));
|
||||
if (rx_e <= coloffs_now)
|
||||
continue;
|
||||
// Clamp rx_s/rx_e to the visible portion
|
||||
std::size_t draw_start = (rx_s > coloffs_now) ? rx_s : coloffs_now;
|
||||
std::size_t draw_end = rx_e;
|
||||
|
||||
for (const auto &sp: spans) {
|
||||
std::size_t rx_s = src_to_rx_full(sp.s);
|
||||
std::size_t rx_e = src_to_rx_full(sp.e);
|
||||
std::size_t draw_start = rx_s;
|
||||
if (draw_start >= expanded.size())
|
||||
continue;
|
||||
draw_end = std::min<std::size_t>(draw_end, expanded.size());
|
||||
std::size_t draw_end = std::min<std::size_t>(rx_e, expanded.size());
|
||||
if (draw_end <= draw_start)
|
||||
continue;
|
||||
// Screen position is relative to coloffs_now
|
||||
std::size_t screen_x = draw_start - coloffs_now;
|
||||
ImU32 col = ImGui::GetColorU32(kte::SyntaxInk(sp.kind));
|
||||
ImVec2 p = ImVec2(line_pos.x + static_cast<float>(screen_x) * space_w,
|
||||
ImU32 col = ImGui::GetColorU32(kte::SyntaxInk(sp.k));
|
||||
ImVec2 p = ImVec2(line_pos.x + rx_to_px(draw_start),
|
||||
line_pos.y);
|
||||
ImGui::GetWindowDrawList()->AddText(
|
||||
p, col, expanded.c_str() + draw_start, expanded.c_str() + draw_end);
|
||||
@@ -406,54 +523,152 @@ GUIRenderer::Draw(Editor &ed)
|
||||
// Use row_h (with spacing) to match click calculation and ensure consistent line positions.
|
||||
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
|
||||
} else {
|
||||
// No syntax: draw as one run, accounting for horizontal scroll offset
|
||||
if (coloffs_now < expanded.size()) {
|
||||
// No syntax: draw the full line; ImGui scroll handles clipping.
|
||||
if (!expanded.empty()) {
|
||||
ImVec2 p = ImVec2(line_pos.x, line_pos.y);
|
||||
ImGui::GetWindowDrawList()->AddText(
|
||||
p, ImGui::GetColorU32(ImGuiCol_Text),
|
||||
expanded.c_str() + coloffs_now);
|
||||
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
|
||||
} else {
|
||||
// Line is fully scrolled out of view horizontally
|
||||
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
|
||||
expanded.c_str());
|
||||
}
|
||||
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + row_h));
|
||||
}
|
||||
|
||||
// Draw a visible cursor indicator on the current line
|
||||
if (i == cy) {
|
||||
// Compute rendered X (rx) from source column with tab expansion
|
||||
std::size_t rx_abs = 0;
|
||||
for (std::size_t k = 0; k < std::min(cx, line.size()); ++k) {
|
||||
if (line[k] == '\t')
|
||||
rx_abs += (tabw - (rx_abs % tabw));
|
||||
else
|
||||
rx_abs += 1;
|
||||
}
|
||||
// Convert to viewport x by subtracting horizontal col offset
|
||||
std::size_t rx_viewport = (rx_abs > coloffs_now) ? (rx_abs - coloffs_now) : 0;
|
||||
ImVec2 p0 = ImVec2(line_pos.x + static_cast<float>(rx_viewport) * space_w, line_pos.y);
|
||||
std::size_t rx_abs = src_to_rx(cx);
|
||||
float cursor_px = rx_to_px(rx_abs);
|
||||
ImVec2 p0 = ImVec2(line_pos.x + cursor_px, line_pos.y);
|
||||
ImVec2 p1 = ImVec2(p0.x + space_w, p0.y + line_h);
|
||||
ImU32 col = IM_COL32(200, 200, 255, 128); // soft highlight
|
||||
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
||||
}
|
||||
|
||||
// Track widest line for content width reporting. We only measure
|
||||
// visible lines and fold the result into a monotonic max cached on
|
||||
// the renderer (see max_width_* members). Off-screen lines are not
|
||||
// measured per frame; if the user scrolls or edits, the cache is
|
||||
// refreshed accordingly.
|
||||
if (!expanded.empty()) {
|
||||
float line_w = ImGui::CalcTextSize(expanded.c_str()).x;
|
||||
if (line_w > max_width_px_)
|
||||
max_width_px_ = line_w;
|
||||
}
|
||||
}
|
||||
// After the visible-range loop, advance the layout cursor to the end of
|
||||
// the (virtual) content so ImGui sees the correct total size. Vertical
|
||||
// height comes from total_rows; horizontal width comes from the cached
|
||||
// max line width. A Dummy at the final position records both.
|
||||
if (total_rows > last_vis) {
|
||||
ImGui::SetCursorPosY(ImGui::GetCursorPosY() +
|
||||
static_cast<float>(total_rows - last_vis) * row_h);
|
||||
}
|
||||
if (max_width_px_ > 0.0f) {
|
||||
ImGui::SetCursorPosX(max_width_px_);
|
||||
}
|
||||
ImGui::Dummy(ImVec2(0, 0));
|
||||
// Synchronize cursor and scrolling after rendering all lines so content size is known.
|
||||
{
|
||||
float child_h_actual = ImGui::GetWindowHeight();
|
||||
float child_w_actual = ImGui::GetWindowWidth();
|
||||
float scroll_y_now = ImGui::GetScrollY();
|
||||
float scroll_x_now = ImGui::GetScrollX();
|
||||
|
||||
long first_row = static_cast<long>(scroll_y_now / row_h);
|
||||
long vis_rows = static_cast<long>(std::round(child_h_actual / row_h));
|
||||
if (vis_rows < 1)
|
||||
vis_rows = 1;
|
||||
long last_row = first_row + vis_rows - 1;
|
||||
|
||||
long cyr = static_cast<long>(cy);
|
||||
if (cyr < first_row) {
|
||||
float target = static_cast<float>(cyr) * row_h;
|
||||
if (target < 0.f)
|
||||
target = 0.f;
|
||||
float max_y = ImGui::GetScrollMaxY();
|
||||
if (max_y >= 0.f && target > max_y)
|
||||
target = max_y;
|
||||
ImGui::SetScrollY(target);
|
||||
first_row = static_cast<long>(target / row_h);
|
||||
last_row = first_row + vis_rows - 1;
|
||||
} else if (cyr > last_row) {
|
||||
long new_first = cyr - vis_rows + 1;
|
||||
if (new_first < 0)
|
||||
new_first = 0;
|
||||
float target = static_cast<float>(new_first) * row_h;
|
||||
float max_y = ImGui::GetScrollMaxY();
|
||||
if (target < 0.f)
|
||||
target = 0.f;
|
||||
if (max_y >= 0.f && target > max_y)
|
||||
target = max_y;
|
||||
ImGui::SetScrollY(target);
|
||||
first_row = static_cast<long>(target / row_h);
|
||||
last_row = first_row + vis_rows - 1;
|
||||
}
|
||||
|
||||
// Horizontal scroll: ensure cursor is visible (pixel-based for proportional fonts)
|
||||
float cursor_px_abs = 0.0f;
|
||||
if (cy < lines.size()) {
|
||||
std::string cur_line = static_cast<std::string>(lines[cy]);
|
||||
const std::size_t tabw = 8;
|
||||
// Expand tabs for cursor line to measure pixel position
|
||||
std::string cur_expanded;
|
||||
cur_expanded.reserve(cur_line.size() + 16);
|
||||
std::size_t cur_rx = 0;
|
||||
for (std::size_t ci = 0; ci < cur_line.size(); ++ci) {
|
||||
if (cur_line[ci] == '\t') {
|
||||
std::size_t adv = tabw - (cur_rx % tabw);
|
||||
cur_expanded.append(adv, ' ');
|
||||
cur_rx += adv;
|
||||
} else {
|
||||
cur_expanded.push_back(cur_line[ci]);
|
||||
cur_rx += 1;
|
||||
}
|
||||
}
|
||||
// Compute rendered column of cursor
|
||||
std::size_t cursor_rx = 0;
|
||||
for (std::size_t ci = 0; ci < cx && ci < cur_line.size(); ++ci) {
|
||||
if (cur_line[ci] == '\t')
|
||||
cursor_rx += tabw - (cursor_rx % tabw);
|
||||
else
|
||||
cursor_rx += 1;
|
||||
}
|
||||
std::size_t exp_end = std::min(cur_expanded.size(), cursor_rx);
|
||||
if (exp_end > 0)
|
||||
cursor_px_abs = ImGui::CalcTextSize(cur_expanded.c_str(),
|
||||
cur_expanded.c_str() + exp_end).x;
|
||||
}
|
||||
if (cursor_px_abs < scroll_x_now || cursor_px_abs > scroll_x_now + child_w_actual) {
|
||||
float target_x = cursor_px_abs - (child_w_actual / 2.0f);
|
||||
if (target_x < 0.f)
|
||||
target_x = 0.f;
|
||||
float max_x = ImGui::GetScrollMaxX();
|
||||
if (max_x >= 0.f && target_x > max_x)
|
||||
target_x = max_x;
|
||||
ImGui::SetScrollX(target_x);
|
||||
}
|
||||
|
||||
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
|
||||
int fr = static_cast<int>(std::max(0L, first_row));
|
||||
int rc = static_cast<int>(std::max(1L, vis_rows));
|
||||
buf->Highlighter()->PrefetchViewport(*buf, fr, rc, buf->Version());
|
||||
}
|
||||
}
|
||||
ImGui::EndChild();
|
||||
ImGui::PopStyleVar(2); // WindowPadding, ItemSpacing
|
||||
|
||||
// Status bar spanning full width
|
||||
ImGui::Separator();
|
||||
|
||||
// Compute full content width and draw a filled background rectangle
|
||||
// Status bar area starting right after the scroll child
|
||||
ImVec2 win_pos = ImGui::GetWindowPos();
|
||||
ImVec2 cr_min = ImGui::GetWindowContentRegionMin();
|
||||
ImVec2 cr_max = ImGui::GetWindowContentRegionMax();
|
||||
float x0 = win_pos.x + cr_min.x;
|
||||
float x1 = win_pos.x + cr_max.x;
|
||||
ImVec2 cursor = ImGui::GetCursorScreenPos();
|
||||
float bar_h = ImGui::GetFrameHeight();
|
||||
ImVec2 p0(x0, cursor.y);
|
||||
ImVec2 p1(x1, cursor.y + bar_h);
|
||||
ImVec2 win_sz = ImGui::GetWindowSize();
|
||||
float x0 = win_pos.x;
|
||||
float x1 = win_pos.x + win_sz.x;
|
||||
float y0 = ImGui::GetCursorScreenPos().y;
|
||||
float bar_h = real_bar_h;
|
||||
|
||||
ImVec2 p0(x0, y0);
|
||||
ImVec2 p1(x1, y0 + bar_h);
|
||||
ImU32 bg_col = ImGui::GetColorU32(ImGuiCol_HeaderActive);
|
||||
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, bg_col);
|
||||
|
||||
// If a prompt is active, replace the entire status bar with the prompt text
|
||||
if (ed.PromptActive()) {
|
||||
std::string label = ed.PromptLabel();
|
||||
@@ -508,7 +723,7 @@ GUIRenderer::Draw(Editor &ed)
|
||||
(size_t) std::max<size_t>(
|
||||
1, (size_t) (tail.size() / 4)))
|
||||
: 1;
|
||||
start += skip;
|
||||
start += skip;
|
||||
std::string candidate = tail.substr(start);
|
||||
ImVec2 cand_sz = ImGui::CalcTextSize(candidate.c_str());
|
||||
if (cand_sz.x <= avail_px) {
|
||||
@@ -539,11 +754,9 @@ GUIRenderer::Draw(Editor &ed)
|
||||
|
||||
ImVec2 msg_sz = ImGui::CalcTextSize(final_msg.c_str());
|
||||
ImGui::PushClipRect(ImVec2(p0.x, p0.y), ImVec2(p1.x, p1.y), true);
|
||||
ImGui::SetCursorScreenPos(ImVec2(left_x, p0.y + (bar_h - msg_sz.y) * 0.5f));
|
||||
ImGui::SetCursorScreenPos(ImVec2(left_x, y0 + (bar_h - msg_sz.y) * 0.5f));
|
||||
ImGui::TextUnformatted(final_msg.c_str());
|
||||
ImGui::PopClipRect();
|
||||
// Advance cursor to after the bar to keep layout consistent
|
||||
ImGui::Dummy(ImVec2(x1 - x0, bar_h));
|
||||
} else {
|
||||
// Build left text
|
||||
std::string left;
|
||||
@@ -566,11 +779,11 @@ GUIRenderer::Draw(Editor &ed)
|
||||
std::size_t total = ed.BufferCount();
|
||||
if (total > 0) {
|
||||
std::size_t idx1 = ed.CurrentBufferIndex() + 1; // 1-based for display
|
||||
left += "[";
|
||||
left += std::to_string(static_cast<unsigned long long>(idx1));
|
||||
left += "/";
|
||||
left += std::to_string(static_cast<unsigned long long>(total));
|
||||
left += "] ";
|
||||
left += "[";
|
||||
left += std::to_string(static_cast<unsigned long long>(idx1));
|
||||
left += "/";
|
||||
left += std::to_string(static_cast<unsigned long long>(total));
|
||||
left += "] ";
|
||||
}
|
||||
}
|
||||
left += fname;
|
||||
@@ -579,9 +792,9 @@ GUIRenderer::Draw(Editor &ed)
|
||||
// Append total line count as "<n>L"
|
||||
{
|
||||
unsigned long lcount = static_cast<unsigned long>(buf->Rows().size());
|
||||
left += " ";
|
||||
left += std::to_string(lcount);
|
||||
left += "L";
|
||||
left += " ";
|
||||
left += std::to_string(lcount);
|
||||
left += "L";
|
||||
}
|
||||
|
||||
// Build right text (cursor/mark)
|
||||
@@ -619,20 +832,21 @@ GUIRenderer::Draw(Editor &ed)
|
||||
float max_left = std::max(0.0f, right_x - left_x - pad);
|
||||
if (max_left < left_sz.x && max_left > 10.0f) {
|
||||
// Render a clipped left using a child region
|
||||
ImGui::SetCursorScreenPos(ImVec2(left_x, p0.y + (bar_h - left_sz.y) * 0.5f));
|
||||
ImGui::PushClipRect(ImVec2(left_x, p0.y), ImVec2(right_x - pad, p1.y), true);
|
||||
ImGui::SetCursorScreenPos(ImVec2(left_x, y0 + (bar_h - left_sz.y) * 0.5f));
|
||||
ImGui::PushClipRect(ImVec2(left_x, y0), ImVec2(right_x - pad, y0 + bar_h),
|
||||
true);
|
||||
ImGui::TextUnformatted(left.c_str());
|
||||
ImGui::PopClipRect();
|
||||
}
|
||||
} else {
|
||||
// Draw left normally
|
||||
ImGui::SetCursorScreenPos(ImVec2(left_x, p0.y + (bar_h - left_sz.y) * 0.5f));
|
||||
ImGui::SetCursorScreenPos(ImVec2(left_x, y0 + (bar_h - left_sz.y) * 0.5f));
|
||||
ImGui::TextUnformatted(left.c_str());
|
||||
}
|
||||
|
||||
// Draw right
|
||||
ImGui::SetCursorScreenPos(ImVec2(std::max(right_x, left_x),
|
||||
p0.y + (bar_h - right_sz.y) * 0.5f));
|
||||
y0 + (bar_h - right_sz.y) * 0.5f));
|
||||
ImGui::TextUnformatted(right.c_str());
|
||||
|
||||
// Draw middle message centered in remaining space
|
||||
@@ -644,14 +858,12 @@ GUIRenderer::Draw(Editor &ed)
|
||||
ImVec2 msg_sz = ImGui::CalcTextSize(msg.c_str());
|
||||
float msg_x = mid_left + std::max(0.0f, (mid_w - msg_sz.x) * 0.5f);
|
||||
// Clip to middle region
|
||||
ImGui::PushClipRect(ImVec2(mid_left, p0.y), ImVec2(mid_right, p1.y), true);
|
||||
ImGui::SetCursorScreenPos(ImVec2(msg_x, p0.y + (bar_h - msg_sz.y) * 0.5f));
|
||||
ImGui::PushClipRect(ImVec2(mid_left, y0), ImVec2(mid_right, y0 + bar_h), true);
|
||||
ImGui::SetCursorScreenPos(ImVec2(msg_x, y0 + (bar_h - msg_sz.y) * 0.5f));
|
||||
ImGui::TextUnformatted(msg.c_str());
|
||||
ImGui::PopClipRect();
|
||||
}
|
||||
}
|
||||
// Advance cursor to after the bar to keep layout consistent
|
||||
ImGui::Dummy(ImVec2(x1 - x0, bar_h));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -787,12 +999,8 @@ GUIRenderer::Draw(Editor &ed)
|
||||
ed.SetFilePickerDir(e.path.string());
|
||||
} else if (!e.is_dir && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
||||
// Open file on single click
|
||||
std::string err;
|
||||
if (!ed.OpenFile(e.path.string(), err)) {
|
||||
ed.SetStatus(std::string("open: ") + err);
|
||||
} else {
|
||||
ed.SetStatus(std::string("Opened: ") + e.name);
|
||||
}
|
||||
ed.RequestOpenFile(e.path.string());
|
||||
(void) ed.ProcessPendingOpens();
|
||||
ed.SetFilePickerVisible(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* ImGuiRenderer - ImGui-based renderer for GUI mode
|
||||
*/
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include "Renderer.h"
|
||||
|
||||
struct ImFont;
|
||||
class Buffer;
|
||||
|
||||
class ImGuiRenderer final : public Renderer {
|
||||
public:
|
||||
ImGuiRenderer() = default;
|
||||
|
||||
~ImGuiRenderer() override = default;
|
||||
|
||||
void Draw(Editor &ed) override;
|
||||
|
||||
private:
|
||||
// Per-window scroll tracking for two-way sync between Buffer offsets and ImGui scroll.
|
||||
// These must be per-instance (not static) so each window maintains independent state.
|
||||
long prev_buf_rowoffs_ = -1;
|
||||
long prev_buf_coloffs_ = -1;
|
||||
float prev_scroll_y_ = -1.0f;
|
||||
float prev_scroll_x_ = -1.0f;
|
||||
bool mouse_selecting_ = false;
|
||||
|
||||
// Max-line-width cache for the horizontal scrollbar. Measuring every line
|
||||
// every frame is prohibitively expensive on large files; we only update the
|
||||
// running max from visible lines and reset when buffer/version/font changes.
|
||||
const Buffer *max_width_buf_ = nullptr;
|
||||
std::uint64_t max_width_version_ = 0;
|
||||
ImFont *max_width_font_ = nullptr;
|
||||
float max_width_font_size_ = 0.0f;
|
||||
float max_width_px_ = 0.0f;
|
||||
};
|
||||
+7
-1
@@ -6,6 +6,8 @@
|
||||
|
||||
#include "Command.h"
|
||||
|
||||
class Editor; // fwd decl
|
||||
|
||||
|
||||
// Result of translating raw input into an editor command.
|
||||
struct MappedInput {
|
||||
@@ -19,7 +21,11 @@ class InputHandler {
|
||||
public:
|
||||
virtual ~InputHandler() = default;
|
||||
|
||||
// Optional: attach current Editor so handlers can consult editor state (e.g., universal argument)
|
||||
// Default implementation does nothing.
|
||||
virtual void Attach(Editor *) {}
|
||||
|
||||
// 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.
|
||||
virtual bool Poll(MappedInput &out) = 0;
|
||||
};
|
||||
};
|
||||
|
||||
+32
-6
@@ -1,6 +1,4 @@
|
||||
#include <iostream>
|
||||
#include <ncurses.h>
|
||||
#include <ostream>
|
||||
|
||||
#include "KKeymap.h"
|
||||
|
||||
@@ -17,6 +15,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
||||
case 'd':
|
||||
out = CommandId::KillLine;
|
||||
return true;
|
||||
case 's':
|
||||
out = CommandId::Save;
|
||||
return true;
|
||||
case 'q':
|
||||
out = CommandId::QuitNow;
|
||||
return true;
|
||||
@@ -37,11 +38,29 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
||||
out = CommandId::ToggleReadOnly; // C-k ' (toggle read-only)
|
||||
return true;
|
||||
}
|
||||
if (ascii_key == 'E') {
|
||||
// Explicitly rejected and kept distinct from 'e' (OpenFileStart): the
|
||||
// switch below operates on the lowercased key, so it can't otherwise
|
||||
// tell 'E' and 'e' apart. Return false and let the caller show its
|
||||
// normal "unknown k-command" status-line message - curses already
|
||||
// owns the terminal here, so writing to stderr would corrupt the
|
||||
// screen.
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (k_lower) {
|
||||
case 'a':
|
||||
out = CommandId::MarkAllAndJumpEnd;
|
||||
return true;
|
||||
case ' ': // C-k SPACE
|
||||
out = CommandId::ToggleMark;
|
||||
return true;
|
||||
case 'i':
|
||||
out = CommandId::BufferNew; // C-k i new empty buffer
|
||||
return true;
|
||||
case 'k':
|
||||
out = CommandId::CenterOnCursor; // C-k k center current line
|
||||
return true;
|
||||
case 'b':
|
||||
out = CommandId::BufferSwitchStart;
|
||||
return true;
|
||||
@@ -54,9 +73,6 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
||||
case 'e':
|
||||
out = CommandId::OpenFileStart;
|
||||
return true;
|
||||
case 'E':
|
||||
std::cerr << "E is not a valid command" << std::endl;
|
||||
return false;
|
||||
case 'f':
|
||||
out = CommandId::FlushKillRing;
|
||||
return true;
|
||||
@@ -72,6 +88,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
||||
case 'l':
|
||||
out = CommandId::ReloadBuffer;
|
||||
return true;
|
||||
case 'm':
|
||||
out = CommandId::ToggleEditMode;
|
||||
return true;
|
||||
case 'n':
|
||||
out = CommandId::BufferPrev;
|
||||
return true;
|
||||
@@ -108,6 +127,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
||||
case '=':
|
||||
out = CommandId::IndentRegion;
|
||||
return true;
|
||||
case '/':
|
||||
out = CommandId::VisualLineModeToggle;
|
||||
return true;
|
||||
case ';':
|
||||
out = CommandId::CommandPromptStart; // C-k ; : generic command prompt
|
||||
return true;
|
||||
@@ -211,8 +233,12 @@ KLookupEscCommand(const int ascii_key, CommandId &out) -> bool
|
||||
case 'q':
|
||||
out = CommandId::ReflowParagraph; // Esc q (reflow paragraph)
|
||||
return true;
|
||||
case '\n':
|
||||
case '\r':
|
||||
out = CommandId::SmartNewline; // Shift+Enter (some terminals send this as Alt+Enter sequences)
|
||||
return true;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -27,4 +27,4 @@ KLowerAscii(const int key)
|
||||
if (key >= 'A' && key <= 'Z')
|
||||
return key + ('a' - 'A');
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -20,4 +20,4 @@ private:
|
||||
std::string last_pat_;
|
||||
|
||||
void build_bad_char(const std::string &pattern);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* PasteSplit.h - split pasted/clipboard text into editor commands
|
||||
*
|
||||
* Pure logic (no SDL/GUI dependencies) so it can be unit tested and shared
|
||||
* across frontends. Line breaks in the pasted text must become Newline
|
||||
* commands; InsertText itself rejects embedded '\r'/'\n' (see Command.cc).
|
||||
*/
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "InputHandler.h"
|
||||
|
||||
|
||||
// Translate a block of pasted text into a sequence of editor commands.
|
||||
//
|
||||
// Any of "\n", "\r\n", or a bare "\r" is treated as a single line break and
|
||||
// emitted as a Newline command; the text between breaks becomes InsertText
|
||||
// commands. Empty text segments are skipped, but line breaks are always
|
||||
// emitted so blank lines round-trip correctly.
|
||||
inline std::vector<MappedInput> SplitPasteIntoCommands(const std::string &text)
|
||||
{
|
||||
std::vector<MappedInput> out;
|
||||
std::string segment;
|
||||
|
||||
auto flush_segment = [&]() {
|
||||
if (!segment.empty()) {
|
||||
out.push_back(MappedInput{true, CommandId::InsertText, segment, 0});
|
||||
segment.clear();
|
||||
}
|
||||
};
|
||||
|
||||
for (std::size_t i = 0; i < text.size(); ++i) {
|
||||
const char c = text[i];
|
||||
if (c == '\n' || c == '\r') {
|
||||
flush_segment();
|
||||
out.push_back(MappedInput{true, CommandId::Newline, std::string(), 0});
|
||||
// Collapse a "\r\n" pair into one line break.
|
||||
if (c == '\r' && i + 1 < text.size() && text[i + 1] == '\n')
|
||||
++i;
|
||||
} else {
|
||||
segment.push_back(c);
|
||||
}
|
||||
}
|
||||
flush_segment();
|
||||
|
||||
return out;
|
||||
}
|
||||
+580
-7
@@ -1,5 +1,7 @@
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
#include <limits>
|
||||
#include <ostream>
|
||||
|
||||
#include "PieceTable.h"
|
||||
|
||||
@@ -14,13 +16,32 @@ PieceTable::PieceTable(const std::size_t initialCapacity)
|
||||
}
|
||||
|
||||
|
||||
PieceTable::PieceTable(const std::size_t initialCapacity,
|
||||
const std::size_t piece_limit,
|
||||
const std::size_t small_piece_threshold,
|
||||
const std::size_t max_consolidation_bytes)
|
||||
{
|
||||
add_.reserve(initialCapacity);
|
||||
materialized_.reserve(initialCapacity);
|
||||
piece_limit_ = piece_limit;
|
||||
small_piece_threshold_ = small_piece_threshold;
|
||||
max_consolidation_bytes_ = max_consolidation_bytes;
|
||||
}
|
||||
|
||||
|
||||
PieceTable::PieceTable(const PieceTable &other)
|
||||
: original_(other.original_),
|
||||
add_(other.add_),
|
||||
pieces_(other.pieces_),
|
||||
materialized_(other.materialized_),
|
||||
dirty_(other.dirty_),
|
||||
total_size_(other.total_size_) {}
|
||||
total_size_(other.total_size_)
|
||||
{
|
||||
version_ = other.version_;
|
||||
// caches are per-instance, mark invalid
|
||||
range_cache_ = {};
|
||||
find_cache_ = {};
|
||||
}
|
||||
|
||||
|
||||
PieceTable &
|
||||
@@ -34,6 +55,9 @@ PieceTable::operator=(const PieceTable &other)
|
||||
materialized_ = other.materialized_;
|
||||
dirty_ = other.dirty_;
|
||||
total_size_ = other.total_size_;
|
||||
version_ = other.version_;
|
||||
range_cache_ = {};
|
||||
find_cache_ = {};
|
||||
return *this;
|
||||
}
|
||||
|
||||
@@ -48,6 +72,9 @@ PieceTable::PieceTable(PieceTable &&other) noexcept
|
||||
{
|
||||
other.dirty_ = true;
|
||||
other.total_size_ = 0;
|
||||
version_ = other.version_;
|
||||
range_cache_ = {};
|
||||
find_cache_ = {};
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +91,9 @@ PieceTable::operator=(PieceTable &&other) noexcept
|
||||
total_size_ = other.total_size_;
|
||||
other.dirty_ = true;
|
||||
other.total_size_ = 0;
|
||||
version_ = other.version_;
|
||||
range_cache_ = {};
|
||||
find_cache_ = {};
|
||||
return *this;
|
||||
}
|
||||
|
||||
@@ -79,6 +109,21 @@ PieceTable::Reserve(const std::size_t newCapacity)
|
||||
}
|
||||
|
||||
|
||||
// Setter to allow tuning consolidation heuristics
|
||||
void
|
||||
PieceTable::SetConsolidationParams(const std::size_t piece_limit,
|
||||
const std::size_t small_piece_threshold,
|
||||
const std::size_t max_consolidation_bytes)
|
||||
{
|
||||
piece_limit_ = piece_limit;
|
||||
small_piece_threshold_ = small_piece_threshold;
|
||||
max_consolidation_bytes_ = max_consolidation_bytes;
|
||||
}
|
||||
|
||||
|
||||
// (removed helper) — we'll invalidate caches inline inside mutating methods
|
||||
|
||||
|
||||
void
|
||||
PieceTable::AppendChar(char c)
|
||||
{
|
||||
@@ -151,6 +196,11 @@ PieceTable::Clear()
|
||||
materialized_.clear();
|
||||
total_size_ = 0;
|
||||
dirty_ = true;
|
||||
line_index_.clear();
|
||||
line_index_dirty_ = true;
|
||||
version_++;
|
||||
range_cache_ = {};
|
||||
find_cache_ = {};
|
||||
}
|
||||
|
||||
|
||||
@@ -168,9 +218,12 @@ PieceTable::addPieceBack(const Source src, const std::size_t start, const std::s
|
||||
std::size_t expectStart = last.start + last.len;
|
||||
|
||||
if (expectStart == start) {
|
||||
last.len += len;
|
||||
last.len += len;
|
||||
total_size_ += len;
|
||||
dirty_ = true;
|
||||
dirty_ = true;
|
||||
version_++;
|
||||
range_cache_ = {};
|
||||
find_cache_ = {};
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -178,7 +231,11 @@ PieceTable::addPieceBack(const Source src, const std::size_t start, const std::s
|
||||
|
||||
pieces_.push_back(Piece{src, start, len});
|
||||
total_size_ += len;
|
||||
dirty_ = true;
|
||||
dirty_ = true;
|
||||
InvalidateLineIndex();
|
||||
version_++;
|
||||
range_cache_ = {};
|
||||
find_cache_ = {};
|
||||
}
|
||||
|
||||
|
||||
@@ -194,21 +251,29 @@ PieceTable::addPieceFront(Source src, std::size_t start, std::size_t len)
|
||||
Piece &first = pieces_.front();
|
||||
if (first.src == src && start + len == first.start) {
|
||||
first.start = start;
|
||||
first.len += len;
|
||||
first.len += len;
|
||||
total_size_ += len;
|
||||
dirty_ = true;
|
||||
dirty_ = true;
|
||||
version_++;
|
||||
range_cache_ = {};
|
||||
find_cache_ = {};
|
||||
return;
|
||||
}
|
||||
}
|
||||
pieces_.insert(pieces_.begin(), Piece{src, start, len});
|
||||
total_size_ += len;
|
||||
dirty_ = true;
|
||||
dirty_ = true;
|
||||
InvalidateLineIndex();
|
||||
version_++;
|
||||
range_cache_ = {};
|
||||
find_cache_ = {};
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
PieceTable::materialize() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!dirty_) {
|
||||
return;
|
||||
}
|
||||
@@ -225,3 +290,511 @@ PieceTable::materialize() const
|
||||
// Ensure there is a null terminator present via std::string invariants
|
||||
dirty_ = false;
|
||||
}
|
||||
|
||||
|
||||
// ===== New Phase 1 implementation =====
|
||||
|
||||
std::pair<std::size_t, std::size_t>
|
||||
PieceTable::locate(const std::size_t byte_offset) const
|
||||
{
|
||||
if (byte_offset >= total_size_) {
|
||||
return {pieces_.size(), 0};
|
||||
}
|
||||
std::size_t off = byte_offset;
|
||||
for (std::size_t i = 0; i < pieces_.size(); ++i) {
|
||||
const auto &p = pieces_[i];
|
||||
if (off < p.len) {
|
||||
return {i, off};
|
||||
}
|
||||
off -= p.len;
|
||||
}
|
||||
// Should not reach here unless inconsistency; return end
|
||||
return {pieces_.size(), 0};
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
PieceTable::coalesceNeighbors(std::size_t index)
|
||||
{
|
||||
if (pieces_.empty())
|
||||
return;
|
||||
if (index >= pieces_.size())
|
||||
index = pieces_.size() - 1;
|
||||
// Merge repeatedly with previous while contiguous and same source
|
||||
while (index > 0) {
|
||||
auto &prev = pieces_[index - 1];
|
||||
auto &curr = pieces_[index];
|
||||
if (prev.src == curr.src && prev.start + prev.len == curr.start) {
|
||||
prev.len += curr.len;
|
||||
pieces_.erase(pieces_.begin() + static_cast<std::ptrdiff_t>(index));
|
||||
index -= 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Merge repeatedly with next while contiguous and same source
|
||||
while (index + 1 < pieces_.size()) {
|
||||
auto &curr = pieces_[index];
|
||||
auto &next = pieces_[index + 1];
|
||||
if (curr.src == next.src && curr.start + curr.len == next.start) {
|
||||
curr.len += next.len;
|
||||
pieces_.erase(pieces_.begin() + static_cast<std::ptrdiff_t>(index + 1));
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
PieceTable::InvalidateLineIndex() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
line_index_dirty_ = true;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
PieceTable::RebuildLineIndex() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
|
||||
if (!line_index_dirty_) {
|
||||
return;
|
||||
}
|
||||
line_index_.clear();
|
||||
line_index_.push_back(0);
|
||||
|
||||
std::size_t pos = 0;
|
||||
for (const auto &pc: pieces_) {
|
||||
const std::string &src = pc.src == Source::Original ? original_ : add_;
|
||||
const char *base = src.data() + static_cast<std::ptrdiff_t>(pc.start);
|
||||
|
||||
for (std::size_t j = 0; j < pc.len; ++j) {
|
||||
if (base[j] == '\n') {
|
||||
// next line starts after the newline
|
||||
line_index_.push_back(pos + j + 1);
|
||||
}
|
||||
}
|
||||
|
||||
pos += pc.len;
|
||||
}
|
||||
|
||||
line_index_dirty_ = false;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
PieceTable::Insert(std::size_t byte_offset, const char *text, std::size_t len)
|
||||
{
|
||||
if (len == 0) {
|
||||
return;
|
||||
}
|
||||
if (byte_offset > total_size_) {
|
||||
byte_offset = total_size_;
|
||||
}
|
||||
|
||||
const std::size_t add_start = add_.size();
|
||||
add_.append(text, len);
|
||||
|
||||
if (pieces_.empty()) {
|
||||
pieces_.push_back(Piece{Source::Add, add_start, len});
|
||||
total_size_ += len;
|
||||
dirty_ = true;
|
||||
InvalidateLineIndex();
|
||||
maybeConsolidate();
|
||||
version_++;
|
||||
range_cache_ = {};
|
||||
find_cache_ = {};
|
||||
return;
|
||||
}
|
||||
|
||||
auto [idx, inner] = locate(byte_offset);
|
||||
if (idx == pieces_.size()) {
|
||||
// insert at end
|
||||
pieces_.push_back(Piece{Source::Add, add_start, len});
|
||||
total_size_ += len;
|
||||
dirty_ = true;
|
||||
InvalidateLineIndex();
|
||||
coalesceNeighbors(pieces_.size() - 1);
|
||||
maybeConsolidate();
|
||||
version_++;
|
||||
range_cache_ = {};
|
||||
find_cache_ = {};
|
||||
return;
|
||||
}
|
||||
|
||||
Piece target = pieces_[idx];
|
||||
// Build replacement sequence: left, inserted, right
|
||||
std::vector<Piece> repl;
|
||||
repl.reserve(3);
|
||||
if (inner > 0) {
|
||||
repl.push_back(Piece{target.src, target.start, inner});
|
||||
}
|
||||
repl.push_back(Piece{Source::Add, add_start, len});
|
||||
const std::size_t right_len = target.len - inner;
|
||||
if (right_len > 0) {
|
||||
repl.push_back(Piece{target.src, target.start + inner, right_len});
|
||||
}
|
||||
|
||||
// Replace target with repl
|
||||
pieces_.erase(pieces_.begin() + static_cast<std::ptrdiff_t>(idx));
|
||||
pieces_.insert(pieces_.begin() + static_cast<std::ptrdiff_t>(idx), repl.begin(), repl.end());
|
||||
|
||||
total_size_ += len;
|
||||
dirty_ = true;
|
||||
InvalidateLineIndex();
|
||||
// Try coalescing around the inserted position (the inserted piece is at idx + (inner>0 ? 1 : 0))
|
||||
std::size_t ins_index = idx + (inner > 0 ? 1 : 0);
|
||||
coalesceNeighbors(ins_index);
|
||||
maybeConsolidate();
|
||||
version_++;
|
||||
range_cache_ = {};
|
||||
find_cache_ = {};
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
PieceTable::Delete(std::size_t byte_offset, std::size_t len)
|
||||
{
|
||||
if (len == 0) {
|
||||
return;
|
||||
}
|
||||
if (byte_offset >= total_size_) {
|
||||
return;
|
||||
}
|
||||
if (byte_offset + len > total_size_) {
|
||||
len = total_size_ - byte_offset;
|
||||
}
|
||||
|
||||
auto [idx, inner] = locate(byte_offset);
|
||||
std::size_t remaining = len;
|
||||
|
||||
while (remaining > 0 && idx < pieces_.size()) {
|
||||
Piece &pc = pieces_[idx];
|
||||
std::size_t available = pc.len - inner; // bytes we can remove from this piece starting at inner
|
||||
std::size_t take = std::min(available, remaining);
|
||||
|
||||
// Compute lengths for left and right remnants
|
||||
std::size_t left_len = inner;
|
||||
std::size_t right_len = pc.len - inner - take;
|
||||
Source src = pc.src;
|
||||
std::size_t start = pc.start;
|
||||
|
||||
// Replace current piece with up to two remnants
|
||||
if (left_len > 0 && right_len > 0) {
|
||||
pc.len = left_len; // keep left in place
|
||||
Piece right{src, start + inner + take, right_len};
|
||||
pieces_.insert(pieces_.begin() + static_cast<std::ptrdiff_t>(idx + 1), right);
|
||||
idx += 1; // move to right for next iteration decision
|
||||
} else if (left_len > 0) {
|
||||
pc.len = left_len;
|
||||
// no insertion; idx now points to left; move to next piece
|
||||
} else if (right_len > 0) {
|
||||
pc.start = start + inner + take;
|
||||
pc.len = right_len;
|
||||
} else {
|
||||
// entire piece removed
|
||||
pieces_.erase(pieces_.begin() + static_cast<std::ptrdiff_t>(idx));
|
||||
// stay at same idx for next piece
|
||||
inner = 0;
|
||||
remaining -= take;
|
||||
continue;
|
||||
}
|
||||
|
||||
// After modifying current idx, next deletion continues at beginning of the next logical region
|
||||
inner = 0;
|
||||
remaining -= take;
|
||||
if (remaining == 0)
|
||||
break;
|
||||
// Move to next piece
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
total_size_ -= len;
|
||||
dirty_ = true;
|
||||
InvalidateLineIndex();
|
||||
if (idx < pieces_.size())
|
||||
coalesceNeighbors(idx);
|
||||
if (idx > 0)
|
||||
coalesceNeighbors(idx - 1);
|
||||
maybeConsolidate();
|
||||
version_++;
|
||||
range_cache_ = {};
|
||||
find_cache_ = {};
|
||||
}
|
||||
|
||||
|
||||
// ===== Consolidation implementation =====
|
||||
|
||||
void
|
||||
PieceTable::appendPieceDataTo(std::string &out, const Piece &p) const
|
||||
{
|
||||
if (p.len == 0)
|
||||
return;
|
||||
const std::string &src = p.src == Source::Original ? original_ : add_;
|
||||
out.append(src.data() + static_cast<std::ptrdiff_t>(p.start), p.len);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
PieceTable::consolidateRange(std::size_t start_idx, std::size_t end_idx)
|
||||
{
|
||||
if (start_idx >= end_idx || start_idx >= pieces_.size())
|
||||
return;
|
||||
end_idx = std::min(end_idx, pieces_.size());
|
||||
std::size_t total = 0;
|
||||
for (std::size_t i = start_idx; i < end_idx; ++i)
|
||||
total += pieces_[i].len;
|
||||
if (total == 0)
|
||||
return;
|
||||
|
||||
const std::size_t add_start = add_.size();
|
||||
std::string tmp;
|
||||
tmp.reserve(std::min<std::size_t>(total, max_consolidation_bytes_));
|
||||
for (std::size_t i = start_idx; i < end_idx; ++i)
|
||||
appendPieceDataTo(tmp, pieces_[i]);
|
||||
add_.append(tmp);
|
||||
|
||||
// Replace [start_idx, end_idx) with single Add piece
|
||||
Piece consolidated{Source::Add, add_start, tmp.size()};
|
||||
pieces_.erase(pieces_.begin() + static_cast<std::ptrdiff_t>(start_idx),
|
||||
pieces_.begin() + static_cast<std::ptrdiff_t>(end_idx));
|
||||
pieces_.insert(pieces_.begin() + static_cast<std::ptrdiff_t>(start_idx), consolidated);
|
||||
|
||||
// total_size_ unchanged
|
||||
dirty_ = true;
|
||||
InvalidateLineIndex();
|
||||
coalesceNeighbors(start_idx);
|
||||
// Layout changed; invalidate caches/version
|
||||
version_++;
|
||||
range_cache_ = {};
|
||||
find_cache_ = {};
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
PieceTable::maybeConsolidate()
|
||||
{
|
||||
if (pieces_.size() <= piece_limit_)
|
||||
return;
|
||||
|
||||
// Find the first run of small pieces to consolidate
|
||||
std::size_t n = pieces_.size();
|
||||
std::size_t best_start = n, best_end = n;
|
||||
std::size_t i = 0;
|
||||
while (i < n) {
|
||||
// Skip large pieces quickly
|
||||
if (pieces_[i].len > small_piece_threshold_) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
std::size_t j = i;
|
||||
std::size_t bytes = 0;
|
||||
while (j < n) {
|
||||
const auto &p = pieces_[j];
|
||||
if (p.len > small_piece_threshold_)
|
||||
break;
|
||||
if (bytes + p.len > max_consolidation_bytes_)
|
||||
break;
|
||||
bytes += p.len;
|
||||
j++;
|
||||
}
|
||||
if (j - i >= 2 && bytes > 0) {
|
||||
// consolidate runs of at least 2 pieces
|
||||
best_start = i;
|
||||
best_end = j;
|
||||
break; // do one run per call; subsequent ops can repeat if still over limit
|
||||
}
|
||||
i = j + 1;
|
||||
}
|
||||
|
||||
if (best_start < best_end) {
|
||||
consolidateRange(best_start, best_end);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
std::size_t
|
||||
PieceTable::LineCount() const
|
||||
{
|
||||
RebuildLineIndex();
|
||||
return line_index_.empty() ? 0 : line_index_.size();
|
||||
}
|
||||
|
||||
|
||||
std::pair<std::size_t, std::size_t>
|
||||
PieceTable::GetLineRange(std::size_t line_num) const
|
||||
{
|
||||
RebuildLineIndex();
|
||||
if (line_index_.empty())
|
||||
return {0, 0};
|
||||
if (line_num >= line_index_.size())
|
||||
return {0, 0};
|
||||
std::size_t start = line_index_[line_num];
|
||||
std::size_t end = (line_num + 1 < line_index_.size()) ? line_index_[line_num + 1] : total_size_;
|
||||
return {start, end};
|
||||
}
|
||||
|
||||
|
||||
std::string
|
||||
PieceTable::GetLine(std::size_t line_num) const
|
||||
{
|
||||
auto [start, end] = GetLineRange(line_num);
|
||||
if (end < start)
|
||||
return std::string();
|
||||
// Trim trailing '\n'
|
||||
if (end > start) {
|
||||
// To check last char, we can get it via GetRange of len 1 at end-1 without materializing whole
|
||||
std::string last = GetRange(end - 1, 1);
|
||||
if (!last.empty() && last[0] == '\n') {
|
||||
end -= 1;
|
||||
}
|
||||
}
|
||||
return GetRange(start, end - start);
|
||||
}
|
||||
|
||||
|
||||
std::pair<std::size_t, std::size_t>
|
||||
PieceTable::ByteOffsetToLineCol(std::size_t byte_offset) const
|
||||
{
|
||||
if (byte_offset > total_size_)
|
||||
byte_offset = total_size_;
|
||||
RebuildLineIndex();
|
||||
if (line_index_.empty())
|
||||
return {0, 0};
|
||||
auto it = std::upper_bound(line_index_.begin(), line_index_.end(), byte_offset);
|
||||
std::size_t row = (it == line_index_.begin()) ? 0 : static_cast<std::size_t>((it - line_index_.begin()) - 1);
|
||||
std::size_t col = byte_offset - line_index_[row];
|
||||
return {row, col};
|
||||
}
|
||||
|
||||
|
||||
std::size_t
|
||||
PieceTable::LineColToByteOffset(std::size_t row, std::size_t col) const
|
||||
{
|
||||
RebuildLineIndex();
|
||||
if (line_index_.empty())
|
||||
return 0;
|
||||
if (row >= line_index_.size())
|
||||
return total_size_;
|
||||
std::size_t start = line_index_[row];
|
||||
std::size_t end = (row + 1 < line_index_.size()) ? line_index_[row + 1] : total_size_;
|
||||
// Clamp col to line length excluding trailing newline
|
||||
if (end > start) {
|
||||
std::string last = GetRange(end - 1, 1);
|
||||
if (!last.empty() && last[0] == '\n') {
|
||||
end -= 1;
|
||||
}
|
||||
}
|
||||
std::size_t target = start + std::min(col, end - start);
|
||||
return target;
|
||||
}
|
||||
|
||||
|
||||
std::string
|
||||
PieceTable::GetRange(std::size_t byte_offset, std::size_t len) const
|
||||
{
|
||||
if (byte_offset >= total_size_ || len == 0)
|
||||
return std::string();
|
||||
if (byte_offset + len > total_size_)
|
||||
len = total_size_ - byte_offset;
|
||||
|
||||
// Fast path: return cached value if version/offset/len match
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (range_cache_.valid && range_cache_.version == version_ &&
|
||||
range_cache_.off == byte_offset && range_cache_.len == len) {
|
||||
return range_cache_.data;
|
||||
}
|
||||
}
|
||||
|
||||
std::string out;
|
||||
out.reserve(len);
|
||||
if (!dirty_) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
// Already materialized; slice directly
|
||||
out.assign(materialized_.data() + static_cast<std::ptrdiff_t>(byte_offset), len);
|
||||
} else {
|
||||
// Assemble substring directly from pieces without full materialization
|
||||
auto [idx, inner] = locate(byte_offset);
|
||||
std::size_t remaining = len;
|
||||
while (remaining > 0 && idx < pieces_.size()) {
|
||||
const auto &p = pieces_[idx];
|
||||
const std::string &src = (p.src == Source::Original) ? original_ : add_;
|
||||
std::size_t take = std::min<std::size_t>(p.len - inner, remaining);
|
||||
if (take > 0) {
|
||||
const char *base = src.data() + static_cast<std::ptrdiff_t>(p.start + inner);
|
||||
out.append(base, take);
|
||||
remaining -= take;
|
||||
inner = 0;
|
||||
idx += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update cache
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
range_cache_.valid = true;
|
||||
range_cache_.version = version_;
|
||||
range_cache_.off = byte_offset;
|
||||
range_cache_.len = len;
|
||||
range_cache_.data = out;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
std::size_t
|
||||
PieceTable::Find(const std::string &needle, std::size_t start) const
|
||||
{
|
||||
if (needle.empty())
|
||||
return start <= total_size_ ? start : std::numeric_limits<std::size_t>::max();
|
||||
if (start > total_size_)
|
||||
return std::numeric_limits<std::size_t>::max();
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (find_cache_.valid &&
|
||||
find_cache_.version == version_ &&
|
||||
find_cache_.needle == needle &&
|
||||
find_cache_.start == start) {
|
||||
return find_cache_.result;
|
||||
}
|
||||
}
|
||||
|
||||
materialize();
|
||||
std::size_t pos;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
pos = materialized_.find(needle, start);
|
||||
if (pos == std::string::npos)
|
||||
pos = std::numeric_limits<std::size_t>::max();
|
||||
// Update cache
|
||||
find_cache_.valid = true;
|
||||
find_cache_.version = version_;
|
||||
find_cache_.needle = needle;
|
||||
find_cache_.start = start;
|
||||
find_cache_.result = pos;
|
||||
}
|
||||
return pos;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
PieceTable::WriteToStream(std::ostream &out) const
|
||||
{
|
||||
// Stream the content piece-by-piece without forcing full materialization
|
||||
// No lock needed for original_ and add_ if they are not being modified.
|
||||
// Since this is a const method and kte's piece table isn't modified by multiple threads
|
||||
// (only queried), we just iterate pieces_.
|
||||
for (const auto &p: pieces_) {
|
||||
if (p.len == 0)
|
||||
continue;
|
||||
const std::string &src = (p.src == Source::Original) ? original_ : add_;
|
||||
const char *base = src.data() + static_cast<std::ptrdiff_t>(p.start);
|
||||
out.write(base, static_cast<std::streamsize>(p.len));
|
||||
}
|
||||
}
|
||||
|
||||
+129
-2
@@ -1,10 +1,48 @@
|
||||
/*
|
||||
* 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
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <ostream>
|
||||
#include <vector>
|
||||
#include <limits>
|
||||
#include <mutex>
|
||||
|
||||
|
||||
class PieceTable {
|
||||
@@ -13,6 +51,12 @@ public:
|
||||
|
||||
explicit PieceTable(std::size_t initialCapacity);
|
||||
|
||||
// Advanced constructor allowing configuration of consolidation heuristics
|
||||
PieceTable(std::size_t initialCapacity,
|
||||
std::size_t piece_limit,
|
||||
std::size_t small_piece_threshold,
|
||||
std::size_t max_consolidation_bytes);
|
||||
|
||||
PieceTable(const PieceTable &other);
|
||||
|
||||
PieceTable &operator=(const PieceTable &other);
|
||||
@@ -68,6 +112,38 @@ public:
|
||||
return materialized_.capacity();
|
||||
}
|
||||
|
||||
|
||||
// ===== New buffer-wide API (Phase 1) =====
|
||||
// Byte-based editing operations
|
||||
void Insert(std::size_t byte_offset, const char *text, std::size_t len);
|
||||
|
||||
void Delete(std::size_t byte_offset, std::size_t len);
|
||||
|
||||
// Line-based queries
|
||||
[[nodiscard]] std::size_t LineCount() const; // number of logical lines
|
||||
[[nodiscard]] std::string GetLine(std::size_t line_num) const;
|
||||
|
||||
[[nodiscard]] std::pair<std::size_t, std::size_t> GetLineRange(std::size_t line_num) const; // [start,end)
|
||||
|
||||
// Position conversion
|
||||
[[nodiscard]] std::pair<std::size_t, std::size_t> ByteOffsetToLineCol(std::size_t byte_offset) const;
|
||||
|
||||
[[nodiscard]] std::size_t LineColToByteOffset(std::size_t row, std::size_t col) const;
|
||||
|
||||
// Substring extraction
|
||||
[[nodiscard]] std::string GetRange(std::size_t byte_offset, std::size_t len) const;
|
||||
|
||||
// Simple search utility; returns byte offset or npos
|
||||
[[nodiscard]] std::size_t Find(const std::string &needle, std::size_t start = 0) const;
|
||||
|
||||
// Stream out content without materializing the entire buffer
|
||||
void WriteToStream(std::ostream &out) const;
|
||||
|
||||
// Heuristic configuration
|
||||
void SetConsolidationParams(std::size_t piece_limit,
|
||||
std::size_t small_piece_threshold,
|
||||
std::size_t max_consolidation_bytes);
|
||||
|
||||
private:
|
||||
enum class Source : unsigned char { Original, Add };
|
||||
|
||||
@@ -83,12 +159,63 @@ private:
|
||||
|
||||
void materialize() const;
|
||||
|
||||
// Helper: locate piece index and inner offset for a global byte offset
|
||||
[[nodiscard]] std::pair<std::size_t, std::size_t> locate(std::size_t byte_offset) const;
|
||||
|
||||
// Helper: try to coalesce neighboring pieces around index
|
||||
void coalesceNeighbors(std::size_t index);
|
||||
|
||||
// Consolidation helpers and heuristics
|
||||
void maybeConsolidate();
|
||||
|
||||
void consolidateRange(std::size_t start_idx, std::size_t end_idx);
|
||||
|
||||
void appendPieceDataTo(std::string &out, const Piece &p) const;
|
||||
|
||||
// Line index support (rebuilt lazily on demand)
|
||||
void InvalidateLineIndex() const;
|
||||
|
||||
void RebuildLineIndex() const;
|
||||
|
||||
// Underlying storages
|
||||
std::string original_; // unused for builder use-case, but kept for API symmetry
|
||||
std::string add_;
|
||||
std::vector<Piece> pieces_;
|
||||
|
||||
mutable std::string materialized_;
|
||||
mutable bool dirty_ = true;
|
||||
std::size_t total_size_ = 0;
|
||||
mutable bool dirty_ = true;
|
||||
// Monotonic content version. Increment on any mutation that affects content layout
|
||||
mutable std::uint64_t version_ = 0;
|
||||
std::size_t total_size_ = 0;
|
||||
|
||||
// Cached line index: starting byte offset of each line (always contains at least 1 entry: 0)
|
||||
mutable std::vector<std::size_t> line_index_;
|
||||
mutable bool line_index_dirty_ = true;
|
||||
|
||||
// Heuristic knobs
|
||||
std::size_t piece_limit_ = 4096; // trigger consolidation when exceeded
|
||||
std::size_t small_piece_threshold_ = 64; // bytes
|
||||
std::size_t max_consolidation_bytes_ = 4096; // cap per consolidation run
|
||||
|
||||
// Lightweight caches to avoid redundant work when callers query the same range repeatedly
|
||||
struct RangeCache {
|
||||
bool valid = false;
|
||||
std::uint64_t version = 0;
|
||||
std::size_t off = 0;
|
||||
std::size_t len = 0;
|
||||
std::string data;
|
||||
};
|
||||
|
||||
struct FindCache {
|
||||
bool valid = false;
|
||||
std::uint64_t version = 0;
|
||||
std::string needle;
|
||||
std::size_t start = 0;
|
||||
std::size_t result = std::numeric_limits<std::size_t>::max();
|
||||
};
|
||||
|
||||
mutable RangeCache range_cache_;
|
||||
mutable FindCache find_cache_;
|
||||
|
||||
mutable std::mutex mutex_;
|
||||
};
|
||||
+983
@@ -0,0 +1,983 @@
|
||||
#include "QtFrontend.h"
|
||||
|
||||
#include <QApplication>
|
||||
#include <QWidget>
|
||||
#include <QKeyEvent>
|
||||
#include <QTimer>
|
||||
#include <QScreen>
|
||||
#include <QFont>
|
||||
#include <QFontMetrics>
|
||||
#include <QFontDatabase>
|
||||
#include <QFileDialog>
|
||||
#include <QFontDialog>
|
||||
#include <QPainter>
|
||||
#include <QPaintEvent>
|
||||
#include <QWheelEvent>
|
||||
#include <regex>
|
||||
|
||||
#include "Editor.h"
|
||||
#include "Command.h"
|
||||
#include "Buffer.h"
|
||||
#include "GUITheme.h"
|
||||
#include "Highlight.h"
|
||||
|
||||
namespace {
|
||||
class MainWindow : public QWidget {
|
||||
public:
|
||||
explicit MainWindow(class QtInputHandler &ih, QWidget *parent = nullptr)
|
||||
: QWidget(parent), input_(ih)
|
||||
{
|
||||
// Match ImGui window title format
|
||||
setWindowTitle(QStringLiteral("kge - kyle's graphical editor ")
|
||||
+ QStringLiteral(KTE_VERSION_STR));
|
||||
resize(1280, 800);
|
||||
setFocusPolicy(Qt::StrongFocus);
|
||||
}
|
||||
|
||||
|
||||
bool WasClosed() const
|
||||
{
|
||||
return closed_;
|
||||
}
|
||||
|
||||
|
||||
void SetEditor(Editor *ed)
|
||||
{
|
||||
ed_ = ed;
|
||||
}
|
||||
|
||||
|
||||
void SetFontFamilyAndSize(QString family, int px)
|
||||
{
|
||||
if (family.isEmpty())
|
||||
family = QStringLiteral("Brass Mono");
|
||||
if (px <= 0)
|
||||
px = 18;
|
||||
font_family_ = std::move(family);
|
||||
font_px_ = px;
|
||||
update();
|
||||
}
|
||||
|
||||
protected:
|
||||
void keyPressEvent(QKeyEvent *event) override
|
||||
{
|
||||
// Route to editor keymap; if handled, accept and stop propagation so
|
||||
// Qt doesn't trigger any default widget shortcuts.
|
||||
if (input_.ProcessKeyEvent(*event)) {
|
||||
event->accept();
|
||||
return;
|
||||
}
|
||||
QWidget::keyPressEvent(event);
|
||||
}
|
||||
|
||||
|
||||
void paintEvent(QPaintEvent *event) override
|
||||
{
|
||||
Q_UNUSED(event);
|
||||
QPainter p(this);
|
||||
p.setRenderHint(QPainter::TextAntialiasing, true);
|
||||
|
||||
// Colors from GUITheme palette (Qt branch)
|
||||
auto to_qcolor = [](const KteColor &c) -> QColor {
|
||||
int r = int(std::round(c.x * 255.0f));
|
||||
int g = int(std::round(c.y * 255.0f));
|
||||
int b = int(std::round(c.z * 255.0f));
|
||||
int a = int(std::round(c.w * 255.0f));
|
||||
return QColor(r, g, b, a);
|
||||
};
|
||||
const auto pal = kte::GetPalette();
|
||||
const QColor bg = to_qcolor(pal.bg);
|
||||
const QColor fg = to_qcolor(pal.fg);
|
||||
const QColor sel_bg = to_qcolor(pal.sel_bg);
|
||||
const QColor cur_bg = to_qcolor(pal.cur_bg);
|
||||
const QColor status_bg = to_qcolor(pal.status_bg);
|
||||
const QColor status_fg = to_qcolor(pal.status_fg);
|
||||
|
||||
// Background
|
||||
p.fillRect(rect(), bg);
|
||||
|
||||
// Font/metrics (configured or defaults)
|
||||
QFont f(font_family_, font_px_);
|
||||
p.setFont(f);
|
||||
QFontMetrics fm(f);
|
||||
const int line_h = fm.height();
|
||||
const int ch_w = std::max(1, fm.horizontalAdvance(QStringLiteral(" ")));
|
||||
|
||||
// Layout metrics
|
||||
const int pad_l = 8;
|
||||
const int pad_t = 6;
|
||||
const int pad_r = 8;
|
||||
const int pad_b = 6;
|
||||
const int status_h = line_h + 6; // status bar height
|
||||
|
||||
// Content area (text viewport)
|
||||
const QRect content_rect(pad_l,
|
||||
pad_t,
|
||||
width() - pad_l - pad_r,
|
||||
height() - pad_t - pad_b - status_h);
|
||||
|
||||
// Text viewport occupies all content area (no extra title row)
|
||||
QRect viewport(content_rect.x(), content_rect.y(), content_rect.width(), content_rect.height());
|
||||
|
||||
// Draw buffer contents
|
||||
if (ed_ && viewport.height() > 0 && viewport.width() > 0) {
|
||||
const Buffer *buf = ed_->CurrentBuffer();
|
||||
if (buf) {
|
||||
const std::size_t nrows = buf->Nrows();
|
||||
const std::size_t rowoffs = buf->Rowoffs();
|
||||
const std::size_t coloffs = buf->Coloffs();
|
||||
const std::size_t cy = buf->Cury();
|
||||
const std::size_t cx = buf->Curx();
|
||||
|
||||
// Visible line count
|
||||
const int max_lines = (line_h > 0) ? (viewport.height() / line_h) : 0;
|
||||
const std::size_t last_row = std::min<std::size_t>(
|
||||
nrows, rowoffs + std::max(0, max_lines));
|
||||
|
||||
// Tab width: follow ImGuiRenderer default of 4
|
||||
const std::size_t tabw = 4;
|
||||
|
||||
// Prepare painter clip to viewport
|
||||
p.save();
|
||||
p.setClipRect(viewport);
|
||||
|
||||
// Iterate visible lines
|
||||
for (std::size_t i = rowoffs, vis_idx = 0; i < last_row; ++i, ++vis_idx) {
|
||||
// Get line as string for regex/iterator usage and general string ops.
|
||||
const std::string line = buf->GetLineString(i);
|
||||
const int y = viewport.y() + static_cast<int>(vis_idx) * line_h;
|
||||
const int baseline = y + fm.ascent();
|
||||
|
||||
// Helper: convert src col -> rx with tab expansion
|
||||
auto src_to_rx_line = [&](std::size_t src_col) -> std::size_t {
|
||||
std::size_t rx = 0;
|
||||
for (std::size_t k = 0; k < src_col && k < line.size(); ++k) {
|
||||
rx += (line[k] == '\t') ? (tabw - (rx % tabw)) : 1;
|
||||
}
|
||||
return rx;
|
||||
};
|
||||
|
||||
// Search-match background highlights first (under text)
|
||||
if (ed_->SearchActive() && !ed_->SearchQuery().empty()) {
|
||||
std::vector<std::pair<std::size_t, std::size_t> > hl_src_ranges;
|
||||
// Compute ranges per line (source indices)
|
||||
if (ed_->PromptActive() &&
|
||||
(ed_->CurrentPromptKind() == Editor::PromptKind::RegexSearch ||
|
||||
ed_->CurrentPromptKind() ==
|
||||
Editor::PromptKind::RegexReplaceFind)) {
|
||||
try {
|
||||
std::regex rx(ed_->SearchQuery());
|
||||
for (auto it = std::sregex_iterator(
|
||||
line.begin(), line.end(), rx);
|
||||
it != std::sregex_iterator(); ++it) {
|
||||
const auto &m = *it;
|
||||
std::size_t sx = static_cast<std::size_t>(m.
|
||||
position());
|
||||
std::size_t ex =
|
||||
sx + static_cast<std::size_t>(m.
|
||||
length());
|
||||
hl_src_ranges.emplace_back(sx, ex);
|
||||
}
|
||||
} catch (const std::regex_error &) {
|
||||
// Invalid regex: ignore, status line already shows errors
|
||||
}
|
||||
} else {
|
||||
const std::string &q = ed_->SearchQuery();
|
||||
if (!q.empty()) {
|
||||
std::size_t pos = 0;
|
||||
while ((pos = line.find(q, pos)) != std::string::npos) {
|
||||
hl_src_ranges.emplace_back(pos, pos + q.size());
|
||||
pos += q.size();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hl_src_ranges.empty()) {
|
||||
const bool has_current =
|
||||
ed_->SearchMatchLen() > 0 && ed_->SearchMatchY() == i;
|
||||
const std::size_t cur_x = has_current ? ed_->SearchMatchX() : 0;
|
||||
const std::size_t cur_end = has_current
|
||||
? (ed_->SearchMatchX() + ed_->SearchMatchLen())
|
||||
: 0;
|
||||
for (const auto &rg: hl_src_ranges) {
|
||||
std::size_t sx = rg.first, ex = rg.second;
|
||||
std::size_t rx_s = src_to_rx_line(sx);
|
||||
std::size_t rx_e = src_to_rx_line(ex);
|
||||
if (rx_e <= coloffs)
|
||||
continue; // fully left of view
|
||||
int vx0 = viewport.x() + static_cast<int>((
|
||||
(rx_s > coloffs ? rx_s - coloffs : 0)
|
||||
* ch_w));
|
||||
int vx1 = viewport.x() + static_cast<int>((
|
||||
(rx_e - coloffs) * ch_w));
|
||||
QRect r(vx0, y, std::max(0, vx1 - vx0), line_h);
|
||||
if (r.width() <= 0)
|
||||
continue;
|
||||
bool is_current =
|
||||
has_current && sx == cur_x && ex == cur_end;
|
||||
QColor col = is_current
|
||||
? QColor(255, 220, 120, 140)
|
||||
: QColor(200, 200, 0, 90);
|
||||
p.fillRect(r, col);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Selection background (if active on this line)
|
||||
if (buf->MarkSet() && (
|
||||
i == buf->MarkCury() || i == cy || (
|
||||
i > std::min(buf->MarkCury(), cy) && i < std::max(
|
||||
buf->MarkCury(), cy)))) {
|
||||
std::size_t sx = 0, ex = 0;
|
||||
if (buf->MarkCury() == i && cy == i) {
|
||||
sx = std::min(buf->MarkCurx(), cx);
|
||||
ex = std::max(buf->MarkCurx(), cx);
|
||||
} else if (i == buf->MarkCury()) {
|
||||
sx = buf->MarkCurx();
|
||||
ex = line.size();
|
||||
} else if (i == cy) {
|
||||
sx = 0;
|
||||
ex = cx;
|
||||
} else {
|
||||
sx = 0;
|
||||
ex = line.size();
|
||||
}
|
||||
std::size_t rx_s = src_to_rx_line(sx);
|
||||
std::size_t rx_e = src_to_rx_line(ex);
|
||||
if (rx_e > coloffs) {
|
||||
int vx0 = viewport.x() + static_cast<int>((rx_s > coloffs
|
||||
? rx_s - coloffs
|
||||
: 0) * ch_w);
|
||||
int vx1 = viewport.x() + static_cast<int>(
|
||||
(rx_e - coloffs) * ch_w);
|
||||
QRect sel_r(vx0, y, std::max(0, vx1 - vx0), line_h);
|
||||
if (sel_r.width() > 0)
|
||||
p.fillRect(sel_r, sel_bg);
|
||||
}
|
||||
}
|
||||
|
||||
// Build expanded line (tabs -> spaces) for drawing
|
||||
std::string expanded;
|
||||
expanded.reserve(line.size() + 8);
|
||||
std::size_t rx_acc = 0;
|
||||
for (char c: line) {
|
||||
if (c == '\t') {
|
||||
std::size_t adv = (tabw - (rx_acc % tabw));
|
||||
expanded.append(adv, ' ');
|
||||
rx_acc += adv;
|
||||
} else {
|
||||
expanded.push_back(c);
|
||||
rx_acc += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Syntax highlighting spans or plain text
|
||||
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->
|
||||
HasHighlighter()) {
|
||||
kte::LineHighlight lh = buf->Highlighter()->GetLine(
|
||||
*buf, static_cast<int>(i), buf->Version());
|
||||
struct SSpan {
|
||||
std::size_t s;
|
||||
std::size_t e;
|
||||
kte::TokenKind k;
|
||||
};
|
||||
std::vector<SSpan> spans;
|
||||
spans.reserve(lh.spans.size());
|
||||
const std::size_t line_len = line.size();
|
||||
for (const auto &sp: lh.spans) {
|
||||
int s_raw = sp.col_start;
|
||||
int e_raw = sp.col_end;
|
||||
if (e_raw < s_raw)
|
||||
std::swap(e_raw, s_raw);
|
||||
std::size_t s = static_cast<std::size_t>(std::max(
|
||||
0, std::min(s_raw, (int) line_len)));
|
||||
std::size_t e = static_cast<std::size_t>(std::max(
|
||||
(int) s, std::min(e_raw, (int) line_len)));
|
||||
if (s < e)
|
||||
spans.push_back({s, e, sp.kind});
|
||||
}
|
||||
std::sort(spans.begin(), spans.end(),
|
||||
[](const SSpan &a, const SSpan &b) {
|
||||
return a.s < b.s;
|
||||
});
|
||||
|
||||
auto colorFor = [](kte::TokenKind k) -> QColor {
|
||||
// GUITheme provides colors via ImGui vector; avoid direct dependency types
|
||||
const auto v = kte::SyntaxInk(k);
|
||||
return QColor(int(v.x * 255.0f), int(v.y * 255.0f),
|
||||
int(v.z * 255.0f), int(v.w * 255.0f));
|
||||
};
|
||||
|
||||
// Helper to convert src col to expanded rx
|
||||
auto src_to_rx_full = [&](std::size_t sidx) -> std::size_t {
|
||||
std::size_t rx = 0;
|
||||
for (std::size_t k = 0; k < sidx && k < line.size(); ++k) {
|
||||
rx += (line[k] == '\t') ? (tabw - (rx % tabw)) : 1;
|
||||
}
|
||||
return rx;
|
||||
};
|
||||
|
||||
if (spans.empty()) {
|
||||
// No highlight spans: draw the whole (visible) expanded line in default fg
|
||||
if (coloffs < expanded.size()) {
|
||||
const char *start =
|
||||
expanded.c_str() + static_cast<int>(coloffs);
|
||||
p.setPen(fg);
|
||||
p.drawText(viewport.x(), baseline,
|
||||
QString::fromUtf8(start));
|
||||
}
|
||||
} else {
|
||||
// Draw colored spans
|
||||
for (const auto &sp: spans) {
|
||||
std::size_t rx_s = src_to_rx_full(sp.s);
|
||||
std::size_t rx_e = src_to_rx_full(sp.e);
|
||||
if (rx_e <= coloffs)
|
||||
continue; // left of viewport
|
||||
std::size_t draw_start = (rx_s > coloffs)
|
||||
? rx_s
|
||||
: coloffs;
|
||||
std::size_t draw_end = std::min<std::size_t>(
|
||||
rx_e, expanded.size());
|
||||
if (draw_end <= draw_start)
|
||||
continue;
|
||||
std::size_t screen_x = draw_start - coloffs;
|
||||
int px = viewport.x() + int(screen_x * ch_w);
|
||||
int len = int(draw_end - draw_start);
|
||||
p.setPen(colorFor(sp.k));
|
||||
p.drawText(px, baseline,
|
||||
QString::fromUtf8(
|
||||
expanded.c_str() + draw_start, len));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Draw expanded text clipped by coloffs
|
||||
if (static_cast<std::size_t>(coloffs) < expanded.size()) {
|
||||
const char *start =
|
||||
expanded.c_str() + static_cast<int>(coloffs);
|
||||
p.setPen(fg);
|
||||
p.drawText(viewport.x(), baseline, QString::fromUtf8(start));
|
||||
}
|
||||
}
|
||||
|
||||
// Cursor indicator on current line
|
||||
if (i == cy) {
|
||||
std::size_t rx_cur = src_to_rx_line(cx);
|
||||
if (rx_cur >= coloffs) {
|
||||
// Compute exact pixel x by measuring expanded substring [coloffs, rx_cur)
|
||||
std::size_t start = std::min<std::size_t>(
|
||||
coloffs, expanded.size());
|
||||
std::size_t end = std::min<
|
||||
std::size_t>(rx_cur, expanded.size());
|
||||
int px_advance = 0;
|
||||
if (end > start) {
|
||||
const QString sub = QString::fromUtf8(
|
||||
expanded.c_str() + start,
|
||||
static_cast<int>(end - start));
|
||||
px_advance = fm.horizontalAdvance(sub);
|
||||
}
|
||||
int x0 = viewport.x() + px_advance;
|
||||
QRect r(x0, y, ch_w, line_h);
|
||||
p.fillRect(r, cur_bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// Status bar
|
||||
const int bar_y = height() - status_h;
|
||||
QRect status_rect(0, bar_y, width(), status_h);
|
||||
p.fillRect(status_rect, status_bg);
|
||||
p.setPen(status_fg);
|
||||
if (ed_) {
|
||||
const int pad = 6;
|
||||
const int left_x = status_rect.x() + pad;
|
||||
const int right_x_max = status_rect.x() + status_rect.width() - pad;
|
||||
const int baseline_y = bar_y + (status_h + fm.ascent() - fm.descent()) / 2;
|
||||
|
||||
// If a prompt is active, mirror ImGui/TUI: show only the prompt across the bar
|
||||
if (ed_->PromptActive()) {
|
||||
std::string label = ed_->PromptLabel();
|
||||
std::string text = ed_->PromptText();
|
||||
|
||||
// Map $HOME to ~ for path prompts (Open/Save/Chdir)
|
||||
auto kind = ed_->CurrentPromptKind();
|
||||
if (kind == Editor::PromptKind::OpenFile ||
|
||||
kind == Editor::PromptKind::SaveAs ||
|
||||
kind == Editor::PromptKind::Chdir) {
|
||||
const char *home_c = std::getenv("HOME");
|
||||
if (home_c && *home_c) {
|
||||
std::string home(home_c);
|
||||
if (text.rfind(home, 0) == 0) {
|
||||
std::string rest = text.substr(home.size());
|
||||
if (rest.empty())
|
||||
text = "~";
|
||||
else if (!rest.empty() && (rest[0] == '/' || rest[0] == '\\'))
|
||||
text = std::string("~") + rest;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::string prefix;
|
||||
if (kind == Editor::PromptKind::Command)
|
||||
prefix = ": ";
|
||||
else if (!label.empty())
|
||||
prefix = label + ": ";
|
||||
|
||||
// Compose text and elide per behavior:
|
||||
const int max_w = status_rect.width() - 2 * pad;
|
||||
QString qprefix = QString::fromStdString(prefix);
|
||||
QString qtext = QString::fromStdString(text);
|
||||
int avail_w = std::max(0, max_w - fm.horizontalAdvance(qprefix));
|
||||
Qt::TextElideMode mode = Qt::ElideRight;
|
||||
if (kind == Editor::PromptKind::OpenFile ||
|
||||
kind == Editor::PromptKind::SaveAs ||
|
||||
kind == Editor::PromptKind::Chdir) {
|
||||
mode = Qt::ElideLeft;
|
||||
}
|
||||
QString shown = fm.elidedText(qtext, mode, avail_w);
|
||||
p.drawText(left_x, baseline_y, qprefix + shown);
|
||||
} else {
|
||||
// Build left segment: app/version, buffer idx/total, filename [+dirty], line count
|
||||
QString left;
|
||||
left += QStringLiteral("kge ");
|
||||
left += QStringLiteral(KTE_VERSION_STR);
|
||||
|
||||
const Buffer *buf = ed_->CurrentBuffer();
|
||||
if (buf) {
|
||||
// buffer index/total
|
||||
std::size_t total = ed_->BufferCount();
|
||||
if (total > 0) {
|
||||
std::size_t idx1 = ed_->CurrentBufferIndex() + 1; // 1-based
|
||||
left += QStringLiteral(" [");
|
||||
left += QString::number(static_cast<qlonglong>(idx1));
|
||||
left += QStringLiteral("/");
|
||||
left += QString::number(static_cast<qlonglong>(total));
|
||||
left += QStringLiteral("] ");
|
||||
} else {
|
||||
left += QStringLiteral(" ");
|
||||
}
|
||||
|
||||
// buffer display name
|
||||
std::string disp;
|
||||
try {
|
||||
disp = ed_->DisplayNameFor(*buf);
|
||||
} catch (...) {
|
||||
disp = buf->Filename();
|
||||
}
|
||||
if (disp.empty())
|
||||
disp = "[No Name]";
|
||||
left += QString::fromStdString(disp);
|
||||
if (buf->Dirty())
|
||||
left += QStringLiteral(" *");
|
||||
|
||||
// total lines suffix " <n>L"
|
||||
unsigned long lcount = static_cast<unsigned long>(buf->Rows().size());
|
||||
left += QStringLiteral(" ");
|
||||
left += QString::number(static_cast<qlonglong>(lcount));
|
||||
left += QStringLiteral("L");
|
||||
}
|
||||
|
||||
// Build right segment: cursor and mark
|
||||
QString right;
|
||||
if (buf) {
|
||||
int row1 = static_cast<int>(buf->Cury()) + 1;
|
||||
int col1 = static_cast<int>(buf->Curx()) + 1;
|
||||
bool have_mark = buf->MarkSet();
|
||||
int mrow1 = have_mark ? static_cast<int>(buf->MarkCury()) + 1 : 0;
|
||||
int mcol1 = have_mark ? static_cast<int>(buf->MarkCurx()) + 1 : 0;
|
||||
if (have_mark)
|
||||
right = QString("%1,%2 | M: %3,%4").arg(row1).arg(col1).arg(mrow1).arg(
|
||||
mcol1);
|
||||
else
|
||||
right = QString("%1,%2 | M: not set").arg(row1).arg(col1);
|
||||
}
|
||||
|
||||
// Middle message: status text
|
||||
QString mid = QString::fromStdString(ed_->Status());
|
||||
|
||||
// Measure and layout
|
||||
int left_w = fm.horizontalAdvance(left);
|
||||
int right_w = fm.horizontalAdvance(right);
|
||||
int lx = left_x;
|
||||
int rx = std::max(left_x, right_x_max - right_w);
|
||||
|
||||
// If overlap, elide left to make space for right
|
||||
if (lx + left_w + pad > rx) {
|
||||
int max_left_w = std::max(0, rx - lx - pad);
|
||||
left = fm.elidedText(left, Qt::ElideRight, max_left_w);
|
||||
left_w = fm.horizontalAdvance(left);
|
||||
}
|
||||
|
||||
// Draw left and right
|
||||
p.drawText(lx, baseline_y, left);
|
||||
if (!right.isEmpty())
|
||||
p.drawText(rx, baseline_y, right);
|
||||
|
||||
// Middle message clipped between end of left and start of right
|
||||
int mid_left = lx + left_w + pad;
|
||||
int mid_right = std::max(mid_left, rx - pad);
|
||||
int mid_w = std::max(0, mid_right - mid_left);
|
||||
if (mid_w > 0 && !mid.isEmpty()) {
|
||||
QString mid_show = fm.elidedText(mid, Qt::ElideRight, mid_w);
|
||||
p.save();
|
||||
p.setClipRect(QRect(mid_left, bar_y, mid_w, status_h));
|
||||
p.drawText(mid_left, baseline_y, mid_show);
|
||||
p.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void resizeEvent(QResizeEvent *event) override
|
||||
{
|
||||
QWidget::resizeEvent(event);
|
||||
if (!ed_)
|
||||
return;
|
||||
// Update editor dimensions based on new size
|
||||
QFont f(font_family_, font_px_);
|
||||
QFontMetrics fm(f);
|
||||
const int line_h = std::max(12, fm.height());
|
||||
const int ch_w = std::max(6, fm.horizontalAdvance(QStringLiteral(" ")));
|
||||
const int pad_l = 8, pad_r = 8, pad_t = 6, pad_b = 6;
|
||||
const int status_h = line_h + 6;
|
||||
const int avail_w = std::max(0, width() - pad_l - pad_r);
|
||||
const int avail_h = std::max(0, height() - pad_t - pad_b - status_h);
|
||||
std::size_t rows = std::max<std::size_t>(1, (avail_h / line_h));
|
||||
std::size_t cols = std::max<std::size_t>(1, (avail_w / ch_w));
|
||||
ed_->SetDimensions(rows, cols);
|
||||
}
|
||||
|
||||
|
||||
void wheelEvent(QWheelEvent *event) override
|
||||
{
|
||||
if (!ed_) {
|
||||
QWidget::wheelEvent(event);
|
||||
return;
|
||||
}
|
||||
Buffer *buf = ed_->CurrentBuffer();
|
||||
if (!buf) {
|
||||
QWidget::wheelEvent(event);
|
||||
return;
|
||||
}
|
||||
|
||||
// Recompute metrics to map pixel deltas to rows/cols
|
||||
QFont f(font_family_, font_px_);
|
||||
QFontMetrics fm(f);
|
||||
const int line_h = std::max(12, fm.height());
|
||||
const int ch_w = std::max(6, fm.horizontalAdvance(QStringLiteral(" ")));
|
||||
|
||||
// Determine scroll intent: use pixelDelta when available (trackpads), otherwise angleDelta
|
||||
QPoint pixel = event->pixelDelta();
|
||||
QPoint angle = event->angleDelta();
|
||||
|
||||
double v_lines_delta = 0.0;
|
||||
double h_cols_delta = 0.0;
|
||||
|
||||
// Horizontal scroll with Shift or explicit horizontal delta
|
||||
bool horiz_mode = (event->modifiers() & Qt::ShiftModifier) || (!pixel.isNull() && pixel.x() != 0) || (
|
||||
!angle.isNull() && angle.x() != 0);
|
||||
|
||||
if (!pixel.isNull()) {
|
||||
// Trackpad smooth scrolling (pixels)
|
||||
v_lines_delta = -static_cast<double>(pixel.y()) / std::max(1, line_h);
|
||||
h_cols_delta = -static_cast<double>(pixel.x()) / std::max(1, ch_w);
|
||||
} else if (!angle.isNull()) {
|
||||
// Mouse wheel: 120 units per notch; map one notch to 3 lines similar to ImGui UX
|
||||
v_lines_delta = -static_cast<double>(angle.y()) / 120.0 * 3.0;
|
||||
// For horizontal wheels, each notch scrolls 8 columns
|
||||
h_cols_delta = -static_cast<double>(angle.x()) / 120.0 * 8.0;
|
||||
}
|
||||
|
||||
// Accumulate fractional deltas across events
|
||||
v_scroll_accum_ += v_lines_delta;
|
||||
h_scroll_accum_ += h_cols_delta;
|
||||
|
||||
int d_rows = 0;
|
||||
int d_cols = 0;
|
||||
if (std::fabs(v_scroll_accum_) >= 1.0 && (!horiz_mode || std::fabs(v_scroll_accum_) > std::fabs(
|
||||
h_scroll_accum_))) {
|
||||
d_rows = static_cast<int>(v_scroll_accum_);
|
||||
v_scroll_accum_ -= d_rows;
|
||||
}
|
||||
if (std::fabs(h_scroll_accum_) >= 1.0 && (horiz_mode || std::fabs(h_scroll_accum_) >= std::fabs(
|
||||
v_scroll_accum_))) {
|
||||
d_cols = static_cast<int>(h_scroll_accum_);
|
||||
h_scroll_accum_ -= d_cols;
|
||||
}
|
||||
|
||||
if (d_rows != 0 || d_cols != 0) {
|
||||
std::size_t new_rowoffs = buf->Rowoffs();
|
||||
std::size_t new_coloffs = buf->Coloffs();
|
||||
// Clamp vertical between 0 and last row (leaving at least one visible line)
|
||||
if (d_rows != 0) {
|
||||
long nr = static_cast<long>(new_rowoffs) + d_rows;
|
||||
if (nr < 0)
|
||||
nr = 0;
|
||||
const auto nrows = static_cast<long>(buf->Rows().size());
|
||||
if (nr > std::max(0L, nrows - 1))
|
||||
nr = std::max(0L, nrows - 1);
|
||||
new_rowoffs = static_cast<std::size_t>(nr);
|
||||
}
|
||||
if (d_cols != 0) {
|
||||
long nc = static_cast<long>(new_coloffs) + d_cols;
|
||||
if (nc < 0)
|
||||
nc = 0;
|
||||
new_coloffs = static_cast<std::size_t>(nc);
|
||||
}
|
||||
buf->SetOffsets(new_rowoffs, new_coloffs);
|
||||
update();
|
||||
event->accept();
|
||||
return;
|
||||
}
|
||||
|
||||
QWidget::wheelEvent(event);
|
||||
}
|
||||
|
||||
|
||||
void closeEvent(QCloseEvent *event) override
|
||||
{
|
||||
closed_ = true;
|
||||
QWidget::closeEvent(event);
|
||||
}
|
||||
|
||||
private:
|
||||
QtInputHandler &input_;
|
||||
bool closed_ = false;
|
||||
Editor *ed_ = nullptr;
|
||||
double v_scroll_accum_ = 0.0;
|
||||
double h_scroll_accum_ = 0.0;
|
||||
QString font_family_ = QStringLiteral("Brass Mono");
|
||||
int font_px_ = 18;
|
||||
};
|
||||
} // namespace
|
||||
|
||||
bool
|
||||
GUIFrontend::Init(int &argc, char **argv, Editor &ed)
|
||||
{
|
||||
app_ = new QApplication(argc, argv);
|
||||
|
||||
window_ = new MainWindow(input_);
|
||||
window_->show();
|
||||
// Ensure the window becomes the active, focused window so it receives key events
|
||||
window_->activateWindow();
|
||||
window_->raise();
|
||||
window_->setFocus(Qt::OtherFocusReason);
|
||||
|
||||
renderer_.Attach(window_);
|
||||
input_.Attach(&ed);
|
||||
if (auto *mw = dynamic_cast<MainWindow *>(window_))
|
||||
mw->SetEditor(&ed);
|
||||
|
||||
// Load GUI configuration (kge.ini) and configure font for Qt
|
||||
config_ = GUIConfig::Load();
|
||||
|
||||
// Apply background mode from config to match ImGui frontend behavior
|
||||
if (config_.background == "light")
|
||||
kte::SetBackgroundMode(kte::BackgroundMode::Light);
|
||||
else
|
||||
kte::SetBackgroundMode(kte::BackgroundMode::Dark);
|
||||
|
||||
// Apply theme by name for Qt palette-based theming (maps to named palettes).
|
||||
// If unknown, falls back to the generic light/dark palette.
|
||||
(void) kte::ApplyQtThemeByName(config_.theme);
|
||||
if (window_)
|
||||
window_->update();
|
||||
|
||||
// Map GUIConfig font name to a system family (Qt uses installed fonts)
|
||||
auto choose_family = [](const std::string &name) -> QString {
|
||||
QString fam;
|
||||
std::string n = name;
|
||||
std::transform(n.begin(), n.end(), n.begin(), [](unsigned char c) {
|
||||
return (char) std::tolower(c);
|
||||
});
|
||||
if (n.empty() || n == "default" || n == "brassmono" || n == "brassmonocode") {
|
||||
fam = QStringLiteral("Brass Mono");
|
||||
} else if (n == "jetbrains" || n == "jetbrains mono" || n == "jetbrains-mono") {
|
||||
fam = QStringLiteral("JetBrains Mono");
|
||||
} else if (n == "iosevka") {
|
||||
fam = QStringLiteral("Iosevka");
|
||||
} else if (n == "inconsolata" || n == "inconsolataex") {
|
||||
fam = QStringLiteral("Inconsolata");
|
||||
} else if (n == "space" || n == "spacemono" || n == "space mono") {
|
||||
fam = QStringLiteral("Space Mono");
|
||||
} else if (n == "go") {
|
||||
fam = QStringLiteral("Go Mono");
|
||||
} else if (n == "ibm" || n == "ibm plex mono" || n == "ibm-plex-mono") {
|
||||
fam = QStringLiteral("IBM Plex Mono");
|
||||
} else if (n == "fira" || n == "fira code" || n == "fira-code") {
|
||||
fam = QStringLiteral("Fira Code");
|
||||
} else if (!name.empty()) {
|
||||
fam = QString::fromStdString(name);
|
||||
}
|
||||
|
||||
// Validate availability; choose a fallback if needed
|
||||
const auto families = QFontDatabase::families();
|
||||
if (!fam.isEmpty() && families.contains(fam)) {
|
||||
return fam;
|
||||
}
|
||||
// Preferred fallback chain on macOS; otherwise, try common monospace families
|
||||
const QStringList fallbacks = {
|
||||
QStringLiteral("Brass Mono"),
|
||||
QStringLiteral("JetBrains Mono"),
|
||||
QStringLiteral("SF Mono"),
|
||||
QStringLiteral("Menlo"),
|
||||
QStringLiteral("Monaco"),
|
||||
QStringLiteral("Courier New"),
|
||||
QStringLiteral("Courier"),
|
||||
QStringLiteral("Monospace")
|
||||
};
|
||||
for (const auto &fb: fallbacks) {
|
||||
if (families.contains(fb))
|
||||
return fb;
|
||||
}
|
||||
// As a last resort, return the request (Qt will substitute)
|
||||
return fam.isEmpty() ? QStringLiteral("Monospace") : fam;
|
||||
};
|
||||
|
||||
QString family = choose_family(config_.font);
|
||||
int px_size = (config_.font_size > 0.0f) ? (int) std::lround(config_.font_size) : 18;
|
||||
if (auto *mw = dynamic_cast<MainWindow *>(window_)) {
|
||||
mw->SetFontFamilyAndSize(family, px_size);
|
||||
}
|
||||
// Track current font in globals for command/status queries
|
||||
kte::gCurrentFontFamily = family.toStdString();
|
||||
kte::gCurrentFontSize = static_cast<float>(px_size);
|
||||
|
||||
// Set initial dimensions based on font metrics
|
||||
QFont f(family, px_size);
|
||||
QFontMetrics fm(f);
|
||||
const int line_h = std::max(12, fm.height());
|
||||
const int ch_w = std::max(6, fm.horizontalAdvance(QStringLiteral("M")));
|
||||
const int w = window_->width();
|
||||
const int h = window_->height();
|
||||
const int pad = 16;
|
||||
const int status_h = line_h + 4;
|
||||
const int avail_w = std::max(0, w - 2 * pad);
|
||||
const int avail_h = std::max(0, h - 2 * pad - status_h);
|
||||
std::size_t rows = std::max<std::size_t>(1, (avail_h / line_h) + 1); // + status
|
||||
std::size_t cols = std::max<std::size_t>(1, (avail_w / ch_w));
|
||||
ed.SetDimensions(rows, cols);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GUIFrontend::Step(Editor &ed, bool &running)
|
||||
{
|
||||
// Pump Qt events
|
||||
if (app_)
|
||||
app_->processEvents();
|
||||
|
||||
// Allow deferred opens (including swap recovery prompts) to run.
|
||||
ed.ProcessPendingOpens();
|
||||
|
||||
// Drain input queue
|
||||
for (;;) {
|
||||
MappedInput mi;
|
||||
if (!input_.Poll(mi))
|
||||
break;
|
||||
if (mi.hasCommand) {
|
||||
Execute(ed, mi.id, mi.arg, mi.count);
|
||||
}
|
||||
}
|
||||
|
||||
if (ed.QuitRequested()) {
|
||||
running = false;
|
||||
}
|
||||
|
||||
// --- Visual File Picker (Qt): invoked via CommandId::VisualFilePickerToggle ---
|
||||
if (ed.FilePickerVisible()) {
|
||||
QString startDir;
|
||||
if (!ed.FilePickerDir().empty()) {
|
||||
startDir = QString::fromStdString(ed.FilePickerDir());
|
||||
}
|
||||
QFileDialog dlg(window_, QStringLiteral("Open File"), startDir);
|
||||
dlg.setFileMode(QFileDialog::ExistingFile);
|
||||
if (dlg.exec() == QDialog::Accepted) {
|
||||
const QStringList files = dlg.selectedFiles();
|
||||
if (!files.isEmpty()) {
|
||||
const QString fp = files.front();
|
||||
ed.RequestOpenFile(fp.toStdString());
|
||||
(void) ed.ProcessPendingOpens();
|
||||
// Update picker dir for next time
|
||||
QFileInfo info(fp);
|
||||
ed.SetFilePickerDir(info.dir().absolutePath().toStdString());
|
||||
}
|
||||
}
|
||||
// Close picker overlay regardless of outcome
|
||||
ed.SetFilePickerVisible(false);
|
||||
if (window_)
|
||||
window_->update();
|
||||
}
|
||||
|
||||
// Apply any queued theme change requests (from command handler)
|
||||
if (kte::gThemeChangePending) {
|
||||
if (!kte::gThemeChangeRequest.empty()) {
|
||||
// Apply Qt palette theme by name; if unknown, keep current palette
|
||||
(void) kte::ApplyQtThemeByName(kte::gThemeChangeRequest);
|
||||
}
|
||||
kte::gThemeChangePending = false;
|
||||
kte::gThemeChangeRequest.clear();
|
||||
if (window_)
|
||||
window_->update();
|
||||
}
|
||||
|
||||
// Visual font picker request (Qt only)
|
||||
if (kte::gFontDialogRequested) {
|
||||
// Seed initial font from current or default
|
||||
QFont seed;
|
||||
if (!kte::gCurrentFontFamily.empty()) {
|
||||
seed = QFont(QString::fromStdString(kte::gCurrentFontFamily),
|
||||
(int) std::lround(kte::gCurrentFontSize > 0 ? kte::gCurrentFontSize : 18));
|
||||
} else {
|
||||
seed = window_ ? window_->font() : QFont();
|
||||
}
|
||||
bool ok = false;
|
||||
const QFont chosen = QFontDialog::getFont(&ok, seed, window_, QStringLiteral("Choose Editor Font"));
|
||||
if (ok) {
|
||||
// Queue font change via existing hooks
|
||||
kte::gFontFamilyRequest = chosen.family().toStdString();
|
||||
// Use pixel size if available, otherwise convert from point size approximately
|
||||
int px = chosen.pixelSize();
|
||||
if (px <= 0) {
|
||||
// Approximate points to pixels (96 DPI assumption); Qt will rasterize appropriately
|
||||
px = (int) std::lround(chosen.pointSizeF() * 96.0 / 72.0);
|
||||
if (px <= 0)
|
||||
px = 18;
|
||||
}
|
||||
kte::gFontSizeRequest = static_cast<float>(px);
|
||||
kte::gFontChangePending = true;
|
||||
}
|
||||
kte::gFontDialogRequested = false;
|
||||
if (window_)
|
||||
window_->update();
|
||||
}
|
||||
|
||||
// Apply any queued font change requests (Qt)
|
||||
if (kte::gFontChangePending) {
|
||||
// Derive target family
|
||||
auto map_family = [](const std::string &name) -> QString {
|
||||
std::string n = name;
|
||||
std::transform(n.begin(), n.end(), n.begin(), [](unsigned char c) {
|
||||
return (char) std::tolower(c);
|
||||
});
|
||||
QString fam;
|
||||
if (n == "brass" || n == "brassmono" || n == "brass mono") {
|
||||
fam = QStringLiteral("Brass Mono");
|
||||
} else if (n == "jetbrains" || n == "jetbrains mono" || n == "jetbrains-mono") {
|
||||
fam = QStringLiteral("JetBrains Mono");
|
||||
} else if (n == "iosevka") {
|
||||
fam = QStringLiteral("Iosevka");
|
||||
} else if (n == "inconsolata" || n == "inconsolataex") {
|
||||
fam = QStringLiteral("Inconsolata");
|
||||
} else if (n == "space" || n == "spacemono" || n == "space mono") {
|
||||
fam = QStringLiteral("Space Mono");
|
||||
} else if (n == "go") {
|
||||
fam = QStringLiteral("Go Mono");
|
||||
} else if (n == "ibm" || n == "ibm plex mono" || n == "ibm-plex-mono") {
|
||||
fam = QStringLiteral("IBM Plex Mono");
|
||||
} else if (n == "fira" || n == "fira code" || n == "fira-code") {
|
||||
fam = QStringLiteral("Fira Code");
|
||||
} else if (!name.empty()) {
|
||||
fam = QString::fromStdString(name);
|
||||
}
|
||||
// Validate availability; choose fallback if needed
|
||||
const auto families = QFontDatabase::families();
|
||||
if (!fam.isEmpty() && families.contains(fam)) {
|
||||
return fam;
|
||||
}
|
||||
// Fallback chain
|
||||
const QStringList fallbacks = {
|
||||
QStringLiteral("Brass Mono"),
|
||||
QStringLiteral("JetBrains Mono"),
|
||||
QStringLiteral("SF Mono"),
|
||||
QStringLiteral("Menlo"),
|
||||
QStringLiteral("Monaco"),
|
||||
QStringLiteral("Courier New"),
|
||||
QStringLiteral("Courier"),
|
||||
QStringLiteral("Monospace")
|
||||
};
|
||||
for (const auto &fb: fallbacks) {
|
||||
if (families.contains(fb))
|
||||
return fb;
|
||||
}
|
||||
return fam.isEmpty() ? QStringLiteral("Monospace") : fam;
|
||||
};
|
||||
|
||||
QString target_family;
|
||||
if (!kte::gFontFamilyRequest.empty()) {
|
||||
target_family = map_family(kte::gFontFamilyRequest);
|
||||
} else if (!kte::gCurrentFontFamily.empty()) {
|
||||
target_family = QString::fromStdString(kte::gCurrentFontFamily);
|
||||
}
|
||||
int target_px = 0;
|
||||
if (kte::gFontSizeRequest > 0.0f) {
|
||||
target_px = (int) std::lround(kte::gFontSizeRequest);
|
||||
} else if (kte::gCurrentFontSize > 0.0f) {
|
||||
target_px = (int) std::lround(kte::gCurrentFontSize);
|
||||
}
|
||||
if (target_px <= 0)
|
||||
target_px = 18;
|
||||
if (target_family.isEmpty())
|
||||
target_family = QStringLiteral("Monospace");
|
||||
|
||||
if (auto *mw = dynamic_cast<MainWindow *>(window_)) {
|
||||
mw->SetFontFamilyAndSize(target_family, target_px);
|
||||
}
|
||||
// Update globals
|
||||
kte::gCurrentFontFamily = target_family.toStdString();
|
||||
kte::gCurrentFontSize = static_cast<float>(target_px);
|
||||
// Reset requests
|
||||
kte::gFontChangePending = false;
|
||||
kte::gFontFamilyRequest.clear();
|
||||
kte::gFontSizeRequest = 0.0f;
|
||||
|
||||
// Recompute editor dimensions to match new metrics
|
||||
QFont f(target_family, target_px);
|
||||
QFontMetrics fm(f);
|
||||
const int line_h = std::max(12, fm.height());
|
||||
const int ch_w = std::max(6, fm.horizontalAdvance(QStringLiteral("M")));
|
||||
const int w = window_ ? window_->width() : 0;
|
||||
const int h = window_ ? window_->height() : 0;
|
||||
const int pad = 16;
|
||||
const int status_h = line_h + 4;
|
||||
const int avail_w = std::max(0, w - 2 * pad);
|
||||
const int avail_h = std::max(0, h - 2 * pad - status_h);
|
||||
std::size_t rows = std::max<std::size_t>(1, (avail_h / line_h) + 1); // + status
|
||||
std::size_t cols = std::max<std::size_t>(1, (avail_w / ch_w));
|
||||
ed.SetDimensions(rows, cols);
|
||||
|
||||
if (window_)
|
||||
window_->update();
|
||||
}
|
||||
|
||||
// Draw current frame (request repaint)
|
||||
renderer_.Draw(ed);
|
||||
|
||||
// Detect window close
|
||||
if (auto *mw = dynamic_cast<MainWindow *>(window_)) {
|
||||
if (mw->WasClosed()) {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GUIFrontend::Shutdown()
|
||||
{
|
||||
if (window_) {
|
||||
window_->close();
|
||||
delete window_;
|
||||
window_ = nullptr;
|
||||
}
|
||||
if (app_) {
|
||||
delete app_;
|
||||
app_ = nullptr;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* QtFrontend - couples QtInputHandler + QtRenderer and owns Qt lifecycle
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include "Frontend.h"
|
||||
#include "GUIConfig.h"
|
||||
#include "QtInputHandler.h"
|
||||
#include "QtRenderer.h"
|
||||
|
||||
class QApplication;
|
||||
class QWidget;
|
||||
|
||||
// Keep the public class name GUIFrontend to match main.cc selection logic.
|
||||
class GUIFrontend final : public Frontend {
|
||||
public:
|
||||
GUIFrontend() = default;
|
||||
|
||||
~GUIFrontend() override = default;
|
||||
|
||||
bool Init(int &argc, char **argv, Editor &ed) override;
|
||||
|
||||
void Step(Editor &ed, bool &running) override;
|
||||
|
||||
void Shutdown() override;
|
||||
|
||||
private:
|
||||
GUIConfig config_{};
|
||||
QtInputHandler input_{};
|
||||
QtRenderer renderer_{};
|
||||
|
||||
QApplication *app_ = nullptr; // owned
|
||||
QWidget *window_ = nullptr; // owned
|
||||
int width_ = 1280;
|
||||
int height_ = 800;
|
||||
};
|
||||
@@ -0,0 +1,536 @@
|
||||
// Refactor: route all key processing through command subsystem, mirroring ImGuiInputHandler
|
||||
|
||||
#include "QtInputHandler.h"
|
||||
|
||||
#include <QKeyEvent>
|
||||
|
||||
#include <ncurses.h>
|
||||
|
||||
#include "Editor.h"
|
||||
#include "KKeymap.h"
|
||||
|
||||
// Temporary verbose logging to debug macOS Qt key translation issues
|
||||
// Default to off; enable by defining QT_IH_DEBUG=1 at compile time when needed.
|
||||
#ifndef QT_IH_DEBUG
|
||||
#define QT_IH_DEBUG 0
|
||||
#endif
|
||||
|
||||
#if QT_IH_DEBUG
|
||||
#include <cstdio>
|
||||
static const char *
|
||||
mods_str(Qt::KeyboardModifiers m)
|
||||
{
|
||||
static thread_local char buf[64];
|
||||
buf[0] = '\0';
|
||||
bool first = true;
|
||||
auto add = [&](const char *s) {
|
||||
if (!first)
|
||||
std::snprintf(buf + std::strlen(buf), sizeof(buf) - std::strlen(buf), "|");
|
||||
std::snprintf(buf + std::strlen(buf), sizeof(buf) - std::strlen(buf), "%s", s);
|
||||
first = false;
|
||||
};
|
||||
if (m & Qt::ShiftModifier)
|
||||
add("Shift");
|
||||
if (m & Qt::ControlModifier)
|
||||
add("Ctrl");
|
||||
if (m & Qt::AltModifier)
|
||||
add("Alt");
|
||||
if (m & Qt::MetaModifier)
|
||||
add("Meta");
|
||||
if (first)
|
||||
std::snprintf(buf, sizeof(buf), "none");
|
||||
return buf;
|
||||
}
|
||||
#define LOGF(...) std::fprintf(stderr, __VA_ARGS__)
|
||||
#else
|
||||
#define LOGF(...) ((void)0)
|
||||
#endif
|
||||
|
||||
static bool
|
||||
IsPrintableQt(const QKeyEvent &e)
|
||||
{
|
||||
// Printable if it yields non-empty text and no Ctrl/Meta modifier
|
||||
if (e.modifiers() & (Qt::ControlModifier | Qt::MetaModifier))
|
||||
return false;
|
||||
const QString t = e.text();
|
||||
return !t.isEmpty() && !t.at(0).isNull();
|
||||
}
|
||||
|
||||
|
||||
static int
|
||||
ToAsciiKey(const QKeyEvent &e)
|
||||
{
|
||||
const QString t = e.text();
|
||||
if (!t.isEmpty()) {
|
||||
const QChar c = t.at(0);
|
||||
if (!c.isNull())
|
||||
return KLowerAscii(c.unicode());
|
||||
}
|
||||
// When modifiers (like Control) are held, Qt::text() can be empty on macOS.
|
||||
// Fall back to mapping common virtual keys to ASCII.
|
||||
switch (e.key()) {
|
||||
case Qt::Key_A:
|
||||
return 'a';
|
||||
case Qt::Key_B:
|
||||
return 'b';
|
||||
case Qt::Key_C:
|
||||
return 'c';
|
||||
case Qt::Key_D:
|
||||
return 'd';
|
||||
case Qt::Key_E:
|
||||
return 'e';
|
||||
case Qt::Key_F:
|
||||
return 'f';
|
||||
case Qt::Key_G:
|
||||
return 'g';
|
||||
case Qt::Key_H:
|
||||
return 'h';
|
||||
case Qt::Key_I:
|
||||
return 'i';
|
||||
case Qt::Key_J:
|
||||
return 'j';
|
||||
case Qt::Key_K:
|
||||
return 'k';
|
||||
case Qt::Key_L:
|
||||
return 'l';
|
||||
case Qt::Key_M:
|
||||
return 'm';
|
||||
case Qt::Key_N:
|
||||
return 'n';
|
||||
case Qt::Key_O:
|
||||
return 'o';
|
||||
case Qt::Key_P:
|
||||
return 'p';
|
||||
case Qt::Key_Q:
|
||||
return 'q';
|
||||
case Qt::Key_R:
|
||||
return 'r';
|
||||
case Qt::Key_S:
|
||||
return 's';
|
||||
case Qt::Key_T:
|
||||
return 't';
|
||||
case Qt::Key_U:
|
||||
return 'u';
|
||||
case Qt::Key_V:
|
||||
return 'v';
|
||||
case Qt::Key_W:
|
||||
return 'w';
|
||||
case Qt::Key_X:
|
||||
return 'x';
|
||||
case Qt::Key_Y:
|
||||
return 'y';
|
||||
case Qt::Key_Z:
|
||||
return 'z';
|
||||
case Qt::Key_0:
|
||||
return '0';
|
||||
case Qt::Key_1:
|
||||
return '1';
|
||||
case Qt::Key_2:
|
||||
return '2';
|
||||
case Qt::Key_3:
|
||||
return '3';
|
||||
case Qt::Key_4:
|
||||
return '4';
|
||||
case Qt::Key_5:
|
||||
return '5';
|
||||
case Qt::Key_6:
|
||||
return '6';
|
||||
case Qt::Key_7:
|
||||
return '7';
|
||||
case Qt::Key_8:
|
||||
return '8';
|
||||
case Qt::Key_9:
|
||||
return '9';
|
||||
case Qt::Key_Comma:
|
||||
return ',';
|
||||
case Qt::Key_Period:
|
||||
return '.';
|
||||
case Qt::Key_Semicolon:
|
||||
return ';';
|
||||
case Qt::Key_Apostrophe:
|
||||
return '\'';
|
||||
case Qt::Key_Minus:
|
||||
return '-';
|
||||
case Qt::Key_Equal:
|
||||
return '=';
|
||||
case Qt::Key_Slash:
|
||||
return '/';
|
||||
case Qt::Key_Backslash:
|
||||
return '\\';
|
||||
case Qt::Key_BracketLeft:
|
||||
return '[';
|
||||
case Qt::Key_BracketRight:
|
||||
return ']';
|
||||
case Qt::Key_QuoteLeft:
|
||||
return '`';
|
||||
case Qt::Key_Space:
|
||||
return ' ';
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
// Case-preserving ASCII derivation for k-prefix handling where we need to
|
||||
// distinguish between 'C' and 'c'. Falls back to virtual-key mapping if
|
||||
// event text is unavailable (common when Control/Meta held on macOS).
|
||||
static int
|
||||
ToAsciiKeyPreserveCase(const QKeyEvent &e)
|
||||
{
|
||||
const QString t = e.text();
|
||||
if (!t.isEmpty()) {
|
||||
const QChar c = t.at(0);
|
||||
if (!c.isNull())
|
||||
return c.unicode();
|
||||
}
|
||||
// Fall back to virtual key mapping (letters as uppercase A..Z)
|
||||
switch (e.key()) {
|
||||
case Qt::Key_A:
|
||||
return 'A';
|
||||
case Qt::Key_B:
|
||||
return 'B';
|
||||
case Qt::Key_C:
|
||||
return 'C';
|
||||
case Qt::Key_D:
|
||||
return 'D';
|
||||
case Qt::Key_E:
|
||||
return 'E';
|
||||
case Qt::Key_F:
|
||||
return 'F';
|
||||
case Qt::Key_G:
|
||||
return 'G';
|
||||
case Qt::Key_H:
|
||||
return 'H';
|
||||
case Qt::Key_I:
|
||||
return 'I';
|
||||
case Qt::Key_J:
|
||||
return 'J';
|
||||
case Qt::Key_K:
|
||||
return 'K';
|
||||
case Qt::Key_L:
|
||||
return 'L';
|
||||
case Qt::Key_M:
|
||||
return 'M';
|
||||
case Qt::Key_N:
|
||||
return 'N';
|
||||
case Qt::Key_O:
|
||||
return 'O';
|
||||
case Qt::Key_P:
|
||||
return 'P';
|
||||
case Qt::Key_Q:
|
||||
return 'Q';
|
||||
case Qt::Key_R:
|
||||
return 'R';
|
||||
case Qt::Key_S:
|
||||
return 'S';
|
||||
case Qt::Key_T:
|
||||
return 'T';
|
||||
case Qt::Key_U:
|
||||
return 'U';
|
||||
case Qt::Key_V:
|
||||
return 'V';
|
||||
case Qt::Key_W:
|
||||
return 'W';
|
||||
case Qt::Key_X:
|
||||
return 'X';
|
||||
case Qt::Key_Y:
|
||||
return 'Y';
|
||||
case Qt::Key_Z:
|
||||
return 'Z';
|
||||
case Qt::Key_Comma:
|
||||
return ',';
|
||||
case Qt::Key_Period:
|
||||
return '.';
|
||||
case Qt::Key_Semicolon:
|
||||
return ';';
|
||||
case Qt::Key_Apostrophe:
|
||||
return '\'';
|
||||
case Qt::Key_Minus:
|
||||
return '-';
|
||||
case Qt::Key_Equal:
|
||||
return '=';
|
||||
case Qt::Key_Slash:
|
||||
return '/';
|
||||
case Qt::Key_Backslash:
|
||||
return '\\';
|
||||
case Qt::Key_BracketLeft:
|
||||
return '[';
|
||||
case Qt::Key_BracketRight:
|
||||
return ']';
|
||||
case Qt::Key_QuoteLeft:
|
||||
return '`';
|
||||
case Qt::Key_Space:
|
||||
return ' ';
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
QtInputHandler::ProcessKeyEvent(const QKeyEvent &e)
|
||||
{
|
||||
const Qt::KeyboardModifiers mods = e.modifiers();
|
||||
LOGF("[QtIH] keyPress key=0x%X mods=%s text='%s' k_prefix=%d k_ctrl_pending=%d esc_meta=%d\n",
|
||||
e.key(), mods_str(mods), e.text().toUtf8().constData(), (int)k_prefix_, (int)k_ctrl_pending_,
|
||||
(int)esc_meta_);
|
||||
|
||||
// Control-chord detection: only treat the physical Control key as control-like.
|
||||
// Do NOT include Meta (Command) here so that ⌘-letter shortcuts do not fall into
|
||||
// the Ctrl map (prevents ⌘-T being mistaken for C-t).
|
||||
const bool ctrl_like = (mods & Qt::ControlModifier);
|
||||
|
||||
// 1) Universal argument digits (when active), consume digits without enqueuing commands
|
||||
if (ed_ && ed_
|
||||
|
||||
->
|
||||
UArg() != 0
|
||||
) {
|
||||
if (!(mods & (Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier))) {
|
||||
if (e.key() >= Qt::Key_0 && e.key() <= Qt::Key_9) {
|
||||
int d = e.key() - Qt::Key_0;
|
||||
ed_->UArgDigit(d);
|
||||
// request status refresh
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
q_.push(MappedInput{true, CommandId::UArgStatus, std::string(), 0});
|
||||
LOGF("[QtIH] UArg digit %d -> enqueue UArgStatus\n", d);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Enter k-prefix on C-k
|
||||
if (ctrl_like && (e.key() == Qt::Key_K)) {
|
||||
k_prefix_ = true;
|
||||
k_ctrl_pending_ = false;
|
||||
LOGF("[QtIH] Enter KPrefix\n");
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
q_.push(MappedInput{true, CommandId::KPrefix, std::string(), 0});
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3) If currently in k-prefix, resolve next key via KLookupKCommand
|
||||
if (k_prefix_) {
|
||||
// ESC/meta prefix should not interfere with k-suffix resolution
|
||||
esc_meta_ = false;
|
||||
// Support literal 'C' (uppercase) or '^' to indicate the next key is Ctrl-qualified.
|
||||
// Use case-preserving derivation so that 'c' (lowercase) can still be a valid suffix
|
||||
// like C-k c (BufferClose).
|
||||
int ascii_raw = ToAsciiKeyPreserveCase(e);
|
||||
if (ascii_raw == 'C' || ascii_raw == '^') {
|
||||
k_ctrl_pending_ = true;
|
||||
if (ed_)
|
||||
ed_->SetStatus("C-k C _");
|
||||
LOGF("[QtIH] KPrefix: set k_ctrl_pending via '%c'\n", (ascii_raw == 'C') ? 'C' : '^');
|
||||
return true; // consume, wait for next key
|
||||
}
|
||||
int ascii_key = (ascii_raw != 0) ? ascii_raw : ToAsciiKey(e);
|
||||
int lower = KLowerAscii(ascii_key);
|
||||
// Only pass a control suffix for specific supported keys (d/x/q),
|
||||
// matching ImGui behavior so that holding Ctrl during the suffix
|
||||
// doesn't break other mappings like C-k c (BufferClose).
|
||||
bool ctrl_suffix_supported = (lower == 'd' || lower == 'x' || lower == 'q');
|
||||
bool pass_ctrl = (ctrl_like || k_ctrl_pending_) && ctrl_suffix_supported;
|
||||
k_ctrl_pending_ = false; // consume pending qualifier on any suffix
|
||||
LOGF("[QtIH] KPrefix: ascii_key=%d lower=%d pass_ctrl=%d\n", ascii_key, lower, (int)pass_ctrl);
|
||||
if (ascii_key != 0) {
|
||||
CommandId id;
|
||||
if (KLookupKCommand(ascii_key, pass_ctrl, id)) {
|
||||
LOGF("[QtIH] KPrefix: mapped to command id=%d\n", (int)id);
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
q_.push(MappedInput{true, id, std::string(), 0});
|
||||
} else {
|
||||
// Unknown k-command: notify
|
||||
std::string a;
|
||||
a.push_back(static_cast<char>(ascii_key));
|
||||
LOGF("[QtIH] KPrefix: unknown command for '%c'\n", (char)ascii_key);
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
q_.push(MappedInput{true, CommandId::UnknownKCommand, a, 0});
|
||||
}
|
||||
k_prefix_ = false;
|
||||
return true;
|
||||
}
|
||||
// If not resolvable, consume and exit k-prefix
|
||||
k_prefix_ = false;
|
||||
LOGF("[QtIH] KPrefix: unresolved key; exiting prefix\n");
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3.5) GUI shortcut: Command/Meta + T opens the visual font picker (Qt only).
|
||||
// Require Meta present and Control NOT present so Ctrl-T never triggers this.
|
||||
if ((mods & Qt::MetaModifier) && !(mods & Qt::ControlModifier) && e.key() == Qt::Key_T) {
|
||||
LOGF("[QtIH] Meta/Super-T -> VisualFontPickerToggle\n");
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
q_.push(MappedInput{true, CommandId::VisualFontPickerToggle, std::string(), 0});
|
||||
return true;
|
||||
}
|
||||
|
||||
// 4) ESC as Meta prefix (set state). Alt/Meta chord handled below directly.
|
||||
if (e.key() == Qt::Key_Escape) {
|
||||
esc_meta_ = true;
|
||||
LOGF("[QtIH] ESC: set esc_meta\n");
|
||||
return true; // consumed
|
||||
}
|
||||
|
||||
// 5) Alt/Meta bindings (ESC f/b equivalent). Handle either Alt/Meta or pending esc_meta_
|
||||
// ESC/meta chords: on macOS, do NOT treat Meta as ESC; only Alt (Option) should trigger.
|
||||
#if defined(__APPLE__)
|
||||
if (esc_meta_ || (mods & Qt::AltModifier)) {
|
||||
#else
|
||||
if (esc_meta_ || (mods & (Qt::AltModifier | Qt::MetaModifier))) {
|
||||
|
||||
#endif
|
||||
int ascii_key = 0;
|
||||
if (e.key() == Qt::Key_Backspace) {
|
||||
ascii_key = KEY_BACKSPACE;
|
||||
} else if (e.key() >= Qt::Key_A && e.key() <= Qt::Key_Z) {
|
||||
ascii_key = 'a' + (e.key() - Qt::Key_A);
|
||||
} else if (e.key() == Qt::Key_Comma) {
|
||||
ascii_key = '<';
|
||||
} else if (e.key() == Qt::Key_Period) {
|
||||
ascii_key = '>';
|
||||
}
|
||||
// If still unknown, try deriving from text (covers digits, punctuation, locale)
|
||||
if (ascii_key == 0) {
|
||||
ascii_key = ToAsciiKey(e);
|
||||
}
|
||||
esc_meta_ = false; // one-shot regardless
|
||||
if (ascii_key != 0) {
|
||||
ascii_key = KLowerAscii(ascii_key);
|
||||
CommandId id;
|
||||
if (KLookupEscCommand(ascii_key, id)) {
|
||||
LOGF("[QtIH] ESC/Meta: mapped '%d' -> id=%d\n", ascii_key, (int)id);
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
q_.push(MappedInput{true, id, std::string(), 0});
|
||||
return true;
|
||||
} else {
|
||||
// Report invalid ESC sequence just like ImGui path
|
||||
LOGF("[QtIH] ESC/Meta: unknown command for ascii=%d\n", ascii_key);
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
q_.push(MappedInput{true, CommandId::UnknownEscCommand, std::string(), 0});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Nothing derivable: consume (ESC prefix cleared) and do not insert text
|
||||
return true;
|
||||
}
|
||||
|
||||
// 6) Control-chord direct mappings (e.g., C-n/C-p/C-f/C-b...)
|
||||
if (ctrl_like) {
|
||||
// Universal argument handling: C-u starts collection; C-g cancels
|
||||
if (e.key() == Qt::Key_U) {
|
||||
if (ed_)
|
||||
ed_->UArgStart();
|
||||
LOGF("[QtIH] Ctrl-chord: start universal argument\n");
|
||||
return true;
|
||||
}
|
||||
if (e.key() == Qt::Key_G) {
|
||||
if (ed_)
|
||||
ed_->UArgClear();
|
||||
k_ctrl_pending_ = false;
|
||||
k_prefix_ = false;
|
||||
LOGF("[QtIH] Ctrl-chord: cancel universal argument and k-prefix via C-g\n");
|
||||
// Fall through to map C-g to Refresh via ctrl map
|
||||
}
|
||||
if (e.key() >= Qt::Key_A && e.key() <= Qt::Key_Z) {
|
||||
int ascii_key = 'a' + (e.key() - Qt::Key_A);
|
||||
CommandId id;
|
||||
if (KLookupCtrlCommand(ascii_key, id)) {
|
||||
LOGF("[QtIH] Ctrl-chord: 'C-%c' -> id=%d\n", (char)ascii_key, (int)id);
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
q_.push(MappedInput{true, id, std::string(), 0});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// If no mapping, continue to allow other keys below
|
||||
}
|
||||
|
||||
// 7) Special navigation/edit keys (match ImGui behavior)
|
||||
{
|
||||
CommandId id;
|
||||
bool has = false;
|
||||
switch (e.key()) {
|
||||
case Qt::Key_Return:
|
||||
case Qt::Key_Enter:
|
||||
id = CommandId::Newline;
|
||||
has = true;
|
||||
break;
|
||||
case Qt::Key_Backspace:
|
||||
id = CommandId::Backspace;
|
||||
has = true;
|
||||
break;
|
||||
case Qt::Key_Delete:
|
||||
id = CommandId::DeleteChar;
|
||||
has = true;
|
||||
break;
|
||||
case Qt::Key_Left:
|
||||
id = CommandId::MoveLeft;
|
||||
has = true;
|
||||
break;
|
||||
case Qt::Key_Right:
|
||||
id = CommandId::MoveRight;
|
||||
has = true;
|
||||
break;
|
||||
case Qt::Key_Up:
|
||||
id = CommandId::MoveUp;
|
||||
has = true;
|
||||
break;
|
||||
case Qt::Key_Down:
|
||||
id = CommandId::MoveDown;
|
||||
has = true;
|
||||
break;
|
||||
case Qt::Key_Home:
|
||||
id = CommandId::MoveHome;
|
||||
has = true;
|
||||
break;
|
||||
case Qt::Key_End:
|
||||
id = CommandId::MoveEnd;
|
||||
has = true;
|
||||
break;
|
||||
case Qt::Key_PageUp:
|
||||
id = CommandId::PageUp;
|
||||
has = true;
|
||||
break;
|
||||
case Qt::Key_PageDown:
|
||||
id = CommandId::PageDown;
|
||||
has = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (has) {
|
||||
LOGF("[QtIH] Special key -> id=%d\n", (int)id);
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
q_.push(MappedInput{true, id, std::string(), 0});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 8) Insert printable text
|
||||
if (IsPrintableQt(e)) {
|
||||
std::string s = e.text().toStdString();
|
||||
if (!s.empty()) {
|
||||
LOGF("[QtIH] InsertText '%s'\n", s.c_str());
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
q_.push(MappedInput{true, CommandId::InsertText, s, 0});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
LOGF("[QtIH] Unhandled key\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
QtInputHandler::Poll(MappedInput &out)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
if (q_.empty())
|
||||
return false;
|
||||
out = q_.front();
|
||||
q_.pop();
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* QtInputHandler - Qt-based input mapping for GUI mode
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include <mutex>
|
||||
#include <queue>
|
||||
|
||||
#include "InputHandler.h"
|
||||
|
||||
class QKeyEvent;
|
||||
|
||||
class QtInputHandler final : public InputHandler {
|
||||
public:
|
||||
QtInputHandler() = default;
|
||||
|
||||
~QtInputHandler() override = default;
|
||||
|
||||
|
||||
void Attach(Editor *ed) override
|
||||
{
|
||||
ed_ = ed;
|
||||
}
|
||||
|
||||
|
||||
// Translate a Qt key event to editor command and enqueue if applicable.
|
||||
// Returns true if it produced a mapped command or consumed input.
|
||||
bool ProcessKeyEvent(const QKeyEvent &e);
|
||||
|
||||
bool Poll(MappedInput &out) override;
|
||||
|
||||
private:
|
||||
std::mutex mu_;
|
||||
std::queue<MappedInput> q_;
|
||||
bool k_prefix_ = false;
|
||||
bool k_ctrl_pending_ = false; // C-k C-… qualifier
|
||||
bool esc_meta_ = false; // ESC-prefix for next key
|
||||
bool suppress_text_input_once_ = false; // reserved (Qt sends text in keyPressEvent)
|
||||
Editor *ed_ = nullptr;
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
#include "QtRenderer.h"
|
||||
|
||||
#include <QWidget>
|
||||
#include <QPainter>
|
||||
#include <QPaintEvent>
|
||||
#include <QFont>
|
||||
#include <QFontMetrics>
|
||||
|
||||
#include "Editor.h"
|
||||
|
||||
namespace {
|
||||
class EditorWidget : public QWidget {
|
||||
public:
|
||||
explicit EditorWidget(QWidget *parent = nullptr) : QWidget(parent)
|
||||
{
|
||||
setAttribute(Qt::WA_OpaquePaintEvent);
|
||||
setFocusPolicy(Qt::StrongFocus);
|
||||
}
|
||||
|
||||
|
||||
void SetEditor(Editor *ed)
|
||||
{
|
||||
ed_ = ed;
|
||||
}
|
||||
|
||||
protected:
|
||||
void paintEvent(QPaintEvent *event) override
|
||||
{
|
||||
Q_UNUSED(event);
|
||||
QPainter p(this);
|
||||
// Background
|
||||
const QColor bg(28, 28, 30);
|
||||
p.fillRect(rect(), bg);
|
||||
|
||||
// Font and metrics
|
||||
QFont f("JetBrains Mono", 13);
|
||||
p.setFont(f);
|
||||
QFontMetrics fm(f);
|
||||
const int line_h = fm.height();
|
||||
|
||||
// Title
|
||||
p.setPen(QColor(220, 220, 220));
|
||||
p.drawText(8, fm.ascent() + 4, QStringLiteral("kte (Qt frontend)"));
|
||||
|
||||
// Status bar at bottom
|
||||
const int bar_h = line_h + 6; // padding
|
||||
const int bar_y = height() - bar_h;
|
||||
QRect status_rect(0, bar_y, width(), bar_h);
|
||||
p.fillRect(status_rect, QColor(40, 40, 44));
|
||||
p.setPen(QColor(180, 180, 140));
|
||||
if (ed_) {
|
||||
const QString status = QString::fromStdString(ed_->Status());
|
||||
// draw at baseline within the bar
|
||||
const int baseline = bar_y + 3 + fm.ascent();
|
||||
p.drawText(8, baseline, status);
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
Editor *ed_ = nullptr;
|
||||
};
|
||||
} // namespace
|
||||
|
||||
void
|
||||
QtRenderer::Draw(Editor &ed)
|
||||
{
|
||||
if (!widget_)
|
||||
return;
|
||||
|
||||
// If our widget is an EditorWidget, pass the editor pointer for painting
|
||||
if (auto *ew = dynamic_cast<EditorWidget *>(widget_)) {
|
||||
ew->SetEditor(&ed);
|
||||
}
|
||||
// Request a repaint
|
||||
widget_->update();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* QtRenderer - minimal Qt-based renderer
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
#include "Renderer.h"
|
||||
|
||||
class QWidget;
|
||||
|
||||
class QtRenderer final : public Renderer {
|
||||
public:
|
||||
QtRenderer() = default;
|
||||
|
||||
~QtRenderer() override = default;
|
||||
|
||||
|
||||
void Attach(QWidget *widget)
|
||||
{
|
||||
widget_ = widget;
|
||||
}
|
||||
|
||||
|
||||
void Draw(Editor &ed) override;
|
||||
|
||||
private:
|
||||
QWidget *widget_ = nullptr; // not owned
|
||||
};
|
||||
@@ -32,27 +32,27 @@ Project Goals
|
||||
|
||||
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
|
||||
`C-g`.
|
||||
- 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).
|
||||
- Editing: `C-k d` (kill to EOL), `C-k C-d` (kill line), `C-k
|
||||
BACKSPACE` (kill to BOL), `C-w` (kill region), `C-y` ( yank), `C-u`
|
||||
(universal argument).
|
||||
- Editing: `C-k d` (kill to EOL), `C-k C-d` (kill line), `C-w` (kill
|
||||
region), `C-y` (yank), `C-u` (universal argument).
|
||||
- Navigation/Search: `C-s` (incremental find), `C-r` (regex search),
|
||||
`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`
|
||||
(close), `C-k C-r` (reload).
|
||||
- Misc: `C-l` (refresh), `C-g` (cancel), `C-k m` (run make), `C-k g`
|
||||
(goto line).
|
||||
(close), `C-k l` (reload).
|
||||
- Misc: `C-l` (refresh), `C-g` (cancel), `C-k g` (goto line).
|
||||
|
||||
See `ke.md` for the canonical ke reference retained for now.
|
||||
|
||||
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
|
||||
------------------------
|
||||
@@ -62,30 +62,38 @@ Dependencies by platform
|
||||
- `brew install ncurses`
|
||||
- Optional GUI (enable with `-DBUILD_GUI=ON`):
|
||||
- `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
|
||||
- Terminal (default):
|
||||
- `sudo apt-get install -y libncurses5-dev libncursesw5-dev`
|
||||
- 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
|
||||
- Terminal (default):
|
||||
- Ad-hoc shell: `nix-shell -p cmake gcc ncurses`
|
||||
- Optional GUI (enable with `-DBUILD_GUI=ON`):
|
||||
- Ad-hoc shell: `nix-shell -p cmake gcc ncurses SDL2 freetype libGL`
|
||||
- With flakes/devshell (example `flake.nix` inputs not provided): include
|
||||
`ncurses` for TUI, and `SDL2`, `freetype`, `libGL` for GUI in your devShell.
|
||||
- Ad-hoc shell:
|
||||
`nix-shell -p cmake gcc ncurses SDL2 freetype libGL`
|
||||
- With flakes/devshell (example `flake.nix` inputs not provided):
|
||||
include
|
||||
`ncurses` for TUI, and `SDL2`, `freetype`, `libGL` for GUI in your
|
||||
devShell.
|
||||
|
||||
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
|
||||
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`.
|
||||
|
||||
Example build:
|
||||
@@ -113,7 +121,8 @@ built as `kge`) or request the GUI from `kte`:
|
||||
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
|
||||
|
||||
+2502
File diff suppressed because it is too large
Load Diff
@@ -8,5 +8,6 @@ ROADMAP / TODO:
|
||||
- [x] When the filename is longer than the message window, scoot left to
|
||||
keep it in view
|
||||
- [x] Syntax highlighting
|
||||
- [ ] Swap files (crash recovery). See `docs/plans/swap-files.md`
|
||||
- [ ] The undo system should actually work
|
||||
- [ ] LSP integration
|
||||
|
||||
+1
-1
@@ -10,4 +10,4 @@ public:
|
||||
virtual ~Renderer() = default;
|
||||
|
||||
virtual void Draw(Editor &ed) = 0;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
// Swap.h - swap journal (crash recovery) writer/manager for kte
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
#include <unordered_map>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <condition_variable>
|
||||
#include <deque>
|
||||
#include <thread>
|
||||
#include <atomic>
|
||||
|
||||
#include "SwapRecorder.h"
|
||||
#include "ErrorRecovery.h"
|
||||
|
||||
class Buffer;
|
||||
|
||||
namespace kte {
|
||||
// Minimal record types for stage 1
|
||||
enum class SwapRecType : std::uint8_t {
|
||||
INS = 1,
|
||||
DEL = 2,
|
||||
SPLIT = 3,
|
||||
JOIN = 4,
|
||||
META = 0xF0,
|
||||
CHKPT = 0xFE,
|
||||
};
|
||||
|
||||
struct SwapConfig {
|
||||
// Grouping and durability knobs (stage 1 defaults)
|
||||
unsigned flush_interval_ms{200}; // group small writes
|
||||
unsigned fsync_interval_ms{1000}; // at most once per second
|
||||
|
||||
// Checkpoint/compaction knobs (stage 2 defaults)
|
||||
// A checkpoint is a full snapshot of the buffer content written as a CHKPT record.
|
||||
// Compaction rewrites the swap file to contain just the latest checkpoint.
|
||||
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
|
||||
|
||||
// Cleanup / retention (best-effort)
|
||||
bool prune_on_startup{true};
|
||||
unsigned prune_max_age_days{30};
|
||||
std::size_t prune_max_files{2048};
|
||||
};
|
||||
|
||||
// SwapManager manages sidecar swap files and a single background writer thread.
|
||||
class SwapManager final {
|
||||
public:
|
||||
SwapManager();
|
||||
|
||||
~SwapManager();
|
||||
|
||||
// Attach a buffer to begin journaling. Safe to call multiple times; idempotent.
|
||||
void Attach(Buffer *buf);
|
||||
|
||||
// Detach and close journal.
|
||||
// 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);
|
||||
|
||||
// Reset (truncate-by-delete) the journal for a buffer after a clean save.
|
||||
// Best-effort: closes the current fd, deletes the swap file, and resumes recording.
|
||||
void ResetJournal(Buffer &buf);
|
||||
|
||||
// Best-effort pruning of old swap files under the swap directory.
|
||||
// Never touches non-`.swp` files.
|
||||
void PruneSwapDir();
|
||||
|
||||
// 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);
|
||||
|
||||
// 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 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);
|
||||
|
||||
// Re-key an attached buffer's journal/recorder entries after its Buffer object
|
||||
// has moved to a new address (e.g. std::vector<Buffer> reallocation/erase-shift).
|
||||
// Callers must ensure no swap records for old_addr are in flight (see Flush())
|
||||
// before calling this, and must not call it with an address that isn't
|
||||
// currently attached. Returns the recorder for new_addr (nullptr if old_addr
|
||||
// wasn't attached); the caller is responsible for calling
|
||||
// new_buf->SetSwapRecorder() with the result.
|
||||
SwapRecorder *Rehome(Buffer *old_addr, Buffer *new_addr);
|
||||
|
||||
// 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
|
||||
class SuspendGuard {
|
||||
public:
|
||||
SuspendGuard(SwapManager &m, Buffer *b);
|
||||
|
||||
~SuspendGuard();
|
||||
|
||||
private:
|
||||
SwapManager &m_;
|
||||
Buffer *buf_;
|
||||
bool prev_;
|
||||
};
|
||||
|
||||
// Per-buffer toggle
|
||||
void SetSuspended(Buffer &buf, bool on);
|
||||
|
||||
// Error reporting for background thread
|
||||
struct SwapError {
|
||||
std::uint64_t timestamp_ns{0};
|
||||
std::string message;
|
||||
std::string buffer_name; // filename or "<unnamed>"
|
||||
};
|
||||
|
||||
// Query error state (thread-safe)
|
||||
bool HasErrors() const;
|
||||
|
||||
std::string GetLastError() const;
|
||||
|
||||
std::size_t GetErrorCount() const;
|
||||
|
||||
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 {
|
||||
std::string path;
|
||||
int fd{-1};
|
||||
bool header_ok{false};
|
||||
bool suspended{false};
|
||||
std::uint64_t last_flush_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 {
|
||||
Buffer *buf{nullptr};
|
||||
SwapRecType type{SwapRecType::INS};
|
||||
std::vector<std::uint8_t> payload; // framed payload only
|
||||
bool urgent_flush{false};
|
||||
std::uint64_t seq{0};
|
||||
};
|
||||
|
||||
// Helpers
|
||||
static std::string ComputeSidecarPath(const Buffer &buf);
|
||||
|
||||
static std::string ComputeSidecarPathForFilename(const std::string &filename);
|
||||
|
||||
static std::uint64_t now_ns();
|
||||
|
||||
static bool ensure_parent_dir(const std::string &path);
|
||||
|
||||
static std::string SwapDirRoot();
|
||||
|
||||
static bool write_header(int fd);
|
||||
|
||||
static bool open_ctx(JournalCtx &ctx, const std::string &path, std::string &err);
|
||||
|
||||
static void close_ctx(JournalCtx &ctx);
|
||||
|
||||
static bool compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8_t> &chkpt_record,
|
||||
std::string &err);
|
||||
|
||||
static std::uint32_t crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed = 0);
|
||||
|
||||
static void put_le32(std::vector<std::uint8_t> &out, 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 writer_loop();
|
||||
|
||||
void process_one(const Pending &p);
|
||||
|
||||
// Error reporting helper (called from writer thread)
|
||||
void report_error(const std::string &message, Buffer *buf = nullptr);
|
||||
|
||||
// State
|
||||
SwapConfig cfg_{};
|
||||
std::unordered_map<Buffer *, JournalCtx> journals_;
|
||||
std::unordered_map<Buffer *, std::unique_ptr<BufferRecorder> > recorders_;
|
||||
mutable std::mutex mtx_;
|
||||
std::condition_variable cv_;
|
||||
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::thread worker_;
|
||||
|
||||
// Error tracking (protected by mtx_)
|
||||
std::deque<SwapError> errors_; // bounded to max 100 entries
|
||||
std::size_t total_error_count_{0};
|
||||
|
||||
// Circuit breaker for swap operations (protected by mtx_)
|
||||
CircuitBreaker circuit_breaker_;
|
||||
};
|
||||
} // namespace kte
|
||||
@@ -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
|
||||
@@ -0,0 +1,76 @@
|
||||
#include "SyscallWrappers.h"
|
||||
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <cerrno>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace kte {
|
||||
namespace syscall {
|
||||
int
|
||||
Open(const char *path, int flags, mode_t mode)
|
||||
{
|
||||
int fd;
|
||||
do {
|
||||
fd = ::open(path, flags, mode);
|
||||
} while (fd == -1 && errno == EINTR);
|
||||
return fd;
|
||||
}
|
||||
|
||||
|
||||
int
|
||||
Close(int fd)
|
||||
{
|
||||
int ret;
|
||||
do {
|
||||
ret = ::close(fd);
|
||||
} while (ret == -1 && errno == EINTR);
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
int
|
||||
Fsync(int fd)
|
||||
{
|
||||
int ret;
|
||||
do {
|
||||
ret = ::fsync(fd);
|
||||
} while (ret == -1 && errno == EINTR);
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
int
|
||||
Fstat(int fd, struct stat *buf)
|
||||
{
|
||||
int ret;
|
||||
do {
|
||||
ret = ::fstat(fd, buf);
|
||||
} while (ret == -1 && errno == EINTR);
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
int
|
||||
Fchmod(int fd, mode_t mode)
|
||||
{
|
||||
int ret;
|
||||
do {
|
||||
ret = ::fchmod(fd, mode);
|
||||
} while (ret == -1 && errno == EINTR);
|
||||
return ret;
|
||||
}
|
||||
|
||||
|
||||
int
|
||||
Mkstemp(char *template_str)
|
||||
{
|
||||
int fd;
|
||||
do {
|
||||
fd = ::mkstemp(template_str);
|
||||
} while (fd == -1 && errno == EINTR);
|
||||
return fd;
|
||||
}
|
||||
} // namespace syscall
|
||||
} // namespace kte
|
||||
@@ -0,0 +1,47 @@
|
||||
// SyscallWrappers.h - EINTR-safe syscall wrappers for kte
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <cstddef>
|
||||
#include <sys/stat.h>
|
||||
|
||||
namespace kte {
|
||||
namespace syscall {
|
||||
// EINTR-safe wrapper for open(2).
|
||||
// Returns file descriptor on success, -1 on failure (errno set).
|
||||
// Automatically retries on EINTR.
|
||||
int Open(const char *path, int flags, mode_t mode = 0);
|
||||
|
||||
// EINTR-safe wrapper for close(2).
|
||||
// Returns 0 on success, -1 on failure (errno set).
|
||||
// Automatically retries on EINTR.
|
||||
// Note: Some systems may not restart close() on EINTR, but we retry anyway
|
||||
// as recommended by POSIX.1-2008.
|
||||
int Close(int fd);
|
||||
|
||||
// EINTR-safe wrapper for fsync(2).
|
||||
// Returns 0 on success, -1 on failure (errno set).
|
||||
// Automatically retries on EINTR.
|
||||
int Fsync(int fd);
|
||||
|
||||
// EINTR-safe wrapper for fstat(2).
|
||||
// Returns 0 on success, -1 on failure (errno set).
|
||||
// Automatically retries on EINTR.
|
||||
int Fstat(int fd, struct stat *buf);
|
||||
|
||||
// EINTR-safe wrapper for fchmod(2).
|
||||
// Returns 0 on success, -1 on failure (errno set).
|
||||
// Automatically retries on EINTR.
|
||||
int Fchmod(int fd, mode_t mode);
|
||||
|
||||
// EINTR-safe wrapper for mkstemp(3).
|
||||
// Returns file descriptor on success, -1 on failure (errno set).
|
||||
// Automatically retries on EINTR.
|
||||
// Note: template_str must be a mutable buffer ending in "XXXXXX".
|
||||
int Mkstemp(char *template_str);
|
||||
|
||||
// Note: rename(2) and unlink(2) are not wrapped because they operate on
|
||||
// filesystem metadata and typically complete atomically without EINTR.
|
||||
// If interrupted, they either succeed or fail without partial state.
|
||||
} // namespace syscall
|
||||
} // namespace kte
|
||||
+33
-7
@@ -8,8 +8,10 @@
|
||||
|
||||
|
||||
bool
|
||||
TerminalFrontend::Init(Editor &ed)
|
||||
TerminalFrontend::Init(int &argc, char **argv, Editor &ed)
|
||||
{
|
||||
(void) argc;
|
||||
(void) argv;
|
||||
// Ensure Control keys reach the app: disable XON/XOFF and dsusp/susp bindings (e.g., ^S/^Q, ^Y on macOS)
|
||||
{
|
||||
struct termios tio{};
|
||||
@@ -42,19 +44,38 @@ TerminalFrontend::Init(Editor &ed)
|
||||
meta(stdscr, TRUE);
|
||||
// Make ESC key sequences resolve quickly so ESC+<key> works as meta
|
||||
#ifdef set_escdelay
|
||||
set_escdelay(50);
|
||||
set_escdelay(TerminalFrontend::kEscDelayMs);
|
||||
#endif
|
||||
nodelay(stdscr, TRUE);
|
||||
// Make getch() block briefly instead of busy-looping; reduces CPU when idle
|
||||
// Equivalent to nodelay(FALSE) with a small timeout.
|
||||
timeout(16); // ~16ms (about 60Hz)
|
||||
curs_set(1);
|
||||
// Enable mouse support if available
|
||||
mouseinterval(0);
|
||||
mousemask(ALL_MOUSE_EVENTS, nullptr);
|
||||
mousemask(ALL_MOUSE_EVENTS | REPORT_MOUSE_POSITION, nullptr);
|
||||
|
||||
int r = 0, c = 0;
|
||||
getmaxyx(stdscr, r, c);
|
||||
prev_r_ = r;
|
||||
prev_c_ = c;
|
||||
ed.SetDimensions(static_cast<std::size_t>(r), static_cast<std::size_t>(c));
|
||||
// Attach editor to input handler for editor-owned features (e.g., universal argument)
|
||||
input_.Attach(&ed);
|
||||
|
||||
// Ignore SIGINT (Ctrl-C) so it doesn't terminate the TUI.
|
||||
// We'll restore the previous handler on Shutdown().
|
||||
{
|
||||
struct sigaction sa{};
|
||||
sa.sa_handler = SIG_IGN;
|
||||
sigemptyset(&sa.sa_mask);
|
||||
sa.sa_flags = 0;
|
||||
struct sigaction old{};
|
||||
if (sigaction(SIGINT, &sa, &old) == 0) {
|
||||
old_sigint_ = old;
|
||||
have_old_sigint_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -73,14 +94,14 @@ TerminalFrontend::Step(Editor &ed, bool &running)
|
||||
}
|
||||
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;
|
||||
if (input_.Poll(mi)) {
|
||||
if (mi.hasCommand) {
|
||||
Execute(ed, mi.id, mi.arg, mi.count);
|
||||
}
|
||||
} else {
|
||||
// Avoid busy loop
|
||||
usleep(1000);
|
||||
}
|
||||
|
||||
if (ed.QuitRequested()) {
|
||||
@@ -99,5 +120,10 @@ TerminalFrontend::Shutdown()
|
||||
(void) tcsetattr(STDIN_FILENO, TCSANOW, &orig_tio_);
|
||||
have_orig_tio_ = false;
|
||||
}
|
||||
// Restore previous SIGINT handler
|
||||
if (have_old_sigint_) {
|
||||
(void) sigaction(SIGINT, &old_sigint_, nullptr);
|
||||
have_old_sigint_ = false;
|
||||
}
|
||||
endwin();
|
||||
}
|
||||
|
||||
+11
-2
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
#pragma once
|
||||
#include <termios.h>
|
||||
#include <signal.h>
|
||||
|
||||
#include "Frontend.h"
|
||||
#include "TerminalInputHandler.h"
|
||||
@@ -15,7 +16,12 @@ public:
|
||||
|
||||
~TerminalFrontend() override = default;
|
||||
|
||||
bool Init(Editor &ed) override;
|
||||
// Configurable ESC key delay (ms) for ncurses' set_escdelay().
|
||||
// Controls how long ncurses waits to distinguish ESC vs. meta sequences.
|
||||
// Adjust if your terminal needs a different threshold.
|
||||
static constexpr int kEscDelayMs = 50;
|
||||
|
||||
bool Init(int &argc, char **argv, Editor &ed) override;
|
||||
|
||||
void Step(Editor &ed, bool &running) override;
|
||||
|
||||
@@ -29,4 +35,7 @@ private:
|
||||
// Saved terminal attributes to restore on shutdown
|
||||
bool have_orig_tio_ = false;
|
||||
struct termios orig_tio_{};
|
||||
};
|
||||
// Saved SIGINT handler to restore on shutdown
|
||||
bool have_old_sigint_ = false;
|
||||
struct sigaction old_sigint_{};
|
||||
};
|
||||
|
||||
+165
-126
@@ -1,8 +1,12 @@
|
||||
#include <cstdio>
|
||||
#include <climits>
|
||||
#include <cwchar>
|
||||
#include <ncurses.h>
|
||||
|
||||
#include "TerminalInputHandler.h"
|
||||
#include "KKeymap.h"
|
||||
#include "Command.h"
|
||||
#include "Editor.h"
|
||||
|
||||
namespace {
|
||||
constexpr int
|
||||
@@ -10,6 +14,22 @@ CTRL(char c)
|
||||
{
|
||||
return c & 0x1F;
|
||||
}
|
||||
|
||||
|
||||
// Encode a single wide character in the process locale's multibyte encoding
|
||||
// (UTF-8, given main.cc's setlocale(LC_ALL, "")). Returns false if the
|
||||
// codepoint can't be represented, leaving `out` untouched.
|
||||
bool
|
||||
wchar_to_mb(wchar_t wc, std::string &out)
|
||||
{
|
||||
std::mbstate_t state{};
|
||||
char buf[MB_LEN_MAX];
|
||||
std::size_t n = std::wcrtomb(buf, wc, &state);
|
||||
if (n == static_cast<std::size_t>(-1))
|
||||
return false;
|
||||
out.assign(buf, n);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
TerminalInputHandler::TerminalInputHandler() = default;
|
||||
@@ -19,22 +39,30 @@ TerminalInputHandler::~TerminalInputHandler() = default;
|
||||
|
||||
static bool
|
||||
map_key_to_command(const int ch,
|
||||
const bool is_keycode,
|
||||
bool &k_prefix,
|
||||
bool &esc_meta,
|
||||
// universal-argument state (by ref)
|
||||
bool &uarg_active,
|
||||
bool &uarg_collecting,
|
||||
bool &uarg_negative,
|
||||
bool &uarg_had_digits,
|
||||
int &uarg_value,
|
||||
std::string &uarg_text,
|
||||
bool &k_ctrl_pending,
|
||||
bool &mouse_selecting,
|
||||
Editor *ed,
|
||||
MappedInput &out)
|
||||
{
|
||||
// Handle special keys from ncurses
|
||||
// Handle special keys from ncurses. These are only meaningful when
|
||||
// get_wch() reported KEY_CODE_YES: a regular (possibly non-ASCII) wide
|
||||
// character can numerically collide with a KEY_* constant otherwise
|
||||
// (e.g. U+0107 'ć' equals KEY_BACKSPACE's value), which would wrongly
|
||||
// swallow it as a special key instead of inserting it.
|
||||
// These keys exit k-prefix mode if active (user pressed C-k then a special key).
|
||||
switch (ch) {
|
||||
case KEY_MOUSE: {
|
||||
switch (is_keycode ? ch : -1) {
|
||||
case KEY_ENTER:
|
||||
// Some terminals send KEY_ENTER distinct from '\n'/'\r'
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::Newline, "", 0};
|
||||
return true;
|
||||
case KEY_MOUSE: {
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
MEVENT ev{};
|
||||
if (getmouse(&ev) == OK) {
|
||||
// Mouse wheel → scroll viewport without moving cursor
|
||||
@@ -51,12 +79,40 @@ map_key_to_command(const int ch,
|
||||
}
|
||||
#endif
|
||||
// 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];
|
||||
// Use screen coordinates; command handler will translate via offsets
|
||||
std::snprintf(buf, sizeof(buf), "@%d:%d", ev.y, ev.x);
|
||||
out = {true, CommandId::MoveCursorTo, std::string(buf), 0};
|
||||
return true;
|
||||
const bool pressed = (ev.bstate & (BUTTON1_PRESSED | BUTTON1_CLICKED)) != 0;
|
||||
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));
|
||||
// We don't set the mark on simple click anymore in ncurses either,
|
||||
// to be consistent. ncurses doesn't easily support double-click
|
||||
// or drag-threshold in a platform-independent way here,
|
||||
// but we can at least only set mark on MOVED.
|
||||
out.hasCommand = false;
|
||||
return true;
|
||||
}
|
||||
if (mouse_selecting && moved) {
|
||||
if (Buffer *b = ed->CurrentBuffer()) {
|
||||
if (!b->MarkSet()) {
|
||||
// Set mark at CURRENT cursor position (which is where we were before this move)
|
||||
b->SetMark(b->Curx(), b->Cury());
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -65,43 +121,53 @@ map_key_to_command(const int ch,
|
||||
}
|
||||
case KEY_LEFT:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::MoveLeft, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveLeft, "", 0};
|
||||
return true;
|
||||
case KEY_RIGHT:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::MoveRight, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveRight, "", 0};
|
||||
return true;
|
||||
case KEY_UP:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::MoveUp, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveUp, "", 0};
|
||||
return true;
|
||||
case KEY_DOWN:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::MoveDown, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveDown, "", 0};
|
||||
return true;
|
||||
case KEY_HOME:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::MoveHome, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveHome, "", 0};
|
||||
return true;
|
||||
case KEY_END:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::MoveEnd, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveEnd, "", 0};
|
||||
return true;
|
||||
case KEY_PPAGE:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::PageUp, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::PageUp, "", 0};
|
||||
return true;
|
||||
case KEY_NPAGE:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::PageDown, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::PageDown, "", 0};
|
||||
return true;
|
||||
case KEY_DC:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::DeleteChar, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::DeleteChar, "", 0};
|
||||
return true;
|
||||
case KEY_RESIZE:
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::Refresh, "", 0};
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::Refresh, "", 0};
|
||||
return true;
|
||||
default:
|
||||
break;
|
||||
@@ -111,6 +177,7 @@ map_key_to_command(const int ch,
|
||||
if (ch == 27) {
|
||||
// ESC
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
esc_meta = true; // next key will be considered meta-modified
|
||||
out.hasCommand = false; // no command yet
|
||||
return true;
|
||||
@@ -119,59 +186,33 @@ map_key_to_command(const int ch,
|
||||
// Control keys
|
||||
if (ch == CTRL('K')) {
|
||||
// C-k prefix
|
||||
k_prefix = true;
|
||||
out = {true, CommandId::KPrefix, "", 0};
|
||||
k_prefix = true;
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::KPrefix, "", 0};
|
||||
return true;
|
||||
}
|
||||
if (ch == CTRL('G')) {
|
||||
// cancel
|
||||
k_prefix = false;
|
||||
esc_meta = false;
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
esc_meta = false;
|
||||
// cancel universal argument as well
|
||||
uarg_active = false;
|
||||
uarg_collecting = false;
|
||||
uarg_negative = false;
|
||||
uarg_had_digits = false;
|
||||
uarg_value = 0;
|
||||
uarg_text.clear();
|
||||
if (ed)
|
||||
ed->UArgClear();
|
||||
out = {true, CommandId::Refresh, "", 0};
|
||||
return true;
|
||||
}
|
||||
// Universal argument: C-u
|
||||
if (ch == CTRL('U')) {
|
||||
// Start or extend universal argument
|
||||
if (!uarg_active) {
|
||||
uarg_active = true;
|
||||
uarg_collecting = true;
|
||||
uarg_negative = false;
|
||||
uarg_had_digits = false;
|
||||
uarg_value = 4; // default
|
||||
// Reset collected text and emit status update
|
||||
uarg_text.clear();
|
||||
out = {true, CommandId::UArgStatus, uarg_text, 0};
|
||||
return true;
|
||||
} else if (uarg_collecting && !uarg_had_digits && !uarg_negative) {
|
||||
// Bare repeated C-u multiplies by 4
|
||||
if (uarg_value <= 0)
|
||||
uarg_value = 4;
|
||||
else
|
||||
uarg_value *= 4;
|
||||
// Keep showing status (no digits yet)
|
||||
out = {true, CommandId::UArgStatus, uarg_text, 0};
|
||||
return true;
|
||||
} else {
|
||||
// If digits or '-' have been entered, C-u ends the argument (ready for next command)
|
||||
uarg_collecting = false;
|
||||
if (!uarg_had_digits && !uarg_negative && uarg_value <= 0)
|
||||
uarg_value = 4;
|
||||
}
|
||||
// No command produced by C-u itself
|
||||
out.hasCommand = false;
|
||||
if (ed)
|
||||
ed->UArgStart();
|
||||
out.hasCommand = false; // C-u itself doesn't issue a command
|
||||
return true;
|
||||
}
|
||||
// Tab (note: terminals encode Tab and C-i as the same code 9)
|
||||
if (ch == '\t') {
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
out.hasCommand = true;
|
||||
out.id = CommandId::InsertText;
|
||||
out.arg = "\t";
|
||||
@@ -182,22 +223,40 @@ map_key_to_command(const int ch,
|
||||
// IMPORTANT: if we're in k-prefix, the very next key must be interpreted
|
||||
// via the C-k keymap first, even if it's a Control chord like C-d.
|
||||
if (k_prefix) {
|
||||
k_prefix = false; // consume the prefix for this one key
|
||||
// In k-prefix: allow a control qualifier via literal 'C' or '^'
|
||||
// Detect Control keycodes first
|
||||
bool ctrl = false;
|
||||
int ascii_key = ch;
|
||||
if (ch >= 1 && ch <= 26) {
|
||||
ctrl = true;
|
||||
ascii_key = 'a' + (ch - 1);
|
||||
}
|
||||
// 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).
|
||||
if (ascii_key == 'C' || ascii_key == '^') {
|
||||
k_ctrl_pending = true;
|
||||
if (ed)
|
||||
ed->SetStatus("C-k C _");
|
||||
out.hasCommand = false;
|
||||
return true;
|
||||
}
|
||||
// For actual suffix, consume the k-prefix
|
||||
k_prefix = false;
|
||||
// Do NOT lowercase here; KLookupKCommand handles case-sensitive bindings
|
||||
CommandId id;
|
||||
if (KLookupKCommand(ascii_key, ctrl, id)) {
|
||||
bool pass_ctrl = (ctrl || k_ctrl_pending);
|
||||
k_ctrl_pending = false;
|
||||
if (KLookupKCommand(ascii_key, pass_ctrl, id)) {
|
||||
out = {true, id, "", 0};
|
||||
if (ed)
|
||||
ed->SetStatus(""); // clear "C-k _" hint after suffix
|
||||
} else {
|
||||
int shown = KLowerAscii(ascii_key);
|
||||
char c = (shown >= 0x20 && shown <= 0x7e) ? static_cast<char>(shown) : '?';
|
||||
std::string arg(1, c);
|
||||
out = {true, CommandId::UnknownKCommand, arg, 0};
|
||||
if (ed)
|
||||
ed->SetStatus(""); // clear hint; handler will set unknown status
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -213,8 +272,9 @@ map_key_to_command(const int ch,
|
||||
|
||||
// Enter
|
||||
if (ch == '\n' || ch == '\r') {
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::Newline, "", 0};
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::Newline, "", 0};
|
||||
return true;
|
||||
}
|
||||
// If previous key was ESC, interpret as meta and use ESC keymap
|
||||
@@ -222,8 +282,14 @@ map_key_to_command(const int ch,
|
||||
esc_meta = false;
|
||||
int ascii_key = ch;
|
||||
// Handle ESC + BACKSPACE (meta-backspace, Alt-Backspace)
|
||||
if (ch == KEY_BACKSPACE || ch == 127 || ch == CTRL('H')) {
|
||||
if ((is_keycode && ch == KEY_BACKSPACE) || ch == 127 || ch == CTRL('H')) {
|
||||
ascii_key = KEY_BACKSPACE; // normalized value for lookup
|
||||
} else if (ch == ',') {
|
||||
// Some terminals emit ',' when Shift state is lost after ESC; treat as '<'
|
||||
ascii_key = '<';
|
||||
} else if (ch == '.') {
|
||||
// Likewise, map '.' to '>'
|
||||
ascii_key = '>';
|
||||
} else if (ascii_key >= 'A' && ascii_key <= 'Z') {
|
||||
ascii_key = ascii_key - 'A' + 'a';
|
||||
}
|
||||
@@ -232,56 +298,43 @@ map_key_to_command(const int ch,
|
||||
out = {true, id, "", 0};
|
||||
return true;
|
||||
}
|
||||
// Unhandled meta key: no command
|
||||
out.hasCommand = false;
|
||||
// Unhandled ESC sequence: exit escape mode and show status
|
||||
out = {true, CommandId::UnknownEscCommand, "", 0};
|
||||
return true;
|
||||
}
|
||||
|
||||
// Backspace in ncurses can be KEY_BACKSPACE or 127
|
||||
if (ch == KEY_BACKSPACE || ch == 127 || ch == CTRL('H')) {
|
||||
k_prefix = false;
|
||||
out = {true, CommandId::Backspace, "", 0};
|
||||
if ((is_keycode && ch == KEY_BACKSPACE) || ch == 127 || ch == CTRL('H')) {
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::Backspace, "", 0};
|
||||
return true;
|
||||
}
|
||||
|
||||
// k_prefix handled earlier
|
||||
|
||||
// If collecting universal arg, handle digits and optional leading '-'
|
||||
if (uarg_active && uarg_collecting) {
|
||||
if (ch >= '0' && ch <= '9') {
|
||||
int d = ch - '0';
|
||||
if (!uarg_had_digits) {
|
||||
// First digit overrides any 4^n default
|
||||
uarg_value = 0;
|
||||
uarg_had_digits = true;
|
||||
}
|
||||
if (uarg_value < 100000000) {
|
||||
// avoid overflow
|
||||
uarg_value = uarg_value * 10 + d;
|
||||
}
|
||||
// Update raw text and status to reflect collected digits
|
||||
uarg_text.push_back(static_cast<char>(ch));
|
||||
out = {true, CommandId::UArgStatus, uarg_text, 0};
|
||||
return true;
|
||||
}
|
||||
if (ch == '-' && !uarg_had_digits && !uarg_negative) {
|
||||
uarg_negative = true;
|
||||
// Show leading minus in status
|
||||
uarg_text = "-";
|
||||
out = {true, CommandId::UArgStatus, uarg_text, 0};
|
||||
return true;
|
||||
}
|
||||
// Any other key will be processed as a command; fall through to mapping below
|
||||
// but mark collection finished so we apply the argument to that command
|
||||
uarg_collecting = false;
|
||||
// If universal argument is active at editor level and we get a digit, feed it
|
||||
if (ed && ed->UArg() != 0 && ch >= '0' && ch <= '9') {
|
||||
ed->UArgDigit(ch - '0');
|
||||
out.hasCommand = false; // keep collecting, no command yet
|
||||
return true;
|
||||
}
|
||||
|
||||
// Printable ASCII
|
||||
if (ch >= 0x20 && ch <= 0x7E) {
|
||||
// Printable character: ASCII, or (for a regular decoded wide character,
|
||||
// not a keycode) any other printable Unicode codepoint - e.g. accented
|
||||
// Latin, Cyrillic, CJK, etc.
|
||||
if (!is_keycode && ch >= 0x20 && ch != 0x7F) {
|
||||
std::string mb;
|
||||
if (ch <= 0x7E) {
|
||||
mb.assign(1, static_cast<char>(ch));
|
||||
} else if (!wchar_to_mb(static_cast<wchar_t>(ch), mb)) {
|
||||
out.hasCommand = false;
|
||||
return true;
|
||||
}
|
||||
out.hasCommand = true;
|
||||
out.id = CommandId::InsertText;
|
||||
out.arg.assign(1, static_cast<char>(ch));
|
||||
out.count = 0;
|
||||
out.arg = mb;
|
||||
out.count = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -293,36 +346,22 @@ map_key_to_command(const int ch,
|
||||
bool
|
||||
TerminalInputHandler::decode_(MappedInput &out)
|
||||
{
|
||||
int ch = getch();
|
||||
if (ch == ERR) {
|
||||
wint_t wch;
|
||||
int ret = get_wch(&wch);
|
||||
if (ret == ERR) {
|
||||
return false; // no input
|
||||
}
|
||||
const bool is_keycode = (ret == KEY_CODE_YES);
|
||||
const int ch = static_cast<int>(wch);
|
||||
bool consumed = map_key_to_command(
|
||||
ch,
|
||||
ch, is_keycode,
|
||||
k_prefix_, esc_meta_,
|
||||
uarg_active_, uarg_collecting_, uarg_negative_, uarg_had_digits_, uarg_value_, uarg_text_,
|
||||
k_ctrl_pending_,
|
||||
mouse_selecting_,
|
||||
ed_,
|
||||
out);
|
||||
if (!consumed)
|
||||
return false;
|
||||
// If a command was produced and a universal argument is active, attach it and clear state
|
||||
if (out.hasCommand && uarg_active_ && out.id != CommandId::UArgStatus) {
|
||||
int count = 0;
|
||||
if (!uarg_had_digits_ && !uarg_negative_) {
|
||||
// No explicit digits: use current value (default 4 or 4^n)
|
||||
count = (uarg_value_ > 0) ? uarg_value_ : 4;
|
||||
} else {
|
||||
count = uarg_value_;
|
||||
if (uarg_negative_)
|
||||
count = -count;
|
||||
}
|
||||
out.count = count;
|
||||
// Clear state
|
||||
uarg_active_ = false;
|
||||
uarg_collecting_ = false;
|
||||
uarg_negative_ = false;
|
||||
uarg_had_digits_ = false;
|
||||
uarg_value_ = 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
+14
-8
@@ -11,6 +11,13 @@ public:
|
||||
|
||||
~TerminalInputHandler() override;
|
||||
|
||||
|
||||
void Attach(Editor *ed) override
|
||||
{
|
||||
ed_ = ed;
|
||||
}
|
||||
|
||||
|
||||
bool Poll(MappedInput &out) override;
|
||||
|
||||
private:
|
||||
@@ -18,14 +25,13 @@ private:
|
||||
|
||||
// ke-style prefix state
|
||||
bool k_prefix_ = false; // true after C-k until next key or ESC
|
||||
// Optional control qualifier inside k-prefix (e.g., user typed literal 'C' or '^')
|
||||
bool k_ctrl_pending_ = false;
|
||||
// Simple meta (ESC) state for ESC sequences like ESC b/f
|
||||
bool esc_meta_ = false;
|
||||
|
||||
// Universal argument (C-u) state
|
||||
bool uarg_active_ = false; // an argument is pending for the next command
|
||||
bool uarg_collecting_ = false; // collecting digits / '-' right now
|
||||
bool uarg_negative_ = false; // whether a leading '-' was supplied
|
||||
bool uarg_had_digits_ = false; // whether any digits were supplied
|
||||
int uarg_value_ = 0; // current absolute value (>=0)
|
||||
std::string uarg_text_; // raw digits/minus typed for status display
|
||||
};
|
||||
// Mouse drag selection state
|
||||
bool mouse_selecting_ = false;
|
||||
|
||||
Editor *ed_ = nullptr; // attached editor for uarg handling
|
||||
};
|
||||
|
||||
+256
-111
@@ -1,3 +1,6 @@
|
||||
#include <clocale>
|
||||
#define _XOPEN_SOURCE_EXTENDED 1
|
||||
#include <cwchar>
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
@@ -104,63 +107,168 @@ TerminalRenderer::Draw(Editor &ed)
|
||||
const std::size_t cur_mx = has_current ? ed.SearchMatchX() : 0;
|
||||
const std::size_t cur_my = has_current ? ed.SearchMatchY() : 0;
|
||||
const std::size_t cur_mend = has_current ? (ed.SearchMatchX() + ed.SearchMatchLen()) : 0;
|
||||
bool hl_on = false;
|
||||
bool cur_on = false;
|
||||
int written = 0;
|
||||
|
||||
// Mark selection (mark -> cursor), in source coordinates
|
||||
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()) {
|
||||
std::string line = static_cast<std::string>(lines[li]);
|
||||
src_i = 0;
|
||||
render_col = 0;
|
||||
// Syntax highlighting: fetch per-line spans
|
||||
const kte::LineHighlight *lh_ptr = nullptr;
|
||||
std::string line = static_cast<std::string>(lines[li]);
|
||||
const bool vsel_on_line = vsel_active && li >= vsel_sy && li <= vsel_ey;
|
||||
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)
|
||||
std::vector<kte::HighlightSpan> sane_spans;
|
||||
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->
|
||||
HasHighlighter()) {
|
||||
lh_ptr = &buf->Highlighter()->GetLine(
|
||||
kte::LineHighlight lh_val = buf->Highlighter()->GetLine(
|
||||
*buf, static_cast<int>(li), buf->Version());
|
||||
// Sanitize defensively: clamp to [0, line.size()], ensure end>=start, drop empties
|
||||
const std::size_t line_len = line.size();
|
||||
sane_spans.reserve(lh_val.spans.size());
|
||||
for (const auto &sp: lh_val.spans) {
|
||||
int s_raw = sp.col_start;
|
||||
int e_raw = sp.col_end;
|
||||
if (e_raw < s_raw)
|
||||
std::swap(e_raw, s_raw);
|
||||
std::size_t s = static_cast<std::size_t>(std::max(
|
||||
0, std::min(s_raw, static_cast<int>(line_len))));
|
||||
std::size_t e = static_cast<std::size_t>(std::max(
|
||||
static_cast<int>(s),
|
||||
std::min(e_raw, static_cast<int>(line_len))));
|
||||
if (e <= s)
|
||||
continue;
|
||||
sane_spans.push_back(kte::HighlightSpan{
|
||||
static_cast<int>(s), static_cast<int>(e), sp.kind
|
||||
});
|
||||
}
|
||||
std::sort(sane_spans.begin(), sane_spans.end(),
|
||||
[](const kte::HighlightSpan &a, const kte::HighlightSpan &b) {
|
||||
return a.col_start < b.col_start;
|
||||
});
|
||||
}
|
||||
auto token_at = [&](std::size_t src_index) -> kte::TokenKind {
|
||||
if (!lh_ptr)
|
||||
if (sane_spans.empty())
|
||||
return kte::TokenKind::Default;
|
||||
for (const auto &sp: lh_ptr->spans) {
|
||||
if (static_cast<int>(src_index) >= sp.col_start && static_cast<int>(
|
||||
src_index) < sp.col_end)
|
||||
int si = static_cast<int>(src_index);
|
||||
for (const auto &sp: sane_spans) {
|
||||
if (si < sp.col_start)
|
||||
break;
|
||||
if (si >= sp.col_start && si < sp.col_end)
|
||||
return sp.kind;
|
||||
}
|
||||
return kte::TokenKind::Default;
|
||||
};
|
||||
auto apply_token_attr = [&](kte::TokenKind k) {
|
||||
// Map to simple attributes; search highlight uses A_STANDOUT which takes precedence below
|
||||
attrset(A_NORMAL);
|
||||
auto token_attr = [&](kte::TokenKind k) -> attr_t {
|
||||
switch (k) {
|
||||
case kte::TokenKind::Keyword:
|
||||
case kte::TokenKind::Type:
|
||||
case kte::TokenKind::Constant:
|
||||
case kte::TokenKind::Function:
|
||||
attron(A_BOLD);
|
||||
break;
|
||||
return A_BOLD;
|
||||
case kte::TokenKind::Comment:
|
||||
attron(A_DIM);
|
||||
break;
|
||||
return A_DIM;
|
||||
case kte::TokenKind::String:
|
||||
case kte::TokenKind::Char:
|
||||
case kte::TokenKind::Number:
|
||||
// standout a bit using A_UNDERLINE if available
|
||||
attron(A_UNDERLINE);
|
||||
break;
|
||||
return A_UNDERLINE;
|
||||
default:
|
||||
break;
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
while (written < cols) {
|
||||
char ch = ' ';
|
||||
bool from_src = false;
|
||||
wchar_t wch = L' ';
|
||||
int wch_len = 1;
|
||||
int disp_w = 1;
|
||||
|
||||
if (src_i < line.size()) {
|
||||
unsigned char c = static_cast<unsigned char>(line[src_i]);
|
||||
if (c == '\t') {
|
||||
// Decode UTF-8
|
||||
std::mbstate_t state = std::mbstate_t();
|
||||
size_t res = std::mbrtowc(
|
||||
&wch, &line[src_i], line.size() - src_i, &state);
|
||||
if (res == (size_t) -1 || res == (size_t) -2) {
|
||||
// Invalid or incomplete; treat as single byte
|
||||
wch = static_cast<unsigned char>(line[src_i]);
|
||||
wch_len = 1;
|
||||
} else if (res == 0) {
|
||||
wch = L'\0';
|
||||
wch_len = 1;
|
||||
} else {
|
||||
wch_len = static_cast<int>(res);
|
||||
}
|
||||
|
||||
if (wch == L'\t') {
|
||||
std::size_t next_tab = tabw - (render_col % tabw);
|
||||
if (render_col + next_tab <= coloffs) {
|
||||
render_col += next_tab;
|
||||
++src_i;
|
||||
src_i += wch_len;
|
||||
continue;
|
||||
}
|
||||
// Emit spaces for tab
|
||||
@@ -169,111 +277,148 @@ TerminalRenderer::Draw(Editor &ed)
|
||||
std::size_t to_skip = std::min<std::size_t>(
|
||||
next_tab, coloffs - render_col);
|
||||
render_col += to_skip;
|
||||
next_tab -= to_skip;
|
||||
next_tab -= to_skip;
|
||||
}
|
||||
// Now render visible spaces
|
||||
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_cur =
|
||||
has_current && li == cur_my && src_i >= cur_mx
|
||||
&& src_i < cur_mend;
|
||||
// Toggle highlight attributes
|
||||
int attr = 0;
|
||||
if (in_hl)
|
||||
attr |= A_STANDOUT;
|
||||
if (in_cur)
|
||||
attr |= A_BOLD;
|
||||
if ((attr & A_STANDOUT) && !hl_on) {
|
||||
attron(A_STANDOUT);
|
||||
hl_on = true;
|
||||
}
|
||||
if (!(attr & A_STANDOUT) && hl_on) {
|
||||
attroff(A_STANDOUT);
|
||||
hl_on = false;
|
||||
}
|
||||
if ((attr & A_BOLD) && !cur_on) {
|
||||
attron(A_BOLD);
|
||||
cur_on = true;
|
||||
}
|
||||
if (!(attr & A_BOLD) && cur_on) {
|
||||
attroff(A_BOLD);
|
||||
cur_on = false;
|
||||
}
|
||||
// Apply syntax attribute only if not in search highlight
|
||||
if (!in_hl) {
|
||||
apply_token_attr(token_at(src_i));
|
||||
&&
|
||||
src_i < cur_mend;
|
||||
attr_t a = A_NORMAL;
|
||||
a |= token_attr(token_at(src_i));
|
||||
if (in_sel) {
|
||||
a |= A_REVERSE;
|
||||
} else {
|
||||
if (in_hl)
|
||||
a |= A_STANDOUT;
|
||||
if (in_cur)
|
||||
a |= A_BOLD;
|
||||
}
|
||||
attrset(a);
|
||||
addch(' ');
|
||||
++written;
|
||||
++render_col;
|
||||
--next_tab;
|
||||
}
|
||||
++src_i;
|
||||
src_i += wch_len;
|
||||
continue;
|
||||
} else {
|
||||
// normal char
|
||||
disp_w = wcwidth(wch);
|
||||
if (disp_w < 0)
|
||||
disp_w = 1; // non-printable or similar
|
||||
|
||||
if (render_col < coloffs) {
|
||||
++render_col;
|
||||
++src_i;
|
||||
render_col += disp_w;
|
||||
src_i += wch_len;
|
||||
continue;
|
||||
}
|
||||
ch = static_cast<char>(c);
|
||||
from_src = true;
|
||||
}
|
||||
} else {
|
||||
// beyond EOL, fill spaces
|
||||
ch = ' ';
|
||||
wch = L' ';
|
||||
wch_len = 1;
|
||||
disp_w = 1;
|
||||
from_src = false;
|
||||
}
|
||||
|
||||
if (written + disp_w > cols) {
|
||||
// would overflow, just 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_cur =
|
||||
has_current && li == cur_my && from_src && src_i >= cur_mx && src_i <
|
||||
cur_mend;
|
||||
if (in_hl && !hl_on) {
|
||||
attron(A_STANDOUT);
|
||||
hl_on = true;
|
||||
}
|
||||
if (!in_hl && hl_on) {
|
||||
attroff(A_STANDOUT);
|
||||
hl_on = false;
|
||||
}
|
||||
if (in_cur && !cur_on) {
|
||||
attron(A_BOLD);
|
||||
cur_on = true;
|
||||
}
|
||||
if (!in_cur && cur_on) {
|
||||
attroff(A_BOLD);
|
||||
cur_on = false;
|
||||
}
|
||||
if (!in_hl && from_src) {
|
||||
apply_token_attr(token_at(src_i));
|
||||
}
|
||||
addch(static_cast<unsigned char>(ch));
|
||||
++written;
|
||||
++render_col;
|
||||
bool in_cur = has_current && li == cur_my && from_src && src_i >= cur_mx &&
|
||||
src_i < cur_mend;
|
||||
attr_t a = A_NORMAL;
|
||||
if (from_src)
|
||||
++src_i;
|
||||
a |= token_attr(token_at(src_i));
|
||||
if (in_sel) {
|
||||
a |= A_REVERSE;
|
||||
} else {
|
||||
if (in_hl)
|
||||
a |= A_STANDOUT;
|
||||
if (in_cur)
|
||||
a |= A_BOLD;
|
||||
}
|
||||
attrset(a);
|
||||
|
||||
if (from_src) {
|
||||
cchar_t cch;
|
||||
wchar_t warr[2] = {wch, L'\0'};
|
||||
setcchar(&cch, warr, 0, 0, nullptr);
|
||||
add_wch(&cch);
|
||||
} else {
|
||||
addch(' ');
|
||||
}
|
||||
|
||||
written += disp_w;
|
||||
render_col += disp_w;
|
||||
if (from_src)
|
||||
src_i += wch_len;
|
||||
if (src_i >= line.size() && written >= cols)
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hl_on) {
|
||||
attroff(A_STANDOUT);
|
||||
hl_on = false;
|
||||
}
|
||||
if (cur_on) {
|
||||
attroff(A_BOLD);
|
||||
cur_on = false;
|
||||
}
|
||||
attrset(A_NORMAL);
|
||||
clrtoeol();
|
||||
}
|
||||
|
||||
// Place terminal cursor at logical position accounting for tabs and coloffs
|
||||
std::size_t cy = buf->Cury();
|
||||
std::size_t rx = buf->Rx(); // render x computed by command layer
|
||||
int cur_y = static_cast<int>(cy) - static_cast<int>(buf->Rowoffs());
|
||||
int cur_x = static_cast<int>(rx) - static_cast<int>(buf->Coloffs());
|
||||
// Place terminal cursor at logical position accounting for tabs and coloffs.
|
||||
// Recompute the rendered X using the same logic as the drawing loop to avoid
|
||||
// any drift between the command-layer computation and the terminal renderer.
|
||||
std::size_t cy = buf->Cury();
|
||||
std::size_t cx = buf->Curx();
|
||||
int cur_y = static_cast<int>(cy) - static_cast<int>(buf->Rowoffs());
|
||||
std::size_t rx_recomputed = 0;
|
||||
if (cy < lines.size()) {
|
||||
const std::string line_for_cursor = static_cast<std::string>(lines[cy]);
|
||||
std::size_t src_i_cur = 0;
|
||||
std::size_t render_col_cur = 0;
|
||||
while (src_i_cur < line_for_cursor.size() && src_i_cur < cx) {
|
||||
std::mbstate_t state = std::mbstate_t();
|
||||
wchar_t wch;
|
||||
size_t res = std::mbrtowc(
|
||||
&wch, &line_for_cursor[src_i_cur], line_for_cursor.size() - src_i_cur,
|
||||
&state);
|
||||
|
||||
if (res == (size_t) -1 || res == (size_t) -2) {
|
||||
render_col_cur += 1;
|
||||
src_i_cur += 1;
|
||||
} else if (res == 0) {
|
||||
src_i_cur += 1;
|
||||
} else {
|
||||
if (wch == L'\t') {
|
||||
std::size_t next_tab = tabw - (render_col_cur % tabw);
|
||||
render_col_cur += next_tab;
|
||||
} else {
|
||||
int dw = wcwidth(wch);
|
||||
render_col_cur += (dw < 0) ? 1 : dw;
|
||||
}
|
||||
src_i_cur += res;
|
||||
}
|
||||
}
|
||||
rx_recomputed = render_col_cur;
|
||||
}
|
||||
int cur_x = static_cast<int>(rx_recomputed) - static_cast<int>(buf->Coloffs());
|
||||
if (cur_y >= 0 && cur_y < content_rows && cur_x >= 0 && cur_x < cols) {
|
||||
// remember where to leave the terminal cursor after status is drawn
|
||||
saved_cur_y = cur_y;
|
||||
@@ -358,9 +503,9 @@ TerminalRenderer::Draw(Editor &ed)
|
||||
{
|
||||
const char *app = "kte";
|
||||
left.reserve(256);
|
||||
left += app;
|
||||
left += " ";
|
||||
left += KTE_VERSION_STR; // already includes leading 'v'
|
||||
left += app;
|
||||
left += " ";
|
||||
left += KTE_VERSION_STR; // already includes leading 'v'
|
||||
const Buffer *b = buf;
|
||||
std::string fname;
|
||||
if (b) {
|
||||
@@ -381,11 +526,11 @@ TerminalRenderer::Draw(Editor &ed)
|
||||
std::size_t total = ed.BufferCount();
|
||||
if (total > 0) {
|
||||
std::size_t idx1 = ed.CurrentBufferIndex() + 1; // human-friendly 1-based
|
||||
left += "[";
|
||||
left += std::to_string(static_cast<unsigned long long>(idx1));
|
||||
left += "/";
|
||||
left += std::to_string(static_cast<unsigned long long>(total));
|
||||
left += "] ";
|
||||
left += "[";
|
||||
left += std::to_string(static_cast<unsigned long long>(idx1));
|
||||
left += "/";
|
||||
left += std::to_string(static_cast<unsigned long long>(total));
|
||||
left += "] ";
|
||||
}
|
||||
}
|
||||
left += fname;
|
||||
@@ -397,9 +542,9 @@ TerminalRenderer::Draw(Editor &ed)
|
||||
// Append total line count as "<n>L"
|
||||
if (b) {
|
||||
unsigned long lcount = static_cast<unsigned long>(b->Rows().size());
|
||||
left += " ";
|
||||
left += std::to_string(lcount);
|
||||
left += "L";
|
||||
left += " ";
|
||||
left += std::to_string(lcount);
|
||||
left += "L";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -470,4 +615,4 @@ TerminalRenderer::Draw(Editor &ed)
|
||||
}
|
||||
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -12,4 +12,4 @@ public:
|
||||
~TerminalRenderer() override;
|
||||
|
||||
void Draw(Editor &ed) override;
|
||||
};
|
||||
};
|
||||
|
||||
+6
-1
@@ -4,8 +4,10 @@
|
||||
|
||||
|
||||
bool
|
||||
TestFrontend::Init(Editor &ed)
|
||||
TestFrontend::Init(int &argc, char **argv, Editor &ed)
|
||||
{
|
||||
(void) argc;
|
||||
(void) argv;
|
||||
ed.SetDimensions(24, 80);
|
||||
return true;
|
||||
}
|
||||
@@ -14,6 +16,9 @@ TestFrontend::Init(Editor &ed)
|
||||
void
|
||||
TestFrontend::Step(Editor &ed, bool &running)
|
||||
{
|
||||
// Allow deferred opens (including swap recovery prompts) to run.
|
||||
ed.ProcessPendingOpens();
|
||||
|
||||
MappedInput mi;
|
||||
if (input_.Poll(mi)) {
|
||||
if (mi.hasCommand) {
|
||||
|
||||
+2
-2
@@ -13,7 +13,7 @@ public:
|
||||
|
||||
~TestFrontend() override = default;
|
||||
|
||||
bool Init(Editor &ed) override;
|
||||
bool Init(int &argc, char **argv, Editor &ed) override;
|
||||
|
||||
void Step(Editor &ed, bool &running) override;
|
||||
|
||||
@@ -34,4 +34,4 @@ public:
|
||||
private:
|
||||
TestInputHandler input_{};
|
||||
TestRenderer renderer_{};
|
||||
};
|
||||
};
|
||||
|
||||
+1
-1
@@ -27,4 +27,4 @@ public:
|
||||
|
||||
private:
|
||||
std::queue<MappedInput> queue_;
|
||||
};
|
||||
};
|
||||
|
||||
+1
-1
@@ -29,4 +29,4 @@ public:
|
||||
|
||||
private:
|
||||
std::size_t draw_count_ = 0;
|
||||
};
|
||||
};
|
||||
|
||||
+11
-2
@@ -9,13 +9,22 @@ enum class UndoType : std::uint8_t {
|
||||
Paste,
|
||||
Newline,
|
||||
DeleteRow,
|
||||
InsertRow,
|
||||
// Inverse of Newline: forward = join_lines(row) [removes the newline at the
|
||||
// end of `row`], backward = split_line(row, col) [recreates the original
|
||||
// two lines]. Used by backspace-at-col-0 and delete-at-eol, whose forward
|
||||
// action is a join, not a split — Newline's apply() semantics are the
|
||||
// wrong direction for those.
|
||||
JoinLines,
|
||||
};
|
||||
|
||||
struct UndoNode {
|
||||
UndoType type{};
|
||||
int row{};
|
||||
int col{};
|
||||
std::uint64_t group_id = 0; // 0 means ungrouped; non-zero means undo/redo as an atomic group
|
||||
std::string text;
|
||||
UndoNode *child = nullptr; // next in current timeline
|
||||
UndoNode *next = nullptr; // redo branch
|
||||
UndoNode *parent = nullptr; // previous state; null means pre-first-edit
|
||||
UndoNode *child = nullptr; // next in current timeline
|
||||
UndoNode *next = nullptr; // redo branch
|
||||
};
|
||||
+11
-9
@@ -20,10 +20,11 @@ public:
|
||||
available_.pop();
|
||||
// Node comes zeroed; ensure links are reset
|
||||
node->text.clear();
|
||||
node->child = nullptr;
|
||||
node->next = nullptr;
|
||||
node->row = node->col = 0;
|
||||
node->type = UndoType{};
|
||||
node->parent = nullptr;
|
||||
node->child = nullptr;
|
||||
node->next = nullptr;
|
||||
node->row = node->col = 0;
|
||||
node->type = UndoType{};
|
||||
return node;
|
||||
}
|
||||
|
||||
@@ -34,10 +35,11 @@ public:
|
||||
return;
|
||||
// Clear heavy fields to free memory held by strings
|
||||
node->text.clear();
|
||||
node->child = nullptr;
|
||||
node->next = nullptr;
|
||||
node->row = node->col = 0;
|
||||
node->type = UndoType{};
|
||||
node->parent = nullptr;
|
||||
node->child = nullptr;
|
||||
node->next = nullptr;
|
||||
node->row = node->col = 0;
|
||||
node->type = UndoType{};
|
||||
available_.push(node);
|
||||
}
|
||||
|
||||
@@ -58,4 +60,4 @@ private:
|
||||
std::size_t block_size_;
|
||||
std::vector<std::unique_ptr<UndoNode[]> > blocks_;
|
||||
std::stack<UndoNode *> available_;
|
||||
};
|
||||
};
|
||||
|
||||
+244
-13
@@ -8,69 +8,265 @@ UndoSystem::UndoSystem(Buffer &owner, UndoTree &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
|
||||
UndoSystem::Begin(UndoType type)
|
||||
{
|
||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
||||
(void) type;
|
||||
if (!buf_)
|
||||
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 || type ==
|
||||
UndoType::InsertRow || type == UndoType::JoinLines);
|
||||
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:
|
||||
case UndoType::InsertRow:
|
||||
case UndoType::JoinLines:
|
||||
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
|
||||
UndoSystem::Append(char ch)
|
||||
{
|
||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
||||
(void) ch;
|
||||
if (!tree_.pending)
|
||||
return;
|
||||
if (pending_mode_ == PendingAppendMode::Prepend) {
|
||||
tree_.pending->text.insert(tree_.pending->text.begin(), ch);
|
||||
} else {
|
||||
tree_.pending->text.push_back(ch);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
UndoSystem::Append(std::string_view text)
|
||||
{
|
||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
||||
(void) text;
|
||||
if (!tree_.pending)
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
UndoSystem::mark_saved()
|
||||
{
|
||||
// STUB: Undo system incomplete - disabled until it can be properly implemented
|
||||
commit();
|
||||
tree_.saved = tree_.current;
|
||||
update_dirty_flag();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
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
|
||||
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 +275,65 @@ UndoSystem::apply(const UndoNode *node, int direction)
|
||||
{
|
||||
if (!node)
|
||||
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) {
|
||||
case UndoType::Insert:
|
||||
case UndoType::Paste:
|
||||
if (direction > 0) {
|
||||
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 {
|
||||
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;
|
||||
case UndoType::Delete:
|
||||
if (direction > 0) {
|
||||
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 {
|
||||
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;
|
||||
case UndoType::Newline:
|
||||
if (direction > 0) {
|
||||
buf_->split_line(node->row, node->col);
|
||||
buf_->SetCursor(0, static_cast<std::size_t>(node->row + 1));
|
||||
} else {
|
||||
buf_->join_lines(node->row);
|
||||
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
||||
}
|
||||
break;
|
||||
case UndoType::DeleteRow:
|
||||
if (direction > 0) {
|
||||
buf_->delete_row(node->row);
|
||||
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
|
||||
} else {
|
||||
buf_->insert_row(node->row, node->text);
|
||||
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
|
||||
}
|
||||
break;
|
||||
case UndoType::InsertRow:
|
||||
if (direction > 0) {
|
||||
buf_->insert_row(node->row, node->text);
|
||||
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
|
||||
} else {
|
||||
buf_->delete_row(node->row);
|
||||
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
|
||||
}
|
||||
break;
|
||||
case UndoType::JoinLines:
|
||||
// Mirror image of Newline: forward removes a newline, backward restores it.
|
||||
if (direction > 0) {
|
||||
buf_->join_lines(node->row);
|
||||
buf_->SetCursor(static_cast<std::size_t>(node->col), static_cast<std::size_t>(node->row));
|
||||
} else {
|
||||
buf_->split_line(node->row, node->col);
|
||||
buf_->SetCursor(0, static_cast<std::size_t>(node->row + 1));
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -206,6 +433,10 @@ UndoSystem::type_str(UndoType t)
|
||||
return "Newline";
|
||||
case UndoType::DeleteRow:
|
||||
return "DeleteRow";
|
||||
case UndoType::InsertRow:
|
||||
return "InsertRow";
|
||||
case UndoType::JoinLines:
|
||||
return "JoinLines";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
+69
-1
@@ -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
|
||||
#include <string_view>
|
||||
#include <cstddef>
|
||||
@@ -12,6 +53,12 @@ class UndoSystem {
|
||||
public:
|
||||
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 Append(char ch);
|
||||
@@ -22,7 +69,10 @@ public:
|
||||
|
||||
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();
|
||||
|
||||
@@ -32,7 +82,20 @@ public:
|
||||
|
||||
void UpdateBufferReference(Buffer &new_buf);
|
||||
|
||||
#if defined(KTE_TESTS)
|
||||
// Test-only introspection hook.
|
||||
const UndoTree &TreeForTests() const
|
||||
{
|
||||
return tree_;
|
||||
}
|
||||
#endif
|
||||
|
||||
private:
|
||||
enum class PendingAppendMode : std::uint8_t {
|
||||
Append,
|
||||
Prepend,
|
||||
};
|
||||
|
||||
void apply(const UndoNode *node, int direction); // +1 redo, -1 undo
|
||||
void free_node(UndoNode *node);
|
||||
|
||||
@@ -48,6 +111,11 @@ private:
|
||||
|
||||
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_;
|
||||
UndoTree &tree_;
|
||||
};
|
||||
+23
-2
@@ -1,3 +1,24 @@
|
||||
// Placeholder translation unit for UndoTree struct definition.
|
||||
// Undo logic is implemented in UndoSystem.
|
||||
// Undo logic is implemented in UndoSystem; this file only owns node lifetime.
|
||||
#include "UndoTree.h"
|
||||
|
||||
namespace {
|
||||
void
|
||||
free_node_graph(UndoNode *node)
|
||||
{
|
||||
// Walk the sibling (redo-branch) list; for each node, recursively free its
|
||||
// child subtree first, then the node itself.
|
||||
while (node) {
|
||||
UndoNode *next = node->next;
|
||||
free_node_graph(node->child);
|
||||
delete node;
|
||||
node = next;
|
||||
}
|
||||
}
|
||||
} // namespace
|
||||
|
||||
|
||||
UndoTree::~UndoTree()
|
||||
{
|
||||
free_node_graph(root);
|
||||
delete pending;
|
||||
}
|
||||
|
||||
+8
-1
@@ -7,4 +7,11 @@ struct UndoTree {
|
||||
UndoNode *current = nullptr; // current state of buffer
|
||||
UndoNode *saved = nullptr; // points to node matching last save (for dirty flag)
|
||||
UndoNode *pending = nullptr; // in-progress batch (detached)
|
||||
};
|
||||
|
||||
// Frees the entire node graph (root's subtree/branches plus any detached
|
||||
// pending node). `current`/`saved` are aliases into that same graph, not
|
||||
// separately owned. Without this, closing/reloading/resetting a buffer
|
||||
// (which replaces or destroys its UndoTree) leaks every UndoNode ever
|
||||
// created for it.
|
||||
~UndoTree();
|
||||
};
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
/*
|
||||
* BufferBench.cc - microbenchmarks for GapBuffer and PieceTable
|
||||
*
|
||||
* This benchmark exercises the public APIs shared by both structures as used
|
||||
* in Buffer::Line: Reserve, AppendChar, Append, PrependChar, Prepend, Clear.
|
||||
*
|
||||
* Run examples:
|
||||
* ./kte_bench_buffer # defaults
|
||||
* ./kte_bench_buffer 200000 8 4096 # N=200k, rounds=8, chunk=4096
|
||||
*/
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <typeinfo>
|
||||
|
||||
#include "GapBuffer.h"
|
||||
#include "PieceTable.h"
|
||||
|
||||
using clock_t = std::chrono::steady_clock;
|
||||
using us = std::chrono::microseconds;
|
||||
|
||||
struct Result {
|
||||
std::string name;
|
||||
std::string scenario;
|
||||
double micros = 0.0;
|
||||
std::size_t bytes = 0;
|
||||
};
|
||||
|
||||
|
||||
static void
|
||||
print_header()
|
||||
{
|
||||
std::cout << std::left << std::setw(14) << "Structure"
|
||||
<< std::left << std::setw(18) << "Scenario"
|
||||
<< std::right << std::setw(12) << "time(us)"
|
||||
<< std::right << std::setw(14) << "bytes"
|
||||
<< std::right << std::setw(14) << "MB/s"
|
||||
<< "\n";
|
||||
std::cout << std::string(72, '-') << "\n";
|
||||
}
|
||||
|
||||
|
||||
static void
|
||||
print_row(const Result &r)
|
||||
{
|
||||
double mb = r.bytes / (1024.0 * 1024.0);
|
||||
double mbps = (r.micros > 0.0) ? (mb / (r.micros / 1'000'000.0)) : 0.0;
|
||||
std::cout << std::left << std::setw(14) << r.name
|
||||
<< std::left << std::setw(18) << r.scenario
|
||||
<< std::right << std::setw(12) << std::fixed << std::setprecision(2) << r.micros
|
||||
<< std::right << std::setw(14) << r.bytes
|
||||
<< std::right << std::setw(14) << std::fixed << std::setprecision(2) << mbps
|
||||
<< "\n";
|
||||
}
|
||||
|
||||
|
||||
template<typename Buf>
|
||||
Result
|
||||
bench_sequential_append(std::size_t N, int rounds)
|
||||
{
|
||||
Result r;
|
||||
r.name = typeid(Buf).name();
|
||||
r.scenario = "seq_append";
|
||||
const char c = 'x';
|
||||
auto start = clock_t::now();
|
||||
std::size_t bytes = 0;
|
||||
for (int t = 0; t < rounds; ++t) {
|
||||
Buf b;
|
||||
b.Reserve(N);
|
||||
for (std::size_t i = 0; i < N; ++i) {
|
||||
b.AppendChar(c);
|
||||
}
|
||||
bytes += N;
|
||||
}
|
||||
auto end = clock_t::now();
|
||||
r.micros = std::chrono::duration_cast<us>(end - start).count();
|
||||
r.bytes = bytes;
|
||||
return r;
|
||||
}
|
||||
|
||||
|
||||
template<typename Buf>
|
||||
Result
|
||||
bench_sequential_prepend(std::size_t N, int rounds)
|
||||
{
|
||||
Result r;
|
||||
r.name = typeid(Buf).name();
|
||||
r.scenario = "seq_prepend";
|
||||
const char c = 'x';
|
||||
auto start = clock_t::now();
|
||||
std::size_t bytes = 0;
|
||||
for (int t = 0; t < rounds; ++t) {
|
||||
Buf b;
|
||||
b.Reserve(N);
|
||||
for (std::size_t i = 0; i < N; ++i) {
|
||||
b.PrependChar(c);
|
||||
}
|
||||
bytes += N;
|
||||
}
|
||||
auto end = clock_t::now();
|
||||
r.micros = std::chrono::duration_cast<us>(end - start).count();
|
||||
r.bytes = bytes;
|
||||
return r;
|
||||
}
|
||||
|
||||
|
||||
template<typename Buf>
|
||||
Result
|
||||
bench_chunk_append(std::size_t N, std::size_t chunk, int rounds)
|
||||
{
|
||||
Result r;
|
||||
r.name = typeid(Buf).name();
|
||||
r.scenario = "chunk_append";
|
||||
std::string payload(chunk, 'y');
|
||||
auto start = clock_t::now();
|
||||
std::size_t bytes = 0;
|
||||
for (int t = 0; t < rounds; ++t) {
|
||||
Buf b;
|
||||
b.Reserve(N);
|
||||
std::size_t written = 0;
|
||||
while (written < N) {
|
||||
std::size_t now = std::min(chunk, N - written);
|
||||
b.Append(payload.data(), now);
|
||||
written += now;
|
||||
}
|
||||
bytes += N;
|
||||
}
|
||||
auto end = clock_t::now();
|
||||
r.micros = std::chrono::duration_cast<us>(end - start).count();
|
||||
r.bytes = bytes;
|
||||
return r;
|
||||
}
|
||||
|
||||
|
||||
template<typename Buf>
|
||||
Result
|
||||
bench_mixed(std::size_t N, std::size_t chunk, int rounds)
|
||||
{
|
||||
Result r;
|
||||
r.name = typeid(Buf).name();
|
||||
r.scenario = "mixed";
|
||||
std::string payload(chunk, 'z');
|
||||
auto start = clock_t::now();
|
||||
std::size_t bytes = 0;
|
||||
for (int t = 0; t < rounds; ++t) {
|
||||
Buf b;
|
||||
b.Reserve(N);
|
||||
std::size_t written = 0;
|
||||
while (written < N) {
|
||||
// alternate append/prepend with small chunks
|
||||
std::size_t now = std::min(chunk, N - written);
|
||||
if ((written / chunk) % 2 == 0) {
|
||||
b.Append(payload.data(), now);
|
||||
} else {
|
||||
b.Prepend(payload.data(), now);
|
||||
}
|
||||
written += now;
|
||||
}
|
||||
bytes += N;
|
||||
}
|
||||
auto end = clock_t::now();
|
||||
r.micros = std::chrono::duration_cast<us>(end - start).count();
|
||||
r.bytes = bytes;
|
||||
return r;
|
||||
}
|
||||
|
||||
|
||||
int
|
||||
main(int argc, char **argv)
|
||||
{
|
||||
// Parameters
|
||||
std::size_t N = 100'000; // bytes per round
|
||||
int rounds = 5; // iterations
|
||||
std::size_t chunk = 1024; // chunk size for chunked scenarios
|
||||
if (argc >= 2)
|
||||
N = static_cast<std::size_t>(std::stoull(argv[1]));
|
||||
if (argc >= 3)
|
||||
rounds = std::stoi(argv[2]);
|
||||
if (argc >= 4)
|
||||
chunk = static_cast<std::size_t>(std::stoull(argv[3]));
|
||||
|
||||
std::cout << "KTE Buffer Microbenchmarks" << "\n";
|
||||
std::cout << "N=" << N << ", rounds=" << rounds << ", chunk=" << chunk << "\n\n";
|
||||
|
||||
print_header();
|
||||
|
||||
// Run for GapBuffer
|
||||
print_row(bench_sequential_append<GapBuffer>(N, rounds));
|
||||
print_row(bench_sequential_prepend<GapBuffer>(N, rounds));
|
||||
print_row(bench_chunk_append<GapBuffer>(N, chunk, rounds));
|
||||
print_row(bench_mixed<GapBuffer>(N, chunk, rounds));
|
||||
|
||||
// Run for PieceTable
|
||||
print_row(bench_sequential_append<PieceTable>(N, rounds));
|
||||
print_row(bench_sequential_prepend<PieceTable>(N, rounds));
|
||||
print_row(bench_chunk_append<PieceTable>(N, chunk, rounds));
|
||||
print_row(bench_mixed<PieceTable>(N, chunk, rounds));
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -1,318 +0,0 @@
|
||||
/*
|
||||
* PerformanceSuite.cc - broader performance and verification benchmarks
|
||||
*/
|
||||
|
||||
#include <algorithm>
|
||||
#include <cassert>
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
#include <random>
|
||||
#include <string>
|
||||
#include <typeinfo>
|
||||
#include <vector>
|
||||
|
||||
#include "GapBuffer.h"
|
||||
#include "PieceTable.h"
|
||||
#include "OptimizedSearch.h"
|
||||
|
||||
using clock_t = std::chrono::steady_clock;
|
||||
using us = std::chrono::microseconds;
|
||||
|
||||
namespace {
|
||||
struct Stat {
|
||||
double micros{0.0};
|
||||
std::size_t bytes{0};
|
||||
std::size_t ops{0};
|
||||
};
|
||||
|
||||
|
||||
static void
|
||||
print_header(const std::string &title)
|
||||
{
|
||||
std::cout << "\n" << title << "\n";
|
||||
std::cout << std::left << std::setw(18) << "Case"
|
||||
<< std::left << std::setw(18) << "Type"
|
||||
<< std::right << std::setw(12) << "time(us)"
|
||||
<< std::right << std::setw(14) << "bytes"
|
||||
<< std::right << std::setw(14) << "ops/s"
|
||||
<< std::right << std::setw(14) << "MB/s"
|
||||
<< "\n";
|
||||
std::cout << std::string(90, '-') << "\n";
|
||||
}
|
||||
|
||||
|
||||
static void
|
||||
print_row(const std::string &caseName, const std::string &typeName, const Stat &s)
|
||||
{
|
||||
double mb = s.bytes / (1024.0 * 1024.0);
|
||||
double sec = s.micros / 1'000'000.0;
|
||||
double mbps = sec > 0 ? (mb / sec) : 0.0;
|
||||
double opss = sec > 0 ? (static_cast<double>(s.ops) / sec) : 0.0;
|
||||
std::cout << std::left << std::setw(18) << caseName
|
||||
<< std::left << std::setw(18) << typeName
|
||||
<< std::right << std::setw(12) << std::fixed << std::setprecision(2) << s.micros
|
||||
<< std::right << std::setw(14) << s.bytes
|
||||
<< std::right << std::setw(14) << std::fixed << std::setprecision(2) << opss
|
||||
<< std::right << std::setw(14) << std::fixed << std::setprecision(2) << mbps
|
||||
<< "\n";
|
||||
}
|
||||
} // namespace
|
||||
|
||||
class PerformanceSuite {
|
||||
public:
|
||||
void benchmarkBufferOperations(std::size_t N, int rounds, std::size_t chunk)
|
||||
{
|
||||
print_header("Buffer Operations");
|
||||
run_buffer_case<GapBuffer>("append_char", N, rounds, chunk, [&](auto &b, std::size_t count) {
|
||||
for (std::size_t i = 0; i < count; ++i)
|
||||
b.AppendChar('a');
|
||||
});
|
||||
run_buffer_case<GapBuffer>("prepend_char", N, rounds, chunk, [&](auto &b, std::size_t count) {
|
||||
for (std::size_t i = 0; i < count; ++i)
|
||||
b.PrependChar('a');
|
||||
});
|
||||
run_buffer_case<GapBuffer>("chunk_mix", N, rounds, chunk, [&](auto &b, std::size_t) {
|
||||
std::string payload(chunk, 'x');
|
||||
std::size_t written = 0;
|
||||
while (written < N) {
|
||||
std::size_t now = std::min(chunk, N - written);
|
||||
if (((written / chunk) & 1) == 0)
|
||||
b.Append(payload.data(), now);
|
||||
else
|
||||
b.Prepend(payload.data(), now);
|
||||
written += now;
|
||||
}
|
||||
});
|
||||
run_buffer_case<PieceTable>("append_char", N, rounds, chunk, [&](auto &b, std::size_t count) {
|
||||
for (std::size_t i = 0; i < count; ++i)
|
||||
b.AppendChar('a');
|
||||
});
|
||||
run_buffer_case<PieceTable>("prepend_char", N, rounds, chunk, [&](auto &b, std::size_t count) {
|
||||
for (std::size_t i = 0; i < count; ++i)
|
||||
b.PrependChar('a');
|
||||
});
|
||||
run_buffer_case<PieceTable>("chunk_mix", N, rounds, chunk, [&](auto &b, std::size_t) {
|
||||
std::string payload(chunk, 'x');
|
||||
std::size_t written = 0;
|
||||
while (written < N) {
|
||||
std::size_t now = std::min(chunk, N - written);
|
||||
if (((written / chunk) & 1) == 0)
|
||||
b.Append(payload.data(), now);
|
||||
else
|
||||
b.Prepend(payload.data(), now);
|
||||
written += now;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
void benchmarkSearchOperations(std::size_t textLen, std::size_t patLen, int rounds)
|
||||
{
|
||||
print_header("Search Operations");
|
||||
std::mt19937_64 rng(0xC0FFEE);
|
||||
std::uniform_int_distribution<int> dist('a', 'z');
|
||||
std::string text(textLen, '\0');
|
||||
for (auto &ch: text)
|
||||
ch = static_cast<char>(dist(rng));
|
||||
std::string pattern(patLen, '\0');
|
||||
for (auto &ch: pattern)
|
||||
ch = static_cast<char>(dist(rng));
|
||||
|
||||
// Ensure at least one hit
|
||||
if (textLen >= patLen && patLen > 0) {
|
||||
std::size_t pos = textLen / 2;
|
||||
std::memcpy(&text[pos], pattern.data(), patLen);
|
||||
}
|
||||
|
||||
// OptimizedSearch find_all vs std::string reference
|
||||
OptimizedSearch os;
|
||||
Stat s{};
|
||||
auto start = clock_t::now();
|
||||
std::size_t matches = 0;
|
||||
std::size_t bytesScanned = 0;
|
||||
for (int r = 0; r < rounds; ++r) {
|
||||
auto hits = os.find_all(text, pattern, 0);
|
||||
matches += hits.size();
|
||||
bytesScanned += text.size();
|
||||
// Verify with reference
|
||||
std::vector<std::size_t> ref;
|
||||
std::size_t from = 0;
|
||||
while (true) {
|
||||
auto p = text.find(pattern, from);
|
||||
if (p == std::string::npos)
|
||||
break;
|
||||
ref.push_back(p);
|
||||
from = p + (patLen ? patLen : 1);
|
||||
}
|
||||
assert(ref == hits);
|
||||
}
|
||||
auto end = clock_t::now();
|
||||
s.micros = std::chrono::duration_cast<us>(end - start).count();
|
||||
s.bytes = bytesScanned;
|
||||
s.ops = matches;
|
||||
print_row("find_all", "OptimizedSearch", s);
|
||||
}
|
||||
|
||||
|
||||
void benchmarkMemoryAllocation(std::size_t N, int rounds)
|
||||
{
|
||||
print_header("Memory Allocation (allocations during editing)");
|
||||
// Measure number of allocations by simulating editing patterns.
|
||||
auto run_session = [&](auto &&buffer) {
|
||||
// alternate small appends and prepends
|
||||
const std::size_t chunk = 32;
|
||||
std::string payload(chunk, 'q');
|
||||
for (int r = 0; r < rounds; ++r) {
|
||||
buffer.Clear();
|
||||
for (std::size_t i = 0; i < N; i += chunk)
|
||||
buffer.Append(payload.data(), std::min(chunk, N - i));
|
||||
for (std::size_t i = 0; i < N / 2; i += chunk)
|
||||
buffer.Prepend(payload.data(), std::min(chunk, N / 2 - i));
|
||||
}
|
||||
};
|
||||
|
||||
// Local allocation counters for this TU via overriding operators
|
||||
reset_alloc_counters();
|
||||
GapBuffer gb;
|
||||
run_session(gb);
|
||||
auto gap_allocs = current_allocs();
|
||||
print_row("edit_session", "GapBuffer", Stat{
|
||||
0.0, static_cast<std::size_t>(gap_allocs.bytes),
|
||||
static_cast<std::size_t>(gap_allocs.count)
|
||||
});
|
||||
|
||||
reset_alloc_counters();
|
||||
PieceTable pt;
|
||||
run_session(pt);
|
||||
auto pt_allocs = current_allocs();
|
||||
print_row("edit_session", "PieceTable", Stat{
|
||||
0.0, static_cast<std::size_t>(pt_allocs.bytes),
|
||||
static_cast<std::size_t>(pt_allocs.count)
|
||||
});
|
||||
}
|
||||
|
||||
private:
|
||||
template<typename Buf, typename Fn>
|
||||
void run_buffer_case(const std::string &caseName, std::size_t N, int rounds, std::size_t chunk, Fn fn)
|
||||
{
|
||||
Stat s{};
|
||||
auto start = clock_t::now();
|
||||
std::size_t bytes = 0;
|
||||
std::size_t ops = 0;
|
||||
for (int t = 0; t < rounds; ++t) {
|
||||
Buf b;
|
||||
b.Reserve(N);
|
||||
fn(b, N);
|
||||
// compare to reference string where possible (only for append_char/prepend_char)
|
||||
bytes += N;
|
||||
ops += N / (chunk ? chunk : 1);
|
||||
}
|
||||
auto end = clock_t::now();
|
||||
s.micros = std::chrono::duration_cast<us>(end - start).count();
|
||||
s.bytes = bytes;
|
||||
s.ops = ops;
|
||||
print_row(caseName, typeid(Buf).name(), s);
|
||||
}
|
||||
|
||||
|
||||
// Simple global allocation tracking for this TU
|
||||
struct AllocStats {
|
||||
std::uint64_t count{0};
|
||||
std::uint64_t bytes{0};
|
||||
};
|
||||
|
||||
|
||||
static AllocStats &alloc_stats()
|
||||
{
|
||||
static AllocStats s;
|
||||
return s;
|
||||
}
|
||||
|
||||
|
||||
static void reset_alloc_counters()
|
||||
{
|
||||
alloc_stats() = {};
|
||||
}
|
||||
|
||||
|
||||
static AllocStats current_allocs()
|
||||
{
|
||||
return alloc_stats();
|
||||
}
|
||||
|
||||
|
||||
// Friend global new/delete defined below
|
||||
friend void *operator new(std::size_t sz) noexcept(false);
|
||||
|
||||
friend void operator delete(void *p) noexcept;
|
||||
|
||||
friend void *operator new[](std::size_t sz) noexcept(false);
|
||||
|
||||
friend void operator delete[](void *p) noexcept;
|
||||
};
|
||||
|
||||
// Override new/delete only in this translation unit to track allocations made here
|
||||
void *
|
||||
operator new(std::size_t sz) noexcept(false)
|
||||
{
|
||||
auto &s = PerformanceSuite::alloc_stats();
|
||||
s.count++;
|
||||
s.bytes += sz;
|
||||
if (void *p = std::malloc(sz))
|
||||
return p;
|
||||
throw std::bad_alloc();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
operator delete(void *p) noexcept
|
||||
{
|
||||
std::free(p);
|
||||
}
|
||||
|
||||
|
||||
void *
|
||||
operator new[](std::size_t sz) noexcept(false)
|
||||
{
|
||||
auto &s = PerformanceSuite::alloc_stats();
|
||||
s.count++;
|
||||
s.bytes += sz;
|
||||
if (void *p = std::malloc(sz))
|
||||
return p;
|
||||
throw std::bad_alloc();
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
operator delete[](void *p) noexcept
|
||||
{
|
||||
std::free(p);
|
||||
}
|
||||
|
||||
|
||||
int
|
||||
main(int argc, char **argv)
|
||||
{
|
||||
std::size_t N = 200'000; // bytes per round for buffer cases
|
||||
int rounds = 3;
|
||||
std::size_t chunk = 1024;
|
||||
if (argc >= 2)
|
||||
N = static_cast<std::size_t>(std::stoull(argv[1]));
|
||||
if (argc >= 3)
|
||||
rounds = std::stoi(argv[2]);
|
||||
if (argc >= 4)
|
||||
chunk = static_cast<std::size_t>(std::stoull(argv[3]));
|
||||
|
||||
std::cout << "KTE Performance Suite" << "\n";
|
||||
std::cout << "N=" << N << ", rounds=" << rounds << ", chunk=" << chunk << "\n";
|
||||
|
||||
PerformanceSuite suite;
|
||||
suite.benchmarkBufferOperations(N, rounds, chunk);
|
||||
suite.benchmarkSearchOperations(1'000'000, 16, rounds);
|
||||
suite.benchmarkMemoryAllocation(N, rounds);
|
||||
return 0;
|
||||
}
|
||||
@@ -23,5 +23,9 @@
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<!-- Allow running multiple instances of the app -->
|
||||
<key>LSMultipleInstancesProhibited</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,78 @@
|
||||
cmake_minimum_required(VERSION 3.15)
|
||||
|
||||
# Fix up a macOS .app bundle by copying non-Qt dylibs into
|
||||
# Contents/Frameworks and rewriting install names to use @rpath/@loader_path.
|
||||
#
|
||||
# Usage:
|
||||
# cmake -DAPP_BUNDLE=/path/to/kge.app -P cmake/fix_bundle.cmake
|
||||
|
||||
if (NOT APP_BUNDLE)
|
||||
message(FATAL_ERROR "APP_BUNDLE not set. Invoke with -DAPP_BUNDLE=/path/to/App.app")
|
||||
endif ()
|
||||
|
||||
get_filename_component(APP_DIR "${APP_BUNDLE}" ABSOLUTE)
|
||||
set(EXECUTABLE "${APP_DIR}/Contents/MacOS/kge")
|
||||
|
||||
if (NOT EXISTS "${EXECUTABLE}")
|
||||
message(FATAL_ERROR "Executable not found at: ${EXECUTABLE}")
|
||||
endif ()
|
||||
|
||||
include(BundleUtilities)
|
||||
|
||||
# Directories to search when resolving prerequisites. We include Homebrew so that
|
||||
# if any deps are currently resolved from there, fixup_bundle will copy them into
|
||||
# the bundle and rewrite install names to be self-contained.
|
||||
set(DIRS
|
||||
"/usr/local/lib"
|
||||
"/opt/homebrew/lib"
|
||||
"/opt/homebrew/opt"
|
||||
)
|
||||
|
||||
# Note: We pass empty plugin list so fixup_bundle scans the executable and all
|
||||
# libs it references recursively. Qt frameworks already live in the bundle after
|
||||
# macdeployqt; this step is primarily for non-Qt dylibs (glib, icu, pcre2, zstd,
|
||||
# dbus, etc.).
|
||||
# fixup_bundle often fails if copied libraries are read-only.
|
||||
# We also try to use the system install_name_tool and otool to avoid issues with Anaconda's version.
|
||||
# Note: BundleUtilities uses find_program(gp_otool "otool") internally, so we might need to set it differently.
|
||||
set(gp_otool "/usr/bin/otool")
|
||||
set(CMAKE_INSTALL_NAME_TOOL "/usr/bin/install_name_tool")
|
||||
set(CMAKE_OTOOL "/usr/bin/otool")
|
||||
set(ENV{PATH} "/usr/bin:/bin:/usr/sbin:/sbin")
|
||||
|
||||
execute_process(COMMAND chmod -R u+w "${APP_DIR}/Contents/Frameworks")
|
||||
|
||||
fixup_bundle("${APP_DIR}" "" "${DIRS}")
|
||||
|
||||
# On Apple Silicon (and modern macOS in general), modifications by fixup_bundle
|
||||
# invalidate code signatures. We must re-sign the bundle (at least ad-hoc)
|
||||
# for it to be allowed to run.
|
||||
# We sign deep, but sometimes explicit signing of components is more reliable.
|
||||
message(STATUS "Re-signing ${APP_DIR} after fixup...")
|
||||
|
||||
# 1. Sign dylibs in Frameworks
|
||||
file(GLOB_RECURSE DYLIBS "${APP_DIR}/Contents/Frameworks/*.dylib")
|
||||
foreach (DYLIB ${DYLIBS})
|
||||
message(STATUS "Signing ${DYLIB}...")
|
||||
execute_process(COMMAND /usr/bin/codesign --force --sign - "${DYLIB}")
|
||||
endforeach ()
|
||||
|
||||
# 2. Sign nested executables
|
||||
message(STATUS "Signing nested kte...")
|
||||
execute_process(COMMAND /usr/bin/codesign --force --sign - "${APP_DIR}/Contents/MacOS/kte")
|
||||
|
||||
# 3. Sign the main executable explicitly
|
||||
message(STATUS "Signing main kge...")
|
||||
execute_process(COMMAND /usr/bin/codesign --force --sign - "${APP_DIR}/Contents/MacOS/kge")
|
||||
|
||||
# 4. Sign the main bundle
|
||||
execute_process(
|
||||
COMMAND /usr/bin/codesign --force --deep --sign - "${APP_DIR}"
|
||||
RESULT_VARIABLE CODESIGN_RESULT
|
||||
)
|
||||
|
||||
if (NOT CODESIGN_RESULT EQUAL 0)
|
||||
message(FATAL_ERROR "Codesign failed with error: ${CODESIGN_RESULT}")
|
||||
endif ()
|
||||
|
||||
message(STATUS "fix_bundle.cmake completed for ${APP_DIR}")
|
||||
+62
-15
@@ -6,10 +6,13 @@
|
||||
SDL2,
|
||||
libGL,
|
||||
xorg,
|
||||
kdePackages,
|
||||
qt6Packages ? kdePackages.qt6Packages,
|
||||
installShellFiles,
|
||||
|
||||
copyDesktopItems,
|
||||
makeDesktopItem,
|
||||
graphical ? false,
|
||||
...
|
||||
graphical-qt ? false,
|
||||
}:
|
||||
let
|
||||
cmakeContent = builtins.readFile ./CMakeLists.txt;
|
||||
@@ -20,25 +23,60 @@ let
|
||||
version = builtins.head (builtins.match ".*set\\(KTE_VERSION \"(.+)\"\\).*" versionLine);
|
||||
in
|
||||
stdenv.mkDerivation {
|
||||
pname = "kte";
|
||||
pname = if graphical then (if graphical-qt then "kge-qt" else "kge") else "kte";
|
||||
inherit version;
|
||||
|
||||
src = lib.cleanSource ./.;
|
||||
|
||||
nativeBuildInputs = [
|
||||
cmake
|
||||
ncurses
|
||||
installShellFiles
|
||||
]
|
||||
++ lib.optionals graphical [
|
||||
] ++ lib.optionals graphical [
|
||||
copyDesktopItems
|
||||
] ++ lib.optionals graphical-qt [
|
||||
qt6Packages.wrapQtAppsHook
|
||||
];
|
||||
|
||||
buildInputs = [
|
||||
ncurses
|
||||
] ++ lib.optionals graphical [
|
||||
SDL2
|
||||
libGL
|
||||
xorg.libX11
|
||||
] ++ lib.optionals graphical-qt [
|
||||
kdePackages.qt6ct
|
||||
qt6Packages.qtbase
|
||||
];
|
||||
|
||||
cmakeFlags = [
|
||||
"-DBUILD_GUI=${if graphical then "ON" else "OFF"}"
|
||||
"-DKTE_USE_QT=${if graphical-qt then "ON" else "OFF"}"
|
||||
"-DCMAKE_BUILD_TYPE=Debug"
|
||||
"-DKTE_STATIC_LINK=OFF"
|
||||
];
|
||||
|
||||
desktopItems = lib.optionals graphical [
|
||||
(makeDesktopItem {
|
||||
name = "kge";
|
||||
desktopName = "kge";
|
||||
genericName = "Text Editor";
|
||||
comment = "kyle's graphical text editor";
|
||||
exec = if graphical-qt then "kge-qt %F" else "kge %F";
|
||||
icon = "kge";
|
||||
terminal = false;
|
||||
categories = [ "Utility" "TextEditor" "Development" ];
|
||||
mimeTypes = [
|
||||
"text/plain"
|
||||
"text/x-c"
|
||||
"text/x-c++"
|
||||
"text/x-python"
|
||||
"text/x-go"
|
||||
"text/x-rust"
|
||||
"application/json"
|
||||
"text/markdown"
|
||||
"text/x-shellscript"
|
||||
];
|
||||
})
|
||||
];
|
||||
|
||||
installPhase = ''
|
||||
@@ -46,17 +84,26 @@ stdenv.mkDerivation {
|
||||
|
||||
mkdir -p $out/bin
|
||||
cp kte $out/bin/
|
||||
|
||||
installManPage ../docs/kte.1
|
||||
|
||||
''
|
||||
+ lib.optionalString graphical ''
|
||||
cp kge $out/bin/
|
||||
installManPage ../docs/kge.1
|
||||
mkdir -p $out/share/icons
|
||||
cp ../kge.png $out/share/icons/
|
||||
''
|
||||
+ ''
|
||||
${lib.optionalString graphical ''
|
||||
${if graphical-qt then ''
|
||||
cp kge $out/bin/kge-qt
|
||||
'' else ''
|
||||
cp kge $out/bin/kge
|
||||
''}
|
||||
installManPage ../docs/kge.1
|
||||
|
||||
mkdir -p $out/share/icons/hicolor/256x256/apps
|
||||
cp ../kge.png $out/share/icons/hicolor/256x256/apps/kge.png
|
||||
''}
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "kyle's text editor" + lib.optionalString graphical " (graphical)";
|
||||
platforms = lib.platforms.unix;
|
||||
mainProgram = if graphical then (if graphical-qt then "kge-qt" else "kge") else "kte";
|
||||
};
|
||||
}
|
||||
|
||||
Executable
+28
@@ -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"
|
||||
@@ -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.
|
||||
File diff suppressed because it is too large
Load Diff
+55
-15
@@ -2,27 +2,43 @@
|
||||
|
||||
## Overview
|
||||
|
||||
`TestFrontend` is a headless implementation of the `Frontend` interface designed to facilitate programmatic testing of editor features. It allows you to queue commands and text input manually, execute them step-by-step, and inspect the editor/buffer state.
|
||||
`TestFrontend` is a headless implementation of the `Frontend` interface
|
||||
designed to facilitate programmatic testing of editor features. It
|
||||
allows you to queue commands and text input manually, execute them
|
||||
step-by-step, and inspect the editor/buffer state.
|
||||
|
||||
## Components
|
||||
|
||||
### TestInputHandler
|
||||
|
||||
A programmable input handler that uses a queue-based system:
|
||||
- `QueueCommand(CommandId id, const std::string &arg = "", int count = 0)` - Queue a specific command
|
||||
- `QueueText(const std::string &text)` - Queue text for insertion (character by character)
|
||||
|
||||
-
|
||||
`QueueCommand(CommandId id, const std::string &arg = "", int count = 0)` -
|
||||
Queue a specific command
|
||||
- `QueueText(const std::string &text)` - Queue text for insertion (
|
||||
character by character)
|
||||
- `Poll(MappedInput &out)` - Returns queued commands one at a time
|
||||
- `IsEmpty()` - Check if the input queue is empty
|
||||
|
||||
### TestRenderer
|
||||
|
||||
A minimal no-op renderer for testing:
|
||||
- `Draw(Editor &ed)` - No-op implementation, just increments draw counter
|
||||
|
||||
- `Draw(Editor &ed)` - No-op implementation, just increments draw
|
||||
counter
|
||||
- `GetDrawCount()` - Returns the number of times Draw() was called
|
||||
- `ResetDrawCount()` - Resets the draw counter
|
||||
|
||||
### TestFrontend
|
||||
The main frontend class that integrates TestInputHandler and TestRenderer:
|
||||
- `Init(Editor &ed)` - Initializes the frontend (sets editor dimensions to 24x80)
|
||||
- `Step(Editor &ed, bool &running)` - Processes one command from the queue and renders
|
||||
|
||||
The main frontend class that integrates TestInputHandler and
|
||||
TestRenderer:
|
||||
|
||||
- `Init(Editor &ed)` - Initializes the frontend (sets editor dimensions
|
||||
to 24x80)
|
||||
- `Step(Editor &ed, bool &running)` - Processes one command from the
|
||||
queue and renders
|
||||
- `Shutdown()` - Cleanup (no-op for TestFrontend)
|
||||
- `Input()` - Access the TestInputHandler
|
||||
- `Renderer()` - Access the TestRenderer
|
||||
@@ -75,31 +91,55 @@ int main() {
|
||||
|
||||
## Key Features
|
||||
|
||||
1. **Programmable Input**: Queue any sequence of commands or text programmatically
|
||||
1. **Programmable Input**: Queue any sequence of commands or text
|
||||
programmatically
|
||||
2. **Step-by-Step Execution**: Run the editor one command at a time
|
||||
3. **State Inspection**: Access and verify editor/buffer state between commands
|
||||
4. **No UI Dependencies**: Headless operation, no terminal or GUI required
|
||||
5. **Integration Testing**: Test command sequences, undo/redo, multi-line editing, etc.
|
||||
3. **State Inspection**: Access and verify editor/buffer state between
|
||||
commands
|
||||
4. **No UI Dependencies**: Headless operation, no terminal or GUI
|
||||
required
|
||||
5. **Integration Testing**: Test command sequences, undo/redo,
|
||||
multi-line editing, etc.
|
||||
|
||||
## Available Commands
|
||||
|
||||
All commands from `CommandId` enum can be queued, including:
|
||||
|
||||
- `CommandId::InsertText` - Insert text (use `QueueText()` helper)
|
||||
- `CommandId::Newline` - Insert newline
|
||||
- `CommandId::Backspace` - Delete character before cursor
|
||||
- `CommandId::Backspace` - Delete character before cursor
|
||||
- `CommandId::DeleteChar` - Delete character at cursor
|
||||
- `CommandId::MoveLeft`, `MoveRight`, `MoveUp`, `MoveDown` - Cursor movement
|
||||
- `CommandId::MoveLeft`, `MoveRight`, `MoveUp`, `MoveDown` - Cursor
|
||||
movement
|
||||
- `CommandId::Undo`, `CommandId::Redo` - Undo/redo operations
|
||||
- `CommandId::Save`, `CommandId::Quit` - File operations
|
||||
- And many more (see Command.h)
|
||||
|
||||
## Integration
|
||||
|
||||
TestFrontend is built into both `kte` and `kge` executables as part of the common source files. You can create standalone test programs by linking against the same source files and ncurses.
|
||||
TestFrontend is built into both `kte` and `kge` executables as part of
|
||||
the common source files. You can create standalone test programs by
|
||||
linking against the same source files and ncurses.
|
||||
|
||||
## Notes
|
||||
|
||||
- Always call `InstallDefaultCommands()` before using any commands
|
||||
- Buffer must be initialized (via `OpenFile()` or `AddBuffer()`) before queuing edit commands
|
||||
- Buffer must be initialized (via `OpenFile()` or `AddBuffer()`) before
|
||||
queuing edit commands
|
||||
- Undo/redo requires the buffer to have an UndoSystem attached
|
||||
- The test frontend sets editor dimensions to 24x80 by default
|
||||
|
||||
## Highlighter stress harness
|
||||
|
||||
For renderer/highlighter race testing without a UI, `kte` provides a
|
||||
lightweight stress mode:
|
||||
|
||||
```
|
||||
kte --stress-highlighter=5
|
||||
```
|
||||
|
||||
This runs a short synthetic workload (5 seconds by default) that edits
|
||||
and scrolls a buffer while
|
||||
exercising `HighlighterEngine::PrefetchViewport` and `GetLine`
|
||||
concurrently. Use Debug builds with
|
||||
AddressSanitizer enabled for best effect.
|
||||
|
||||
@@ -0,0 +1,549 @@
|
||||
# Error Propagation Standardization Report
|
||||
|
||||
**Project:** kte (Kyle's Text Editor)
|
||||
**Date:** 2026-02-17
|
||||
**Auditor:** Error Propagation Standardization Review
|
||||
**Language:** C++20
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This report documents the standardization of error propagation patterns
|
||||
across the kte codebase. Following the implementation of centralized
|
||||
error handling (ErrorHandler), this audit identifies inconsistencies in
|
||||
error propagation and provides concrete remediation recommendations.
|
||||
|
||||
**Key Findings:**
|
||||
|
||||
- **Dominant Pattern**: `bool + std::string &err` is used consistently
|
||||
in Buffer and SwapManager for I/O operations
|
||||
- **Inconsistencies**: PieceTable has no error reporting mechanism; some
|
||||
internal helpers lack error propagation
|
||||
- **Standard Chosen**: `bool + std::string &err` pattern (C++20 project,
|
||||
std::expected not available)
|
||||
- **Documentation**: Comprehensive error handling conventions added to
|
||||
DEVELOPER_GUIDE.md
|
||||
|
||||
**Overall Assessment**: The codebase has a **solid foundation** with the
|
||||
`bool + err` pattern used consistently in critical I/O paths. Primary
|
||||
gaps are in PieceTable memory allocation error handling and some
|
||||
internal helper functions.
|
||||
|
||||
---
|
||||
|
||||
## 1. CURRENT STATE ANALYSIS
|
||||
|
||||
### 1.1 Error Propagation Patterns Found
|
||||
|
||||
#### Pattern 1: `bool + std::string &err` (Dominant)
|
||||
|
||||
**Usage**: File I/O, swap operations, resource allocation
|
||||
|
||||
**Examples**:
|
||||
|
||||
- `Buffer::OpenFromFile(const std::string &path, std::string &err)` (
|
||||
Buffer.h:72)
|
||||
- `Buffer::Save(std::string &err)` (Buffer.h:74)
|
||||
- `Buffer::SaveAs(const std::string &path, std::string &err)` (Buffer.h:
|
||||
75)
|
||||
- `Editor::OpenFile(const std::string &path, std::string &err)` (
|
||||
Editor.h:536)
|
||||
-
|
||||
`SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &err)` (
|
||||
Swap.h:104)
|
||||
-
|
||||
`SwapManager::open_ctx(JournalCtx &ctx, const std::string &path, std::string &err)` (
|
||||
Swap.h:208)
|
||||
-
|
||||
`SwapManager::compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8_t> &chkpt_record, std::string &err)` (
|
||||
Swap.h:212-213)
|
||||
|
||||
**Assessment**: ✅ **Excellent** - Consistent, well-implemented,
|
||||
integrated with ErrorHandler
|
||||
|
||||
#### Pattern 2: `void` (State Changes)
|
||||
|
||||
**Usage**: Setters, cursor movement, flag toggles, internal state
|
||||
modifications
|
||||
|
||||
**Examples**:
|
||||
|
||||
- `Buffer::SetCursor(std::size_t x, std::size_t y)` (Buffer.h:348)
|
||||
- `Buffer::SetDirty(bool d)` (Buffer.h:368)
|
||||
- `Buffer::SetMark(std::size_t x, std::size_t y)` (Buffer.h:387)
|
||||
- `Buffer::insert_text(int row, int col, std::string_view text)` (
|
||||
Buffer.h:545)
|
||||
- `Buffer::delete_text(int row, int col, std::size_t len)` (Buffer.h:
|
||||
547)
|
||||
- `Editor::SetStatus(const std::string &msg)` (Editor.h:various)
|
||||
|
||||
**Assessment**: ✅ **Appropriate** - These operations are infallible
|
||||
state changes
|
||||
|
||||
#### Pattern 3: `bool` without error parameter (Control Flow)
|
||||
|
||||
**Usage**: Validation checks, control flow decisions
|
||||
|
||||
**Examples**:
|
||||
|
||||
- `Editor::ProcessPendingOpens()` (Editor.h:544)
|
||||
- `Editor::ResolveRecoveryPrompt(bool yes)` (Editor.h:558)
|
||||
- `Editor::SwitchTo(std::size_t index)` (Editor.h:563)
|
||||
- `Editor::CloseBuffer(std::size_t index)` (Editor.h:565)
|
||||
|
||||
**Assessment**: ✅ **Appropriate** - Success/failure is sufficient for
|
||||
control flow
|
||||
|
||||
#### Pattern 4: No Error Reporting (PieceTable)
|
||||
|
||||
**Usage**: Memory allocation, text manipulation
|
||||
|
||||
**Examples**:
|
||||
|
||||
- `void PieceTable::Reserve(std::size_t newCapacity)` (PieceTable.h:71)
|
||||
- `void PieceTable::Append(const char *s, std::size_t len)` (
|
||||
PieceTable.h:75)
|
||||
-
|
||||
`void PieceTable::Insert(std::size_t byte_offset, const char *text, std::size_t len)` (
|
||||
PieceTable.h:118)
|
||||
- `char *PieceTable::Data()` (PieceTable.h:89-93) - returns nullptr on
|
||||
allocation failure
|
||||
|
||||
**Assessment**: ⚠️ **Gap** - Memory allocation failures are not reported
|
||||
|
||||
---
|
||||
|
||||
## 2. STANDARDIZATION DECISION
|
||||
|
||||
### 2.1 Chosen Pattern: `bool + std::string &err`
|
||||
|
||||
**Rationale**:
|
||||
|
||||
1. **C++20 Project**: `std::expected` (C++23) is not available
|
||||
2. **Existing Adoption**: Already used consistently in Buffer,
|
||||
SwapManager, Editor for I/O operations
|
||||
3. **Clear Semantics**: `bool` return indicates success/failure, `err`
|
||||
provides details
|
||||
4. **ErrorHandler Integration**: Works seamlessly with centralized error
|
||||
logging
|
||||
5. **Zero Overhead**: No exceptions, no dynamic allocation for error
|
||||
paths
|
||||
6. **Testability**: Easy to verify error messages in unit tests
|
||||
|
||||
**Alternative Considered**: `std::expected<T, std::string>` (C++23)
|
||||
|
||||
- **Rejected**: Requires C++23, would require major refactoring, not
|
||||
available in current toolchain
|
||||
|
||||
### 2.2 Pattern Selection Guidelines
|
||||
|
||||
| Operation Type | Pattern | Example |
|
||||
|---------------------|---------------------------|-----------------------------------------------------------------------------------|
|
||||
| File I/O | `bool + std::string &err` | `Buffer::Save(std::string &err)` |
|
||||
| Syscalls | `bool + std::string &err` | `open_ctx(JournalCtx &ctx, const std::string &path, std::string &err)` |
|
||||
| Resource Allocation | `bool + std::string &err` | Future: `PieceTable::Reserve(std::size_t cap, std::string &err)` |
|
||||
| Parsing/Validation | `bool + std::string &err` | `SwapManager::ReplayFile(Buffer &buf, const std::string &path, std::string &err)` |
|
||||
| State Changes | `void` | `Buffer::SetCursor(std::size_t x, std::size_t y)` |
|
||||
| Control Flow | `bool` (no err) | `Editor::SwitchTo(std::size_t index)` |
|
||||
|
||||
---
|
||||
|
||||
## 3. INCONSISTENCIES AND GAPS
|
||||
|
||||
### 3.1 PieceTable Memory Allocation (Severity: 6/10)
|
||||
|
||||
**Finding**: PieceTable methods that allocate memory (`Reserve`,
|
||||
`Append`, `Insert`, `Data`) do not report allocation failures.
|
||||
|
||||
**Impact**:
|
||||
|
||||
- Memory allocation failures are silent
|
||||
- `Data()` returns `nullptr` on failure, but callers may not check
|
||||
- Large file operations could fail without user notification
|
||||
|
||||
**Evidence**:
|
||||
|
||||
```cpp
|
||||
// PieceTable.h:71
|
||||
void Reserve(std::size_t newCapacity); // No error reporting
|
||||
|
||||
// PieceTable.h:89-93
|
||||
char *Data(); // Returns nullptr on allocation failure
|
||||
```
|
||||
|
||||
**Remediation Priority**: **Medium** - Memory allocation failures are
|
||||
rare on modern systems, but should be handled for robustness
|
||||
|
||||
**Recommended Fix**:
|
||||
|
||||
**Option 1: Add error parameter to fallible operations** (Preferred)
|
||||
|
||||
```cpp
|
||||
// PieceTable.h
|
||||
bool Reserve(std::size_t newCapacity, std::string &err);
|
||||
bool Append(const char *s, std::size_t len, std::string &err);
|
||||
bool Insert(std::size_t byte_offset, const char *text, std::size_t len, std::string &err);
|
||||
|
||||
// Returns nullptr on failure; check with HasMaterializationError()
|
||||
char *Data();
|
||||
bool HasMaterializationError() const;
|
||||
std::string GetMaterializationError() const;
|
||||
```
|
||||
|
||||
**Option 2: Use exceptions for allocation failures** (Not recommended)
|
||||
|
||||
PieceTable could throw `std::bad_alloc` on allocation failures, but this
|
||||
conflicts with the project's error handling philosophy and would require
|
||||
exception handling throughout the codebase.
|
||||
|
||||
**Option 3: Status quo with improved documentation** (Minimal change)
|
||||
|
||||
Document that `Data()` can return `nullptr` and callers must check. Add
|
||||
assertions in debug builds.
|
||||
|
||||
```cpp
|
||||
// PieceTable.h
|
||||
// Returns pointer to materialized buffer, or nullptr if materialization fails.
|
||||
// Callers MUST check for nullptr before dereferencing.
|
||||
char *Data();
|
||||
```
|
||||
|
||||
**Recommendation**: **Option 3** for now (document + assertions), *
|
||||
*Option 1** if memory allocation errors become a concern in production.
|
||||
|
||||
### 3.2 Internal Helper Functions (Severity: 4/10)
|
||||
|
||||
**Finding**: Some internal helper functions in Swap.cc and Buffer.cc use
|
||||
`bool` returns without error parameters.
|
||||
|
||||
**Examples**:
|
||||
|
||||
```cpp
|
||||
// Swap.cc:562
|
||||
static bool ensure_parent_dir(const std::string &path); // No error details
|
||||
|
||||
// Swap.cc:579
|
||||
static bool write_header(int fd); // No error details
|
||||
|
||||
// Buffer.cc:101
|
||||
static bool write_all_fd(int fd, const char *data, std::size_t len, std::string &err); // ✅ Good
|
||||
```
|
||||
|
||||
**Impact**: Limited - These are internal helpers called by functions
|
||||
that do report errors
|
||||
|
||||
**Remediation Priority**: **Low** - Callers already provide error
|
||||
context
|
||||
|
||||
**Recommended Fix**: Add error parameters to internal helpers for
|
||||
consistency
|
||||
|
||||
```cpp
|
||||
// Swap.cc
|
||||
static bool ensure_parent_dir(const std::string &path, std::string &err);
|
||||
static bool write_header(int fd, std::string &err);
|
||||
```
|
||||
|
||||
**Status**: **Deferred** - Low priority, callers already provide
|
||||
adequate error context
|
||||
|
||||
### 3.3 Editor Control Flow Methods (Severity: 2/10)
|
||||
|
||||
**Finding**: Editor methods like `SwitchTo()`, `CloseBuffer()` return
|
||||
`bool` without error details.
|
||||
|
||||
**Assessment**: ✅ **Appropriate** - These are control flow decisions
|
||||
where success/failure is sufficient
|
||||
|
||||
**Remediation**: **None needed** - Current pattern is correct for this
|
||||
use case
|
||||
|
||||
---
|
||||
|
||||
## 4. ERRORHANDLER INTEGRATION STATUS
|
||||
|
||||
### 4.1 Components with ErrorHandler Integration
|
||||
|
||||
✅ **Buffer** (Buffer.cc)
|
||||
|
||||
- `OpenFromFile()` - Reports file open, seek, read errors
|
||||
- `Save()` - Reports write errors
|
||||
- `SaveAs()` - Reports write errors
|
||||
|
||||
✅ **SwapManager** (Swap.cc)
|
||||
|
||||
- `report_error()` - All swap file errors reported
|
||||
- Background thread errors captured and logged
|
||||
- Errno captured for all syscalls
|
||||
|
||||
✅ **main** (main.cc)
|
||||
|
||||
- Top-level exception handler reports Critical errors
|
||||
- Both `std::exception` and unknown exceptions captured
|
||||
|
||||
### 4.2 Components Without ErrorHandler Integration
|
||||
|
||||
⚠️ **PieceTable** (PieceTable.cc)
|
||||
|
||||
- No error reporting mechanism
|
||||
- Memory allocation failures are silent
|
||||
|
||||
⚠️ **Editor** (Editor.cc)
|
||||
|
||||
- File operations delegate to Buffer (✅ covered)
|
||||
- Control flow methods don't need error reporting (✅ appropriate)
|
||||
|
||||
⚠️ **Command** (Command.cc)
|
||||
|
||||
- Commands use `Editor::SetStatus()` for user-facing messages
|
||||
- No ErrorHandler integration for command failures
|
||||
- **Assessment**: Commands are user-initiated actions; status messages
|
||||
are appropriate
|
||||
|
||||
---
|
||||
|
||||
## 5. DOCUMENTATION STATUS
|
||||
|
||||
### 5.1 Error Handling Conventions (DEVELOPER_GUIDE.md)
|
||||
|
||||
✅ **Added comprehensive section** covering:
|
||||
|
||||
- Three standard error propagation patterns
|
||||
- Pattern selection guidelines with decision tree
|
||||
- ErrorHandler integration requirements
|
||||
- Code examples for file I/O, syscalls, background threads, top-level
|
||||
handlers
|
||||
- Anti-patterns and best practices
|
||||
- Error log location and format
|
||||
- Migration guide for updating existing code
|
||||
|
||||
**Location**: `docs/DEVELOPER_GUIDE.md` section 7
|
||||
|
||||
### 5.2 API Documentation
|
||||
|
||||
⚠️ **Gap**: Individual function documentation in headers could be
|
||||
improved
|
||||
|
||||
**Recommendation**: Add brief comments to public APIs documenting error
|
||||
behavior
|
||||
|
||||
```cpp
|
||||
// Buffer.h
|
||||
// Opens a file and loads its content into the buffer.
|
||||
// Returns false on failure; err contains detailed error message.
|
||||
// Errors are logged to ErrorHandler.
|
||||
bool OpenFromFile(const std::string &path, std::string &err);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. REMEDIATION RECOMMENDATIONS
|
||||
|
||||
### 6.1 High Priority (Severity 7-10)
|
||||
|
||||
**None identified** - Critical error handling gaps were addressed in
|
||||
previous sessions:
|
||||
|
||||
- ✅ Top-level exception handler added (Severity 9/10)
|
||||
- ✅ Background thread error reporting added (Severity 9/10)
|
||||
- ✅ File I/O error checking added (Severity 8/10)
|
||||
- ✅ Errno capture added to swap operations (Severity 7/10)
|
||||
- ✅ Centralized error handling implemented (Severity 7/10)
|
||||
|
||||
### 6.2 Medium Priority (Severity 4-6)
|
||||
|
||||
#### 6.2.1 PieceTable Memory Allocation Error Handling (Severity: 6/10)
|
||||
|
||||
**Action**: Document that `Data()` can return `nullptr` and add debug
|
||||
assertions
|
||||
|
||||
**Implementation**:
|
||||
|
||||
```cpp
|
||||
// PieceTable.h
|
||||
// Returns pointer to materialized buffer, or nullptr if materialization fails
|
||||
// due to memory allocation error. Callers MUST check for nullptr.
|
||||
char *Data();
|
||||
|
||||
// PieceTable.cc
|
||||
char *PieceTable::Data() {
|
||||
materialize();
|
||||
assert(materialized_ != nullptr && "PieceTable materialization failed");
|
||||
return materialized_;
|
||||
}
|
||||
```
|
||||
|
||||
**Effort**: Low (documentation + assertions)
|
||||
**Risk**: Low (no API changes)
|
||||
**Timeline**: Next maintenance cycle
|
||||
|
||||
#### 6.2.2 Add Error Parameters to Internal Helpers (Severity: 4/10)
|
||||
|
||||
**Action**: Add `std::string &err` parameters to `ensure_parent_dir()`
|
||||
and `write_header()`
|
||||
|
||||
**Implementation**:
|
||||
|
||||
```cpp
|
||||
// Swap.cc
|
||||
static bool ensure_parent_dir(const std::string &path, std::string &err) {
|
||||
try {
|
||||
fs::path p(path);
|
||||
fs::path dir = p.parent_path();
|
||||
if (dir.empty())
|
||||
return true;
|
||||
if (!fs::exists(dir))
|
||||
fs::create_directories(dir);
|
||||
return true;
|
||||
} catch (const std::exception &e) {
|
||||
err = std::string("Failed to create directory: ") + e.what();
|
||||
return false;
|
||||
} catch (...) {
|
||||
err = "Failed to create directory: unknown error";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Effort**: Low (update 2 functions + call sites)
|
||||
**Risk**: Low (internal helpers only)
|
||||
**Timeline**: Next maintenance cycle
|
||||
|
||||
### 6.3 Low Priority (Severity 1-3)
|
||||
|
||||
#### 6.3.1 Add Function-Level Error Documentation (Severity: 3/10)
|
||||
|
||||
**Action**: Add brief comments to public APIs documenting error behavior
|
||||
|
||||
**Effort**: Medium (many functions to document)
|
||||
**Risk**: None (documentation only)
|
||||
**Timeline**: Ongoing as code is touched
|
||||
|
||||
#### 6.3.2 Add ErrorHandler Integration to Commands (Severity: 2/10)
|
||||
|
||||
**Action**: Consider logging command failures to ErrorHandler for
|
||||
diagnostics
|
||||
|
||||
**Assessment**: **Not recommended** - Commands are user-initiated
|
||||
actions; status messages are more appropriate than error logs
|
||||
|
||||
---
|
||||
|
||||
## 7. TESTING RECOMMENDATIONS
|
||||
|
||||
### 7.1 Error Handling Test Coverage
|
||||
|
||||
**Current State**:
|
||||
|
||||
- ✅ Swap file error handling tested (test_swap_edge_cases.cc)
|
||||
- ✅ Buffer I/O error handling tested (test_buffer_io.cc)
|
||||
- ⚠️ PieceTable allocation failure testing missing
|
||||
|
||||
**Recommendations**:
|
||||
|
||||
1. **Add PieceTable allocation failure tests** (if Option 1 from 3.1 is
|
||||
implemented)
|
||||
2. **Add ErrorHandler query tests** - Verify error logging and retrieval
|
||||
3. **Add errno capture tests** - Verify errno is captured correctly in
|
||||
syscall failures
|
||||
|
||||
### 7.2 Test Examples
|
||||
|
||||
```cpp
|
||||
// test_error_handler.cc
|
||||
TEST(ErrorHandler, LogsErrorsWithContext) {
|
||||
ErrorHandler::Instance().Error("TestComponent", "Test error", "test.txt");
|
||||
EXPECT_TRUE(ErrorHandler::Instance().HasErrors());
|
||||
EXPECT_EQ(ErrorHandler::Instance().GetErrorCount(), 1);
|
||||
std::string last = ErrorHandler::Instance().GetLastError();
|
||||
EXPECT_TRUE(last.find("Test error") != std::string::npos);
|
||||
EXPECT_TRUE(last.find("test.txt") != std::string::npos);
|
||||
}
|
||||
|
||||
// test_piece_table.cc (if Option 1 implemented)
|
||||
TEST(PieceTable, ReportsAllocationFailure) {
|
||||
PieceTable pt;
|
||||
std::string err;
|
||||
// Attempt to allocate huge buffer
|
||||
bool ok = pt.Reserve(SIZE_MAX, err);
|
||||
EXPECT_FALSE(ok);
|
||||
EXPECT_FALSE(err.empty());
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. MIGRATION CHECKLIST
|
||||
|
||||
For developers updating existing code to follow error handling
|
||||
conventions:
|
||||
|
||||
- [ ] Identify all error-prone operations (file I/O, syscalls,
|
||||
allocations)
|
||||
- [ ] Add `std::string &err` parameter if not present
|
||||
- [ ] Clear `err` at function start: `err.clear();`
|
||||
- [ ] Capture `errno` immediately after syscall failures:
|
||||
`int saved_errno = errno;`
|
||||
- [ ] Build detailed error messages with context (paths, operation
|
||||
details)
|
||||
- [ ] Call `ErrorHandler::Instance().Error()` at all error sites
|
||||
- [ ] Return `false` on failure, `true` on success
|
||||
- [ ] Update all call sites to handle the error parameter
|
||||
- [ ] Write unit tests that verify error handling
|
||||
- [ ] Update function documentation to describe error behavior
|
||||
|
||||
---
|
||||
|
||||
## 9. SUMMARY AND NEXT STEPS
|
||||
|
||||
### 9.1 Achievements
|
||||
|
||||
✅ **Standardized on `bool + std::string &err` pattern** for error-prone
|
||||
operations
|
||||
✅ **Documented comprehensive error handling conventions** in
|
||||
DEVELOPER_GUIDE.md
|
||||
✅ **Identified and prioritized remaining gaps** (PieceTable, internal
|
||||
helpers)
|
||||
✅ **Integrated ErrorHandler** into Buffer, SwapManager, and main
|
||||
✅ **Established clear pattern selection guidelines** for future
|
||||
development
|
||||
|
||||
### 9.2 Remaining Work
|
||||
|
||||
**Medium Priority**:
|
||||
|
||||
1. Document PieceTable `Data()` nullptr behavior and add assertions
|
||||
2. Add error parameters to internal helper functions
|
||||
|
||||
**Low Priority**:
|
||||
|
||||
3. Add function-level error documentation to public APIs
|
||||
4. Add ErrorHandler query tests
|
||||
|
||||
### 9.3 Conclusion
|
||||
|
||||
The kte codebase has achieved **strong error handling consistency** with
|
||||
the `bool + std::string &err` pattern used uniformly across critical I/O
|
||||
paths. The centralized ErrorHandler provides comprehensive logging and
|
||||
UI integration. Remaining gaps are minor and primarily affect edge
|
||||
cases (memory allocation failures) that are rare in practice.
|
||||
|
||||
**Overall Grade**: **B+ (8.5/10)**
|
||||
|
||||
**Strengths**:
|
||||
|
||||
- Consistent error propagation in Buffer and SwapManager
|
||||
- Comprehensive ErrorHandler integration
|
||||
- Excellent documentation in DEVELOPER_GUIDE.md
|
||||
- Errno capture for all syscalls
|
||||
- Top-level exception handling
|
||||
|
||||
**Areas for Improvement**:
|
||||
|
||||
- PieceTable memory allocation error handling
|
||||
- Internal helper function error propagation
|
||||
- Function-level API documentation
|
||||
|
||||
The error handling infrastructure is **production-ready** and provides a
|
||||
solid foundation for reliable operation and debugging.
|
||||
@@ -0,0 +1,601 @@
|
||||
# PieceTable Migration Plan
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document outlines the plan to remove GapBuffer support from kte and
|
||||
migrate to using a **single PieceTable per Buffer**, rather than the
|
||||
current vector-of-Lines architecture where each Line contains either a
|
||||
GapBuffer or PieceTable.
|
||||
|
||||
## Current Architecture Analysis
|
||||
|
||||
### Text Storage
|
||||
|
||||
**Current Implementation:**
|
||||
|
||||
- `Buffer` contains `std::vector<Line> rows_`
|
||||
- Each `Line` wraps an `AppendBuffer` (type alias)
|
||||
- `AppendBuffer` is either `GapBuffer` (default) or `PieceTable` (via
|
||||
`KTE_USE_PIECE_TABLE`)
|
||||
- Each line is independently managed with its own buffer
|
||||
- Operations are line-based with coordinate pairs (row, col)
|
||||
|
||||
**Key Files:**
|
||||
|
||||
- `Buffer.h/cc` - Buffer class with vector of Lines
|
||||
- `AppendBuffer.h` - Type selector (GapBuffer vs PieceTable)
|
||||
- `GapBuffer.h/cc` - Per-line gap buffer implementation
|
||||
- `PieceTable.h/cc` - Per-line piece table implementation
|
||||
- `UndoSystem.h/cc` - Records operations with (row, col, text)
|
||||
- `UndoNode.h` - Undo operation types (Insert, Delete, Paste, Newline,
|
||||
DeleteRow)
|
||||
- `Command.cc` - High-level editing commands
|
||||
|
||||
### Current Buffer API
|
||||
|
||||
**Low-level editing operations (used by UndoSystem):**
|
||||
|
||||
```cpp
|
||||
void insert_text(int row, int col, std::string_view text);
|
||||
void delete_text(int row, int col, std::size_t len);
|
||||
void split_line(int row, int col);
|
||||
void join_lines(int row);
|
||||
void insert_row(int row, std::string_view text);
|
||||
void delete_row(int row);
|
||||
```
|
||||
|
||||
**Line access:**
|
||||
|
||||
```cpp
|
||||
std::vector<Line> &Rows();
|
||||
const std::vector<Line> &Rows() const;
|
||||
```
|
||||
|
||||
**Line API (Buffer::Line):**
|
||||
|
||||
```cpp
|
||||
std::size_t size() const;
|
||||
const char *Data() const;
|
||||
char operator[](std::size_t i) const;
|
||||
std::string substr(std::size_t pos, std::size_t len) const;
|
||||
std::size_t find(const std::string &needle, std::size_t pos) const;
|
||||
void erase(std::size_t pos, std::size_t len);
|
||||
void insert(std::size_t pos, const std::string &seg);
|
||||
Line &operator+=(const Line &other);
|
||||
Line &operator+=(const std::string &s);
|
||||
```
|
||||
|
||||
### Current PieceTable Limitations
|
||||
|
||||
The existing `PieceTable` class only supports:
|
||||
|
||||
- `Append(char/string)` - add to end
|
||||
- `Prepend(char/string)` - add to beginning
|
||||
- `Clear()` - empty the buffer
|
||||
- `Data()` / `Size()` - access content (materializes on demand)
|
||||
|
||||
**Missing capabilities needed for buffer-wide storage:**
|
||||
|
||||
- Insert at arbitrary byte position
|
||||
- Delete at arbitrary byte position
|
||||
- Line indexing and line-based queries
|
||||
- Position conversion (byte offset ↔ line/col)
|
||||
- Efficient line boundary tracking
|
||||
|
||||
## Target Architecture
|
||||
|
||||
### Design Overview
|
||||
|
||||
**Single PieceTable per Buffer:**
|
||||
|
||||
- `Buffer` contains one `PieceTable content_` (replaces
|
||||
`std::vector<Line> rows_`)
|
||||
- Text stored as continuous byte sequence with `\n` as line separators
|
||||
- Line index cached for efficient line-based operations
|
||||
- All operations work on byte offsets internally
|
||||
- Buffer provides line/column API as convenience layer
|
||||
|
||||
### Enhanced PieceTable Design
|
||||
|
||||
```cpp
|
||||
class PieceTable {
|
||||
public:
|
||||
// Existing API (keep for compatibility if needed)
|
||||
void Append(const char *s, std::size_t len);
|
||||
void Prepend(const char *s, std::size_t len);
|
||||
void Clear();
|
||||
const char *Data() const;
|
||||
std::size_t Size() const;
|
||||
|
||||
// NEW: Core byte-based editing operations
|
||||
void Insert(std::size_t byte_offset, const char *text, std::size_t len);
|
||||
void Delete(std::size_t byte_offset, std::size_t len);
|
||||
|
||||
// NEW: Line-based queries
|
||||
std::size_t LineCount() const;
|
||||
std::string GetLine(std::size_t line_num) const;
|
||||
std::pair<std::size_t, std::size_t> GetLineRange(std::size_t line_num) const; // (start, end) byte offsets
|
||||
|
||||
// NEW: Position conversion
|
||||
std::pair<std::size_t, std::size_t> ByteOffsetToLineCol(std::size_t byte_offset) const;
|
||||
std::size_t LineColToByteOffset(std::size_t row, std::size_t col) const;
|
||||
|
||||
// NEW: Substring extraction
|
||||
std::string GetRange(std::size_t byte_offset, std::size_t len) const;
|
||||
|
||||
// NEW: Search support
|
||||
std::size_t Find(const std::string &needle, std::size_t start_offset) const;
|
||||
|
||||
private:
|
||||
// Existing members
|
||||
std::string original_;
|
||||
std::string add_;
|
||||
std::vector<Piece> pieces_;
|
||||
mutable std::string materialized_;
|
||||
mutable bool dirty_;
|
||||
std::size_t total_size_;
|
||||
|
||||
// NEW: Line index for efficient line operations
|
||||
struct LineInfo {
|
||||
std::size_t byte_offset; // absolute byte offset from buffer start
|
||||
std::size_t piece_idx; // which piece contains line start
|
||||
std::size_t offset_in_piece; // byte offset within that piece
|
||||
};
|
||||
mutable std::vector<LineInfo> line_index_;
|
||||
mutable bool line_index_dirty_;
|
||||
|
||||
// NEW: Line index management
|
||||
void RebuildLineIndex() const;
|
||||
void InvalidateLineIndex();
|
||||
};
|
||||
```
|
||||
|
||||
### Buffer API Changes
|
||||
|
||||
```cpp
|
||||
class Buffer {
|
||||
public:
|
||||
// NEW: Direct content access
|
||||
PieceTable &Content() { return content_; }
|
||||
const PieceTable &Content() const { return content_; }
|
||||
|
||||
// MODIFIED: Keep existing API but implement via PieceTable
|
||||
void insert_text(int row, int col, std::string_view text);
|
||||
void delete_text(int row, int col, std::size_t len);
|
||||
void split_line(int row, int col);
|
||||
void join_lines(int row);
|
||||
void insert_row(int row, std::string_view text);
|
||||
void delete_row(int row);
|
||||
|
||||
// MODIFIED: Line access - return line from PieceTable
|
||||
std::size_t Nrows() const { return content_.LineCount(); }
|
||||
std::string GetLine(std::size_t row) const { return content_.GetLine(row); }
|
||||
|
||||
// REMOVED: Rows() - no longer have vector of Lines
|
||||
// std::vector<Line> &Rows(); // REMOVE
|
||||
|
||||
private:
|
||||
// REMOVED: std::vector<Line> rows_;
|
||||
// NEW: Single piece table for all content
|
||||
PieceTable content_;
|
||||
|
||||
// Keep existing members
|
||||
std::size_t curx_, cury_, rx_;
|
||||
std::size_t nrows_; // cached from content_.LineCount()
|
||||
std::size_t rowoffs_, coloffs_;
|
||||
std::string filename_;
|
||||
bool is_file_backed_;
|
||||
bool dirty_;
|
||||
bool read_only_;
|
||||
bool mark_set_;
|
||||
std::size_t mark_curx_, mark_cury_;
|
||||
std::unique_ptr<UndoTree> undo_tree_;
|
||||
std::unique_ptr<UndoSystem> undo_sys_;
|
||||
std::uint64_t version_;
|
||||
bool syntax_enabled_;
|
||||
std::string filetype_;
|
||||
std::unique_ptr<kte::HighlighterEngine> highlighter_;
|
||||
kte::SwapRecorder *swap_rec_;
|
||||
};
|
||||
```
|
||||
|
||||
## Migration Phases
|
||||
|
||||
### Phase 1: Extend PieceTable (Foundation)
|
||||
|
||||
**Goal:** Add buffer-wide capabilities to PieceTable without breaking
|
||||
existing per-line usage.
|
||||
|
||||
**Tasks:**
|
||||
|
||||
1. Add line indexing infrastructure to PieceTable
|
||||
- Add `LineInfo` struct and `line_index_` member
|
||||
- Implement `RebuildLineIndex()` that scans pieces for '\n'
|
||||
characters
|
||||
- Implement `InvalidateLineIndex()` called by Insert/Delete
|
||||
|
||||
2. Implement core byte-based operations
|
||||
- `Insert(byte_offset, text, len)` - split piece at offset, insert
|
||||
new piece
|
||||
- `Delete(byte_offset, len)` - split pieces, remove/truncate as
|
||||
needed
|
||||
|
||||
3. Implement line-based query methods
|
||||
- `LineCount()` - return line_index_.size()
|
||||
- `GetLine(line_num)` - extract text between line boundaries
|
||||
- `GetLineRange(line_num)` - return (start, end) byte offsets
|
||||
|
||||
4. Implement position conversion
|
||||
- `ByteOffsetToLineCol(offset)` - binary search in line_index_
|
||||
- `LineColToByteOffset(row, col)` - lookup line start, add col
|
||||
|
||||
5. Implement utility methods
|
||||
- `GetRange(offset, len)` - extract substring
|
||||
- `Find(needle, start)` - search across pieces
|
||||
|
||||
**Testing:**
|
||||
|
||||
- Write unit tests for new PieceTable methods
|
||||
- Test with multi-line content
|
||||
- Verify line index correctness after edits
|
||||
- Benchmark performance vs current line-based approach
|
||||
|
||||
**Estimated Effort:** 3-5 days
|
||||
|
||||
### Phase 2: Create Buffer Adapter Layer (Compatibility)
|
||||
|
||||
**Goal:** Create compatibility layer in Buffer to use PieceTable while
|
||||
maintaining existing API.
|
||||
|
||||
**Tasks:**
|
||||
|
||||
1. Add `PieceTable content_` member to Buffer (alongside existing
|
||||
`rows_`)
|
||||
2. Add compilation flag `KTE_USE_BUFFER_PIECE_TABLE` (like existing
|
||||
`KTE_USE_PIECE_TABLE`)
|
||||
3. Implement Buffer methods to delegate to content_:
|
||||
```cpp
|
||||
#ifdef KTE_USE_BUFFER_PIECE_TABLE
|
||||
void insert_text(int row, int col, std::string_view text) {
|
||||
std::size_t offset = content_.LineColToByteOffset(row, col);
|
||||
content_.Insert(offset, text.data(), text.size());
|
||||
}
|
||||
// ... similar for other methods
|
||||
#else
|
||||
// Existing line-based implementation
|
||||
#endif
|
||||
```
|
||||
4. Update file I/O to work with PieceTable
|
||||
- `OpenFromFile()` - load into content_ instead of rows_
|
||||
- `Save()` - serialize content_ instead of rows_
|
||||
5. Update `AsString()` to materialize from content_
|
||||
|
||||
**Testing:**
|
||||
|
||||
- Run existing buffer correctness tests with new flag
|
||||
- Verify undo/redo still works
|
||||
- Test file I/O round-tripping
|
||||
- Test with existing command operations
|
||||
|
||||
**Estimated Effort:** 3-4 days
|
||||
|
||||
### Phase 3: Migrate Command Layer (High-level Operations)
|
||||
|
||||
**Goal:** Update commands that directly access Rows() to use new API.
|
||||
|
||||
**Tasks:**
|
||||
|
||||
1. Audit all usages of `buf.Rows()` in Command.cc
|
||||
2. Refactor helper functions:
|
||||
- `extract_region_text()` - use content_.GetRange()
|
||||
- `delete_region()` - convert to byte offsets, use content_.Delete()
|
||||
- `insert_text_at_cursor()` - convert position, use content_
|
||||
.Insert()
|
||||
3. Update commands that iterate over lines:
|
||||
- Use `buf.GetLine(i)` instead of `buf.Rows()[i]`
|
||||
- Update line count queries to use `buf.Nrows()`
|
||||
4. Update search/replace operations:
|
||||
- Modify `search_compute_matches()` to work with GetLine()
|
||||
- Update regex matching to work line-by-line or use content directly
|
||||
|
||||
**Testing:**
|
||||
|
||||
- Test all editing commands (insert, delete, newline, backspace)
|
||||
- Test region operations (mark, copy, kill)
|
||||
- Test search and replace
|
||||
- Test word navigation and deletion
|
||||
- Run through common editing workflows
|
||||
|
||||
**Estimated Effort:** 4-6 days
|
||||
|
||||
### Phase 4: Update Renderer and Frontend (Display)
|
||||
|
||||
**Goal:** Ensure all renderers work with new Buffer structure.
|
||||
|
||||
**Tasks:**
|
||||
|
||||
1. Audit renderer implementations:
|
||||
- `TerminalRenderer.cc`
|
||||
- `ImGuiRenderer.cc`
|
||||
- `QtRenderer.cc`
|
||||
- `TestRenderer.cc`
|
||||
2. Update line access patterns:
|
||||
- Replace `buf.Rows()[y]` with `buf.GetLine(y)`
|
||||
- Handle string return instead of Line object
|
||||
3. Update syntax highlighting integration:
|
||||
- Ensure HighlighterEngine works with GetLine()
|
||||
- Update any line-based caching
|
||||
|
||||
**Testing:**
|
||||
|
||||
- Test rendering in terminal
|
||||
- Test ImGui frontend (if enabled)
|
||||
- Test Qt frontend (if enabled)
|
||||
- Verify syntax highlighting displays correctly
|
||||
- Test scrolling and viewport updates
|
||||
|
||||
**Estimated Effort:** 2-3 days
|
||||
|
||||
### Phase 5: Remove Old Infrastructure (Cleanup) ✅ COMPLETED
|
||||
|
||||
**Goal:** Remove GapBuffer, AppendBuffer, and Line class completely.
|
||||
|
||||
**Status:** Completed on 2025-12-05
|
||||
|
||||
**Tasks:**
|
||||
|
||||
1. ✅ Remove conditional compilation:
|
||||
- Removed `#ifdef KTE_USE_BUFFER_PIECE_TABLE` (PieceTable is now the
|
||||
only way)
|
||||
- Removed `#ifdef KTE_USE_PIECE_TABLE`
|
||||
- Removed `AppendBuffer.h`
|
||||
2. ✅ Delete obsolete code:
|
||||
- Deleted `GapBuffer.h/cc`
|
||||
- Line class now uses PieceTable internally (kept for API
|
||||
compatibility)
|
||||
- `rows_` kept as mutable cache rebuilt from `content_` PieceTable
|
||||
3. ✅ Update CMakeLists.txt:
|
||||
- Removed GapBuffer from sources
|
||||
- Removed AppendBuffer.h from headers
|
||||
- Removed KTE_USE_PIECE_TABLE and KTE_USE_BUFFER_PIECE_TABLE options
|
||||
4. ✅ Clean up includes and dependencies
|
||||
5. ✅ Update documentation
|
||||
|
||||
**Testing:**
|
||||
|
||||
- Full regression test suite
|
||||
- Verify clean compilation
|
||||
- Check for any lingering references
|
||||
|
||||
**Estimated Effort:** 1-2 days
|
||||
|
||||
### Phase 6: Performance Optimization (Polish)
|
||||
|
||||
**Goal:** Optimize the new implementation for real-world usage.
|
||||
|
||||
**Tasks:**
|
||||
|
||||
1. Profile common operations:
|
||||
- Measure line access patterns
|
||||
- Identify hot paths in editing
|
||||
- Benchmark against old implementation
|
||||
2. Optimize line index:
|
||||
- Consider incremental updates instead of full rebuild
|
||||
- Tune rebuild threshold
|
||||
- Cache frequently accessed lines
|
||||
3. Optimize piece table:
|
||||
- Tune piece coalescing heuristics
|
||||
- Consider piece count limits and consolidation
|
||||
4. Memory optimization:
|
||||
- Review materialization frequency
|
||||
- Consider lazy materialization strategies
|
||||
- Profile memory usage on large files
|
||||
|
||||
**Testing:**
|
||||
|
||||
- Benchmark suite with various file sizes
|
||||
- Memory profiling
|
||||
- Real-world usage testing
|
||||
|
||||
**Estimated Effort:** 3-5 days
|
||||
|
||||
## Files Requiring Modification
|
||||
|
||||
### Core Files (Must Change)
|
||||
|
||||
- `PieceTable.h/cc` - Add new methods (Phase 1)
|
||||
- `Buffer.h/cc` - Replace rows_ with content_ (Phase 2)
|
||||
- `Command.cc` - Update line access (Phase 3)
|
||||
- `UndoSystem.cc` - May need updates for new Buffer API
|
||||
|
||||
### Renderer Files (Will Change)
|
||||
|
||||
- `TerminalRenderer.cc` - Update line access (Phase 4)
|
||||
- `ImGuiRenderer.cc` - Update line access (Phase 4)
|
||||
- `QtRenderer.cc` - Update line access (Phase 4)
|
||||
- `TestRenderer.cc` - Update line access (Phase 4)
|
||||
|
||||
### Files Removed (Phase 5 - Completed)
|
||||
|
||||
- `GapBuffer.h/cc` - ✅ Deleted
|
||||
- `AppendBuffer.h` - ✅ Deleted
|
||||
- `test_buffer_correctness.cc` - ✅ Deleted (obsolete GapBuffer
|
||||
comparison test)
|
||||
- `bench/BufferBench.cc` - ✅ Deleted (obsolete GapBuffer benchmarks)
|
||||
- `bench/PerformanceSuite.cc` - ✅ Deleted (obsolete GapBuffer
|
||||
benchmarks)
|
||||
- `Buffer::Line` class - ✅ Updated to use PieceTable internally (kept
|
||||
for API compatibility)
|
||||
|
||||
### Build Files
|
||||
|
||||
- `CMakeLists.txt` - Update sources (Phase 5)
|
||||
|
||||
### Documentation
|
||||
|
||||
- `README.md` - Update architecture notes
|
||||
- `docs/` - Update any architectural documentation
|
||||
- `REWRITE.md` - Note C++ now matches Rust design
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- **PieceTable Tests:** New file `test_piece_table.cc`
|
||||
- Test Insert/Delete at various positions
|
||||
- Test line indexing correctness
|
||||
- Test position conversion
|
||||
- Test with edge cases (empty, single line, large files)
|
||||
|
||||
- **Buffer Tests:** Extend `test_buffer_correctness.cc`
|
||||
- Test new Buffer API with PieceTable backend
|
||||
- Test file I/O round-tripping
|
||||
- Test multi-line operations
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- **Undo Tests:** `test_undo.cc` should still pass
|
||||
- Verify undo/redo across all operation types
|
||||
- Test undo tree navigation
|
||||
|
||||
- **Search Tests:** `test_search_correctness.cc` should still pass
|
||||
- Verify search across multiple lines
|
||||
- Test regex search
|
||||
|
||||
### Manual Testing
|
||||
|
||||
- Load and edit large files (>10MB)
|
||||
- Perform complex editing sequences
|
||||
- Test all keybindings and commands
|
||||
- Verify syntax highlighting
|
||||
- Test crash recovery (swap files)
|
||||
|
||||
### Regression Testing
|
||||
|
||||
- All existing tests must pass with new implementation
|
||||
- No observable behavior changes for users
|
||||
- Performance should be comparable or better
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
### High Risk
|
||||
|
||||
- **Undo System Integration:** Undo records operations with
|
||||
row/col/text. Need to ensure compatibility or refactor.
|
||||
- *Mitigation:* Carefully preserve undo semantics, extensive testing
|
||||
|
||||
- **Performance Regression:** Line index rebuilding could be expensive
|
||||
on large files.
|
||||
- *Mitigation:* Profile early, optimize incrementally, consider
|
||||
caching strategies
|
||||
|
||||
### Medium Risk
|
||||
|
||||
- **Syntax Highlighting:** Highlighters may depend on line-based access
|
||||
patterns.
|
||||
- *Mitigation:* Review highlighter integration, test thoroughly
|
||||
|
||||
- **Renderer Updates:** Multiple renderers need updating, risk of
|
||||
inconsistency.
|
||||
- *Mitigation:* Update all renderers in same phase, test each
|
||||
|
||||
### Low Risk
|
||||
|
||||
- **Search/Replace:** Should work naturally with new GetLine() API.
|
||||
- *Mitigation:* Test thoroughly with existing test suite
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- ✓ All existing tests pass
|
||||
- ✓ All commands work identically to before
|
||||
- ✓ File I/O works correctly
|
||||
- ✓ Undo/redo functionality preserved
|
||||
- ✓ Syntax highlighting works
|
||||
- ✓ All frontends (terminal, ImGui, Qt) work
|
||||
|
||||
### Code Quality
|
||||
|
||||
- ✓ GapBuffer completely removed
|
||||
- ✓ No conditional compilation for buffer type
|
||||
- ✓ Clean, maintainable code
|
||||
- ✓ Good test coverage for new PieceTable methods
|
||||
|
||||
### Performance
|
||||
|
||||
- ✓ Editing operations at least as fast as current
|
||||
- ✓ Line access within 2x of current performance
|
||||
- ✓ Memory usage reasonable (no excessive materialization)
|
||||
- ✓ Large file handling acceptable (tested up to 100MB)
|
||||
|
||||
## Timeline Estimate
|
||||
|
||||
| Phase | Duration | Dependencies |
|
||||
|----------------------------|----------------|--------------|
|
||||
| Phase 1: Extend PieceTable | 3-5 days | None |
|
||||
| Phase 2: Buffer Adapter | 3-4 days | Phase 1 |
|
||||
| Phase 3: Command Layer | 4-6 days | Phase 2 |
|
||||
| Phase 4: Renderer Updates | 2-3 days | Phase 3 |
|
||||
| Phase 5: Cleanup | 1-2 days | Phase 4 |
|
||||
| Phase 6: Optimization | 3-5 days | Phase 5 |
|
||||
| **Total** | **16-25 days** | |
|
||||
|
||||
**Note:** Timeline assumes one developer working full-time. Actual
|
||||
duration may vary based on:
|
||||
|
||||
- Unforeseen integration issues
|
||||
- Performance optimization needs
|
||||
- Testing thoroughness
|
||||
- Code review iterations
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Alternative 1: Keep Line-based but unify GapBuffer/PieceTable
|
||||
|
||||
- Keep vector of Lines, but make each Line always use PieceTable
|
||||
- Remove GapBuffer, remove AppendBuffer selector
|
||||
- **Pros:** Smaller change, less risk
|
||||
- **Cons:** Doesn't achieve architectural goal, still have per-line
|
||||
overhead
|
||||
|
||||
### Alternative 2: Hybrid approach
|
||||
|
||||
- Use PieceTable for buffer, but maintain materialized Line objects as
|
||||
cache
|
||||
- **Pros:** Easier migration, maintains some compatibility
|
||||
- **Cons:** Complex dual representation, cache invalidation issues
|
||||
|
||||
### Alternative 3: Complete rewrite
|
||||
|
||||
- Follow REWRITE.md exactly, implement in Rust
|
||||
- **Pros:** Modern language, better architecture
|
||||
- **Cons:** Much larger effort, different project
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Proceed with planned migration** (single PieceTable per Buffer)
|
||||
because:
|
||||
|
||||
1. Aligns with long-term architecture vision (REWRITE.md)
|
||||
2. Removes unnecessary per-line buffer overhead
|
||||
3. Simplifies codebase (one text representation)
|
||||
4. Enables future optimizations (better undo, swap files, etc.)
|
||||
5. Reasonable effort (16-25 days) for significant improvement
|
||||
|
||||
**Suggested Approach:**
|
||||
|
||||
- Start with Phase 1 (extend PieceTable) in isolated branch
|
||||
- Thoroughly test new PieceTable functionality
|
||||
- Proceed incrementally through phases
|
||||
- Maintain working editor at end of each phase
|
||||
- Merge to main after Phase 4 (before cleanup) to get testing
|
||||
- Complete Phase 5-6 based on feedback
|
||||
|
||||
## References
|
||||
|
||||
- `REWRITE.md` - Rust architecture specification (lines 54-157)
|
||||
- Current buffer implementation: `Buffer.h/cc`
|
||||
- Current piece table: `PieceTable.h/cc`
|
||||
- Undo system: `UndoSystem.h/cc`, `UndoNode.h`
|
||||
- Commands: `Command.cc`
|
||||
@@ -0,0 +1,124 @@
|
||||
Based on the project structure and the presence of files like
|
||||
`imgui.ini`, `GUIFrontend.h`, and `TerminalFrontend.h`, here is an
|
||||
analysis of the difficulty and challenges involved in adding a GTK or Qt
|
||||
version of the GUI.
|
||||
|
||||
### **Executive Summary: Difficulty Level - Moderate**
|
||||
|
||||
The project is well-architected for this task. It already supports
|
||||
multiple frontends (Terminal vs. GUI), meaning the "Core Logic" (
|
||||
Buffers, Syntax, Commands) is successfully decoupled from the "View" (
|
||||
Rendering/Input). However, the specific move from an **Immediate Mode**
|
||||
GUI (likely Dear ImGui, implied by `imgui.ini` and standard naming
|
||||
patterns) to a **Retained Mode** GUI (Qt/GTK) introduces specific
|
||||
architectural frictions regarding the event loop and state management.
|
||||
|
||||
---
|
||||
|
||||
### **1. Architectural Analysis**
|
||||
|
||||
The existence of abstract interfaces—likely `Frontend`, `Renderer`, and
|
||||
`InputHandler`—is the biggest asset here.
|
||||
|
||||
* **Current State:**
|
||||
* **Abstract Layer:** `Frontend.h`, `Renderer.h`, `InputHandler.h`
|
||||
likely define the contract.
|
||||
* **Implementations:**
|
||||
* `Terminal*` files implement the TUI (likely ncurses or VT100).
|
||||
* `GUI*` files (currently ImGui) implement the graphical
|
||||
version.
|
||||
* **The Path Forward:**
|
||||
* You would create `QtFrontend`, `QtRenderer`, `QtInputHandler` (or
|
||||
GTK equivalents).
|
||||
* Because the core logic (`Editor.cc`, `Buffer.cc`) calls these
|
||||
interfaces, you theoretically don't need to touch the core text
|
||||
manipulation code.
|
||||
|
||||
### **2. Key Challenges**
|
||||
|
||||
#### **A. The Event Loop Inversion (Main Challenge)**
|
||||
|
||||
* **Current (ImGui):** Typically, the application owns the loop:
|
||||
`while (running) { HandleInput(); Update(); Render(); }`. The
|
||||
application explicitly tells the GUI to draw every frame.
|
||||
* **Target (Qt/GTK):** The framework owns the loop: `app.exec()` or
|
||||
`gtk_main()`. The framework calls *you* when events happen.
|
||||
* **Difficulty:** You will need to refactor `main.cc` or the entry point
|
||||
to hand over control to the Qt/GTK application object. The Editor's "
|
||||
tick" function might need to be connected to a timer or an idle event
|
||||
in the new framework to ensure logic updates happen.
|
||||
|
||||
#### **B. Rendering Paradigm: Canvas vs. Widgets**
|
||||
|
||||
* **The "Easy" Way (Custom Canvas):**
|
||||
* Implement the `QtRenderer` by subclassing `QWidget` and overriding
|
||||
`paintEvent`.
|
||||
* Use `QPainter` (or Cairo in GTK) to draw text, cursors, and
|
||||
selections exactly where the `Renderer` interface says to.
|
||||
* **Pros:** Keeps the code similar to the current ImGui/Terminal
|
||||
renderers.
|
||||
* **Cons:** You lose native accessibility and some native "feel" (
|
||||
scrolling physics, native text context menus).
|
||||
* **The "Hard" Way (Native Widgets):**
|
||||
* Trying to map an internal `Buffer` directly to a `QTextEdit` or
|
||||
`GtkTextView`.
|
||||
* **Difficulty:** This is usually very hard because the Editor core
|
||||
likely manages its own cursor, selection, and syntax highlighting.
|
||||
Syncing that internal state with a complex native widget often
|
||||
leads to conflicts.
|
||||
* **Recommendation:** Stick to the "Custom Canvas" approach (drawing
|
||||
text manually on a surface) to preserve the custom editor
|
||||
behavior (vim-like modes, specific syntax highlighting).
|
||||
|
||||
#### **C. Input Handling**
|
||||
|
||||
* **Challenge:** Mapping Qt/GTK key events to the internal `Keymap`.
|
||||
* **Detail:** ImGui and Terminal libraries often provide raw scancodes
|
||||
or simple chars. Qt/GTK provide complex Event objects. You will need a
|
||||
translation layer in `QtInputHandler::keyPressEvent` that converts
|
||||
`Qt::Key_Escape` -> `KKey::Escape` (or your internal equivalent).
|
||||
|
||||
### **3. Portability of Assets**
|
||||
|
||||
#### **Themes (Colors)**
|
||||
|
||||
* **Feasibility:** High.
|
||||
* **Approach:** `GUITheme.h` likely contains structs with RGB/Hex
|
||||
values. Qt supports stylesheets (QSS) and GTK uses CSS. You can write
|
||||
a converter that reads your current theme configuration and generates
|
||||
a CSS string to apply to your window, or simply use the RGB values
|
||||
directly in your custom `QPainter`/Cairo drawing logic.
|
||||
|
||||
#### **Fonts**
|
||||
|
||||
* **Feasibility:** Moderate.
|
||||
* **Approach:**
|
||||
* **ImGui:** Usually loads a TTF into a texture atlas.
|
||||
* **Qt/GTK:** Uses the system font engine (Freetype/Pango).
|
||||
* **Challenge:** You won't use the texture atlas anymore. You will
|
||||
simply request a font family and size (e.g.,
|
||||
`QFont("JetBrains Mono", 12)`). You may need to ensure your custom
|
||||
renderer calculates character width/height metrics correctly using
|
||||
`QFontMetrics` (Qt) or `PangoLayout` (GTK) to align the grid
|
||||
correctly.
|
||||
|
||||
### **4. Summary Recommendation**
|
||||
|
||||
If you proceed, **Qt** is generally considered easier to integrate with
|
||||
C++ projects than GTK (which is C-based, though `gtkmm` exists).
|
||||
|
||||
1. **Create a `QtFrontend`** class inheriting from `Frontend`.
|
||||
2. **Create a `QtWindow`** class inheriting from `QWidget`.
|
||||
3. **Implement `QtRenderer`** that holds a pointer to the `QtWindow`.
|
||||
When the core calls `DrawText()`, `QtRenderer` should queue that
|
||||
command or draw directly to the widget's paint buffer.
|
||||
4. **Refactor `main.cc`** to instantiate `QApplication` instead of the
|
||||
current manual loop.
|
||||
|
||||
---
|
||||
|
||||
Note (2025-12): The Qt frontend defers all key processing to the
|
||||
existing command subsystem and keymaps, mirroring the ImGui path. There
|
||||
are no Qt-only keybindings; `QtInputHandler` translates Qt key events
|
||||
into the shared keymap flow (C-k prefix, Ctrl chords, ESC/Meta,
|
||||
universal-argument digits, printable insertion).
|
||||
@@ -0,0 +1,147 @@
|
||||
Swap files for kte — design plan
|
||||
================================
|
||||
|
||||
Goals
|
||||
-----
|
||||
|
||||
- Preserve user work across crashes, power failures, and OS kills.
|
||||
- Keep the editor responsive; avoid blocking the UI on disk I/O.
|
||||
- Bound recovery time and swap size.
|
||||
- Favor simple, robust primitives that work well on POSIX and macOS;
|
||||
keep Windows feasibility in mind.
|
||||
|
||||
Model overview
|
||||
--------------
|
||||
Per open buffer, maintain a swap journal in a per-user state directory:
|
||||
|
||||
- Path: `$XDG_STATE_HOME/kte/swap/<encoded-path>.swp` (or
|
||||
`~/.local/state/kte/swap/...`)
|
||||
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
|
||||
checkpoints.
|
||||
- Crash safety: only append, fsync as per policy; checkpoint via
|
||||
write‑to‑temp + fsync + atomic rename.
|
||||
|
||||
File format (v1)
|
||||
----------------
|
||||
Header (fixed 64 bytes):
|
||||
|
||||
- Magic: `KTE_SWP\0` (8 bytes)
|
||||
- Version: 1 (u32)
|
||||
- Flags: bitset (u32) — e.g., compression, checksums, endian.
|
||||
- Created time (u64)
|
||||
- Host info hash (u64) — optional, for telemetry/debug.
|
||||
- File identity: hash of canonical path (u64) and original file
|
||||
size+mtime (u64+u64) at start.
|
||||
- Reserved/padding.
|
||||
|
||||
Records (stream after header):
|
||||
|
||||
- Each record: [type u8][len u24][payload][crc32 u32]
|
||||
- Types:
|
||||
- `CHKPT` — full snapshot checkpoint of entire buffer content and
|
||||
minimal metadata (cursor pos, filetype). Payload optionally
|
||||
compressed. Written occasionally to cap replay time.
|
||||
- `INS` — insert at (row, col) text bytes (text may contain
|
||||
newlines). Encoded with varints.
|
||||
- `DEL` — delete length at (row, col). If spanning lines, semantics
|
||||
defined as in Buffer::delete_text.
|
||||
- `SPLIT`, `JOIN` — explicit structural ops (optional; can be
|
||||
expressed via INS/DEL).
|
||||
- `META` — update metadata (e.g., filetype, encoding hints).
|
||||
|
||||
Durability policy
|
||||
-----------------
|
||||
Configurable knobs (sane defaults in parentheses):
|
||||
|
||||
- Time‑based flush: group edits and flush every 150–300 ms (200 ms).
|
||||
- Operation count flush: after N ops (200).
|
||||
- Idle flush: on 500 ms idle lull, flush immediately.
|
||||
- Checkpoint cadence: after M KB of journal (512–2048 KB) or T seconds (
|
||||
30–120 s), whichever first.
|
||||
- fsync policy:
|
||||
- `always`: fsync every flush (safest, slowest).
|
||||
- `grouped` (default): fsync at most every 1–2 s or on
|
||||
idle/blur/quit.
|
||||
- `never`: rely on OS flush (fastest, riskier).
|
||||
- On POSIX, prefer `fdatasync` when available; fall back to `fsync`.
|
||||
|
||||
Performance & threading
|
||||
-----------------------
|
||||
|
||||
- Background writer thread per editor instance (shared) with a bounded
|
||||
MPSC queue of per‑buffer records.
|
||||
- Each Buffer has a small in‑memory journal buffer; UI thread enqueues
|
||||
ops (non‑blocking) and may coalesce adjacent inserts/deletes.
|
||||
- Writer batch‑writes records to the swap file, computes CRCs, and
|
||||
decides checkpoint boundaries.
|
||||
- Backpressure: if the queue grows beyond a high watermark, signal the
|
||||
UI to start coalescing more aggressively and slow enqueue (never block
|
||||
hard editing path; at worst drop optional `META`).
|
||||
|
||||
Recovery flow
|
||||
-------------
|
||||
|
||||
On opening a file:
|
||||
|
||||
1. Detect swap journal `$XDG_STATE_HOME/kte/swap/<encoded-path>.swp`.
|
||||
2. Validate header, iterate records verifying CRCs.
|
||||
3. Compare recorded original file identity against actual file; if
|
||||
mismatch, warn user but allow recovery (content wins).
|
||||
4. Reconstruct buffer: start from the last good `CHKPT` (if any), then
|
||||
replay subsequent ops. If trailing partial record encountered (EOF
|
||||
mid‑record), truncate at last good offset.
|
||||
5. Present a choice: Recover (load recovered buffer; keep the swap file
|
||||
until user saves) or Discard (delete swap file and open clean file).
|
||||
|
||||
Stability & corruption mitigation
|
||||
---------------------------------
|
||||
|
||||
- Append‑only with per‑record CRC32 guards against torn writes.
|
||||
- Atomic checkpoint rotation: write `<encoded-path>.swp.tmp`, fsync,
|
||||
then rename over old `.swp`.
|
||||
- Size caps: rotate or compact when `.swp` exceeds a threshold (e.g.,
|
||||
64–128 MB). Compaction creates a fresh file with a single checkpoint.
|
||||
- Low‑disk‑space behavior: on write failures, surface a non‑modal
|
||||
warning and temporarily fall back to in‑memory only; retry
|
||||
opportunistically.
|
||||
|
||||
Security considerations
|
||||
-----------------------
|
||||
|
||||
- Swap files mirror buffer content, which may be sensitive. Options:
|
||||
- Configurable location (same dir vs. `$XDG_STATE_HOME/kte/swap`).
|
||||
- Optional per‑file encryption (future work) using OS keychain.
|
||||
- Ensure permissions are 0600.
|
||||
|
||||
Interoperability & UX
|
||||
---------------------
|
||||
|
||||
- Use a distinctive directory (`$XDG_STATE_HOME/kte/swap`) to avoid
|
||||
conflicts with other editors’ `.swp` conventions.
|
||||
- Status bar indicator when swap is active; commands to purge/compact.
|
||||
- On save: do not delete swap immediately; keep until the buffer is
|
||||
clean and idle for a short grace period (allows undo of accidental
|
||||
external changes).
|
||||
|
||||
Implementation plan (staged)
|
||||
----------------------------
|
||||
|
||||
1. Minimal journal writer (append‑only INS/DEL) with grouped fsync;
|
||||
single per‑editor writer thread.
|
||||
2. Reader/recovery path with CRC validation and replay.
|
||||
3. Checkpoints + atomic rotation; compaction path.
|
||||
4. Config surface and UI prompts; telemetry counters.
|
||||
5. Optional compression and advanced coalescing.
|
||||
|
||||
Defaults balancing performance and stability
|
||||
-------------------------------------------
|
||||
|
||||
- Grouped flush with fsync every ~1 s or on idle/quit.
|
||||
- Checkpoint every 1 MB or 60 s.
|
||||
- Bounded queue and batch writes to minimize syscalls.
|
||||
- Immediate flush on critical events (buffer close, app quit, power
|
||||
source change on laptops if detectable).
|
||||
@@ -0,0 +1,163 @@
|
||||
### Unit testing plan (headless, no interactive frontend)
|
||||
|
||||
#### Principles
|
||||
- Headless-only: exercise core components directly (`PieceTable`, `Buffer`, `UndoSystem`, `OptimizedSearch`, and minimal `Editor` flows) without starting `kte` or `kge`.
|
||||
- Deterministic and fast: avoid timers, GUI, environment-specific behavior; prefer in-memory operations and temporary files.
|
||||
- Regression-focused: encode prior failures (save/newline mismatch, legacy `rows_` writes) as explicit tests to prevent recurrences.
|
||||
|
||||
#### Harness and execution
|
||||
- Single binary: use target `kte_tests` (already present) to compile and run all tests under `tests/` with the minimal in-tree framework (`tests/Test.h`, `tests/TestRunner.cc`).
|
||||
- No GUI/ncurses deps: link only engine sources (PieceTable/Buffer/Undo/Search/Undo* and syntax minimal set), not frontends.
|
||||
- How to build/run:
|
||||
- Debug profile:
|
||||
```
|
||||
cmake -S /Users/kyle/src/kte -B /Users/kyle/src/kte/cmake-build-debug -DBUILD_TESTS=ON && \
|
||||
cmake --build /Users/kyle/src/kte/cmake-build-debug --target kte_tests && \
|
||||
/Users/kyle/src/kte/cmake-build-debug/kte_tests
|
||||
```
|
||||
- Release profile:
|
||||
```
|
||||
cmake -S /Users/kyle/src/kte -B /Users/kyle/src/kte/cmake-build-release -DBUILD_TESTS=ON && \
|
||||
cmake --build /Users/kyle/src/kte/cmake-build-release --target kte_tests && \
|
||||
/Users/kyle/src/kte/cmake-build-release/kte_tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Test catalog (summary table)
|
||||
|
||||
The table below catalogs all unit tests defined in this plan. It is headless-only and maps directly to the suites A–H described later. “Implemented” reflects current coverage in `kte_tests`.
|
||||
|
||||
| Suite | ID | Name | Description (1‑line) | Headless | Implemented |
|
||||
|:-----:|:---:|:------------------------------------------|:-------------------------------------------------------------------------------------|:--------:|:-----------:|
|
||||
| A | 1 | SaveAs then Save (append) | New buffer → write two lines → `SaveAs` → append → `Save`; verify exact bytes. | Yes | ✓ |
|
||||
| A | 2 | Open existing then Save | Open seeded file, append, `Save`; verify overwrite bytes. | Yes | ✓ |
|
||||
| A | 3 | Open non-existent then SaveAs | Start from non-existent path, insert `hello, world\n`, `SaveAs`; verify bytes. | Yes | ✓ |
|
||||
| A | 4 | Trailing newline preservation | Verify saving preserves presence/absence of final `\n`. | Yes | Planned |
|
||||
| A | 5 | Empty buffer saves | Empty → `SaveAs` → 0 bytes; then insert `\n` → `Save` → 1 byte. | Yes | Planned |
|
||||
| A | 6 | Large file streaming | 1–4 MiB with periodic newlines; size and content integrity. | Yes | Planned |
|
||||
| A | 7 | Tilde expansion | `SaveAs` with `~/...`; re-open to confirm path/content. | Yes | Planned |
|
||||
| A | 8 | Error propagation | Save to unwritable path → expect failure and error message. | Yes | Planned |
|
||||
| B | 1 | Insert/Delete LineCount | Basic inserts/deletes and line counting sanity. | Yes | ✓ |
|
||||
| B | 2 | Line/Col conversions | `LineColToByteOffset` and reverse around boundaries. | Yes | ✓ |
|
||||
| B | 3 | Delete spanning newlines | Delete ranges that cross line breaks; verify bytes/lines. | Yes | Planned |
|
||||
| B | 4 | Split/Join equivalence | `split_line` followed by `join_lines` yields original bytes. | Yes | Planned |
|
||||
| B | 5 | Stream vs Data equivalence | `WriteToStream` matches `GetRange`/`Data()` after edits. | Yes | Planned |
|
||||
| B | 6 | UTF‑8 bytes stability | Multibyte sequences behave correctly (byte-based ops). | Yes | Planned |
|
||||
| C | 1 | insert_text/delete_text | Edits at start/middle/end; `Rows()` mirrors PieceTable. | Yes | Planned |
|
||||
| C | 2 | split_line/join_lines | Effects and snapshots across multiple positions. | Yes | Planned |
|
||||
| C | 3 | insert_row/delete_row | Replace paragraph by row ops; verify bytes/linecount. | Yes | Planned |
|
||||
| C | 4 | Cache invalidation | After each mutation, `Rows()` matches `LineCount()`. | Yes | Planned |
|
||||
| D | 1 | Grouped insert undo | Contiguous typing undone/redone as a group. | Yes | Planned |
|
||||
| D | 2 | Delete/Newline undo/redo | Backspace/Delete and Newline transitions across undo/redo. | Yes | Planned |
|
||||
| D | 3 | Mark saved & dirty | Dirty/save markers interact correctly with undo/redo. | Yes | Planned |
|
||||
| E | 1 | Search parity basic | `OptimizedSearch::find_all` vs `std::string` reference. | Yes | ✓ |
|
||||
| E | 2 | Large text search | ~1 MiB random text/patterns parity. | Yes | Planned |
|
||||
| F | 1 | Editor open & reload | Open via `Editor`, modify, reload, verify on-disk bytes. | Yes | Planned |
|
||||
| F | 2 | Read-only toggle | Toggle and verify enforcement/behavior of saves. | Yes | Planned |
|
||||
| F | 3 | Prompt lifecycle | Start/Accept/Cancel prompt doesn’t corrupt state. | Yes | Planned |
|
||||
| G | 1 | Saved only newline regression | Insert text + newline; `Save` includes both bytes. | Yes | Planned |
|
||||
| G | 2 | Backspace crash regression | PieceTable-backed delete/join path remains stable. | Yes | Planned |
|
||||
| G | 3 | Overwrite-confirm path | Saving over existing path succeeds and is correct. | Yes | Planned |
|
||||
| H | 1 | Many small edits | 10k small edits; final bytes correct within time bounds. | Yes | Planned |
|
||||
| H | 2 | Consolidation equivalence | After many edits, stream vs data produce identical bytes. | Yes | Planned |
|
||||
|
||||
Legend: Implemented = ✓, Planned = to be added per Coverage roadmap.
|
||||
|
||||
### Test suites and cases
|
||||
|
||||
#### A) Filesystem I/O via Buffer
|
||||
1) SaveAs then Save (append)
|
||||
- New buffer → `insert_text` two lines (explicit `\n`) → `SaveAs(tmp)` → insert a third line → `Save()`.
|
||||
- Assert file bytes equal exact expected string.
|
||||
2) Open existing then Save
|
||||
- Seed a file on disk; `OpenFromFile(path)` → append line → `Save()`.
|
||||
- Assert file bytes updated exactly.
|
||||
3) Open non-existent then SaveAs
|
||||
- `OpenFromFile(nonexistent)` → assert `IsFileBacked()==false` → insert `"hello, world\n"` → `SaveAs(path)`.
|
||||
- Read back exact bytes.
|
||||
4) Trailing newline preservation
|
||||
- Case (a) last line without `\n`; (b) last line with `\n` → save and verify bytes unchanged.
|
||||
5) Empty buffer saves
|
||||
- `SaveAs(tmp)` on empty buffer → 0-byte file. Then insert `"\n"` and `Save()` → 1-byte file.
|
||||
6) Large file streaming
|
||||
- Insert ~1–4 MiB of data with periodic newlines. `SaveAs` then `Save`; verify size matches `content_.Size()` and bytes integrity.
|
||||
7) Path normalization and tilde expansion
|
||||
- `SaveAs("~/.../file.txt")` → verify path expands to `$HOME` and file content round-trips with `OpenFromFile`.
|
||||
8) Error propagation (guarded)
|
||||
- Attempt save into a non-writable path; expect `Save/SaveAs` returns false with non-empty error. Mark as skipped in environments lacking such path.
|
||||
|
||||
#### B) PieceTable semantics
|
||||
1) Line counting and deletion across lines
|
||||
- Insert `"abc\n123\nxyz"` → 3 lines; delete middle line range → 2 lines; validate `GetLine` contents.
|
||||
2) Position conversions
|
||||
- Validate `LineColToByteOffset` and `ByteOffsetToLineCol` at start/end of lines and EOF, especially around `\n`.
|
||||
3) Delete spanning newlines
|
||||
- Remove a range that crosses line boundaries; verify resulting bytes, `LineCount` and line contents.
|
||||
4) Split/join equivalence
|
||||
- Split at various columns; then join adjacent lines; verify bytes equal original.
|
||||
5) WriteToStream vs materialized `Data()`
|
||||
- After multiple inserts/deletes (without forcing `Data()`), stream to `std::ostringstream`; compare with `GetRange(0, Size())`, then call `Data()` and re-compare.
|
||||
6) UTF-8 bytes stability
|
||||
- Insert multibyte sequences (e.g., `"héllo"`, `"中文"`, emoji) as raw bytes; ensure line counting and conversions behave (byte-based API; no crashes/corruption).
|
||||
|
||||
#### C) Buffer editing helpers and rows cache correctness
|
||||
1) `insert_text`/`delete_text`
|
||||
- Apply at start/middle/end of lines; immediately call `Rows()` and validate contents/lengths mirror PieceTable.
|
||||
2) `split_line` and `join_lines`
|
||||
- Verify content effects and `Rows()` snapshots for multiple positions and consecutive operations.
|
||||
3) `insert_row`/`delete_row`
|
||||
- Replace a paragraph by deleting N rows then inserting N′ rows; verify bytes and `LineCount`.
|
||||
4) Cache invalidation
|
||||
- After each mutation, fetch `Rows()`; assert `Nrows() == content.LineCount()` and no stale data remains.
|
||||
|
||||
#### D) UndoSystem semantics
|
||||
1) Grouped contiguous insert undo
|
||||
- Emulate typing at a single location via repeated `insert_text`; one `undo()` should remove the whole run; `redo()` restores it.
|
||||
2) Delete/newline undo/redo
|
||||
- Simulate backspace/delete (`delete_text` and `join_lines`) and newline (`split_line`); verify content transitions across `undo()`/`redo()`.
|
||||
3) Mark saved and dirty flag
|
||||
- After successful save, call `UndoSystem::mark_saved()` (via existing pathways) and ensure dirty state pairing behaves as intended (at least: `SetDirty(false)` plus save does not break undo/redo).
|
||||
|
||||
#### E) Search algorithms
|
||||
1) Parity with `std::string::find`
|
||||
- Use `OptimizedSearch::find_all` across edge cases (empty needle/text, overlaps like `"aaaaa"` vs `"aa"`, Unicode byte sequences). Compare to reference implementation.
|
||||
2) Large text
|
||||
- Random ASCII text ~1 MiB; random patterns; results match reference.
|
||||
|
||||
#### F) Editor non-interactive flows (no frontend)
|
||||
1) Open and reload
|
||||
- Through `Editor`, open file; modify the underlying `Buffer` directly; invoke reload (`Buffer::OpenFromFile` or `cmd_reload_buffer` if you bring `Command.cc` into the test target). Verify bytes match the on-disk file after reload.
|
||||
2) Read-only toggle
|
||||
- Toggle `Buffer::ToggleReadOnly()`; confirm flag value changes and that subsequent saves still execute when not read-only (or, if enforcement exists, that mutations are appropriately restricted).
|
||||
3) Prompt lifecycle (headless)
|
||||
- Exercise `StartPrompt` → `AcceptPrompt` → `CancelPrompt`; ensure state resets and does not corrupt buffer/editor state.
|
||||
|
||||
#### G) Regression tests for reported bugs
|
||||
1) “Saved only newline”
|
||||
- Build buffer content via `insert_text` followed by `split_line` for newline; `Save` then validate bytes include both the text and newline.
|
||||
2) Backspace crash path
|
||||
- Mimic backspace behavior using PieceTable-backed helpers (`delete_text`/`join_lines`); ensure no dependency on legacy `rows_` mutation and no memory issues.
|
||||
3) Overwrite-confirm path behavior
|
||||
- Start with non-file-backed buffer named to collide with an existing file; perform `SaveAs(existing_path)` and assert success and correctness on disk (unit test bypasses interactive confirm, validating underlying write path).
|
||||
|
||||
#### H) Performance/stress sanity
|
||||
1) Many small edits
|
||||
- 10k single-char inserts and interleaved deletes; assert final bytes; keep within conservative runtime bounds.
|
||||
2) Consolidation heuristics
|
||||
- After many edits, call both `WriteToStream` and `Data()` and verify identical bytes.
|
||||
|
||||
---
|
||||
|
||||
### Coverage roadmap
|
||||
- Phase 1 (already implemented and passing):
|
||||
- Buffer I/O basics (A.1–A.3), PieceTable basics (B.1–B.2), Search parity (E.1).
|
||||
- Phase 2 (add next):
|
||||
- Buffer I/O edge cases (A.4–A.7), deeper PieceTable ops (B.3–B.6), Buffer helpers and cache (C.1–C.4), Undo semantics (D.1–D.2), Regression set (G.1–G.3).
|
||||
- Phase 3:
|
||||
- Editor flows (F.1–F.3), performance/stress (H.1–H.2), and optional integration of `Command.cc` into the test target to exercise non-interactive command execution paths directly.
|
||||
|
||||
### Notes
|
||||
- Use per-test temp files under the repo root or a unique temp directory; ensure cleanup after assertions.
|
||||
- For HOME-dependent tests (tilde expansion), set `HOME` in the test process if not present or skip with a clear message.
|
||||
- On macOS Debug, a benign allocator warning may appear; rely on process exit code for pass/fail.
|
||||
+237
@@ -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)
|
||||
+85
-34
@@ -4,67 +4,118 @@ Syntax highlighting in kte
|
||||
Overview
|
||||
--------
|
||||
|
||||
kte provides lightweight syntax highlighting with a pluggable highlighter interface. The initial implementation targets C/C++ and focuses on speed and responsiveness.
|
||||
kte provides lightweight syntax highlighting with a pluggable
|
||||
highlighter interface. The initial implementation targets C/C++ and
|
||||
focuses on speed and responsiveness.
|
||||
|
||||
Core types
|
||||
----------
|
||||
|
||||
- `TokenKind` — token categories (keywords, types, strings, comments, numbers, preprocessor, operators, punctuation, identifiers, whitespace, etc.).
|
||||
- `HighlightSpan` — a half-open column range `[col_start, col_end)` with a `TokenKind`.
|
||||
- `LineHighlight` — a vector of `HighlightSpan` and the buffer `version` used to compute it.
|
||||
- `TokenKind` — token categories (keywords, types, strings, comments,
|
||||
numbers, preprocessor, operators, punctuation, identifiers,
|
||||
whitespace, etc.).
|
||||
- `HighlightSpan` — a half-open column range `[col_start, col_end)` with
|
||||
a `TokenKind`.
|
||||
- `LineHighlight` — a vector of `HighlightSpan` and the buffer `version`
|
||||
used to compute it.
|
||||
|
||||
Engine and caching
|
||||
------------------
|
||||
|
||||
- `HighlighterEngine` maintains a per-line cache of `LineHighlight` keyed by row and buffer version.
|
||||
- Cache invalidation occurs when the buffer version changes or when the buffer calls `InvalidateFrom(row)`, which clears cached lines and line states from `row` downward.
|
||||
- The engine supports both stateless and stateful highlighters. For stateful highlighters, it memoizes a simple per-line state and computes lines sequentially when necessary.
|
||||
- `HighlighterEngine` maintains a per-line cache of `LineHighlight`
|
||||
keyed by row and buffer version.
|
||||
- Cache invalidation occurs when the buffer version changes or when the
|
||||
buffer calls `InvalidateFrom(row)`, which clears cached lines and line
|
||||
states from `row` downward.
|
||||
- The engine supports both stateless and stateful highlighters. For
|
||||
stateful highlighters, it memoizes a simple per-line state and
|
||||
computes lines sequentially when necessary.
|
||||
|
||||
Stateful highlighters
|
||||
---------------------
|
||||
|
||||
- `LanguageHighlighter` is the base interface for stateless per-line tokenization.
|
||||
- `StatefulHighlighter` extends it with a `LineState` and the method `HighlightLineStateful(buf, row, prev_state, out)`.
|
||||
- The engine detects `StatefulHighlighter` via dynamic_cast and feeds each line the previous line’s state, caching the resulting state per line.
|
||||
- `LanguageHighlighter` is the base interface for stateless per-line
|
||||
tokenization.
|
||||
- `StatefulHighlighter` extends it with a `LineState` and the method
|
||||
`HighlightLineStateful(buf, row, prev_state, out)`.
|
||||
- The engine detects `StatefulHighlighter` via dynamic_cast and feeds
|
||||
each line the previous line’s state, caching the resulting state per
|
||||
line.
|
||||
|
||||
C/C++ highlighter
|
||||
-----------------
|
||||
|
||||
- `CppHighlighter` implements `StatefulHighlighter`.
|
||||
- Stateless constructs: line comments `//`, strings `"..."`, chars `'...'`, numbers, identifiers (keywords/types), preprocessor at beginning of line after leading whitespace, operators/punctuation, and whitespace.
|
||||
- Stateless constructs: line comments `//`, strings `"..."`, chars
|
||||
`'...'`, numbers, identifiers (keywords/types), preprocessor at
|
||||
beginning of line after leading whitespace, operators/punctuation, and
|
||||
whitespace.
|
||||
- Stateful constructs (v2):
|
||||
- Multi-line block comments `/* ... */` — the state records whether the next line continues a comment.
|
||||
- Raw strings `R"delim(... )delim"` — the state tracks whether we are inside a raw string and its delimiter `delim` until the closing sequence appears.
|
||||
- Multi-line block comments `/* ... */` — the state records whether
|
||||
the next line continues a comment.
|
||||
- Raw strings `R"delim(... )delim"` — the state tracks whether we
|
||||
are inside a raw string and its delimiter `delim` until the
|
||||
closing sequence appears.
|
||||
|
||||
Limitations and TODOs
|
||||
---------------------
|
||||
|
||||
- Raw string detection is intentionally simple and does not handle all corner cases of the C++ standard.
|
||||
- Preprocessor handling is line-based; continuation lines with `\\` are not yet tracked.
|
||||
- No semantic analysis; identifiers are classified via small keyword/type sets.
|
||||
- Additional languages (JSON, Markdown, Shell, Python, Go, Rust, Lisp, …) are planned.
|
||||
- Terminal color mapping is conservative to support 8/16-color terminals. Rich color-pair themes can be added later.
|
||||
- Raw string detection is intentionally simple and does not handle all
|
||||
corner cases of the C++ standard.
|
||||
- Preprocessor handling is line-based; continuation lines with `\\` are
|
||||
not yet tracked.
|
||||
- No semantic analysis; identifiers are classified via small
|
||||
keyword/type sets.
|
||||
- Additional languages (JSON, Markdown, Shell, Python, Go, Rust,
|
||||
Lisp, …) are planned.
|
||||
- Terminal color mapping is conservative to support 8/16-color
|
||||
terminals. Rich color-pair themes can be added later.
|
||||
|
||||
Renderer integration
|
||||
--------------------
|
||||
|
||||
- Terminal and GUI renderers request line spans via `Highlighter()->GetLine(buf, row, buf.Version())`.
|
||||
- Search highlight and cursor overlays take precedence over syntax colors.
|
||||
- Terminal and GUI renderers request line spans via
|
||||
`Highlighter()->GetLine(buf, row, buf.Version())`.
|
||||
- Search highlight and cursor overlays take precedence over syntax
|
||||
colors.
|
||||
|
||||
Renderer-side robustness
|
||||
------------------------
|
||||
|
||||
- Renderers defensively sanitize `HighlightSpan` data before use to
|
||||
ensure stability even if a highlighter misbehaves:
|
||||
- Clamp `col_start/col_end` to the line length and ensure
|
||||
`end >= start`.
|
||||
- Drop empty/invalid spans and sort by start.
|
||||
- Clip drawing to the horizontally visible region and the
|
||||
tab-expanded line length.
|
||||
- The highlighter engine returns `LineHighlight` by value to avoid
|
||||
cross-thread lifetime issues; renderers operate on a local copy for
|
||||
each frame.
|
||||
|
||||
Extensibility (Phase 4)
|
||||
-----------------------
|
||||
|
||||
- Public registration API: external code can register custom highlighters by filetype.
|
||||
- Use `HighlighterRegistry::Register("mylang", []{ return std::make_unique<MyHighlighter>(); });`
|
||||
- Registered factories are preferred over built-ins for the same filetype key.
|
||||
- Filetype keys are normalized via `HighlighterRegistry::Normalize()`.
|
||||
- Optional Tree-sitter adapter: disabled by default to keep dependencies minimal.
|
||||
- Enable with CMake option `-DKTE_ENABLE_TREESITTER=ON` and provide
|
||||
`-DTREESITTER_INCLUDE_DIR=...` and `-DTREESITTER_LIBRARY=...` if needed.
|
||||
- Register a Tree-sitter-backed highlighter for a language (example assumes you link a grammar):
|
||||
```c++
|
||||
extern "C" const TSLanguage* tree_sitter_c();
|
||||
kte::HighlighterRegistry::RegisterTreeSitter("c", &tree_sitter_c);
|
||||
```
|
||||
- Current adapter is a stub scaffold; it compiles and integrates cleanly when enabled, but
|
||||
intentionally emits no spans until Tree-sitter node-to-token mapping is implemented.
|
||||
- Public registration API: external code can register custom
|
||||
highlighters by filetype.
|
||||
- Use
|
||||
`HighlighterRegistry::Register("mylang", []{ return std::make_unique<MyHighlighter>(); });`
|
||||
- Registered factories are preferred over built-ins for the same
|
||||
filetype key.
|
||||
- Filetype keys are normalized via
|
||||
`HighlighterRegistry::Normalize()`.
|
||||
- Optional Tree-sitter adapter: disabled by default to keep dependencies
|
||||
minimal.
|
||||
- Enable with CMake option `-DKTE_ENABLE_TREESITTER=ON` and provide
|
||||
`-DTREESITTER_INCLUDE_DIR=...` and `-DTREESITTER_LIBRARY=...` if
|
||||
needed.
|
||||
- Register a Tree-sitter-backed highlighter for a language (example
|
||||
assumes you link a grammar):
|
||||
```c++
|
||||
extern "C" const TSLanguage* tree_sitter_c();
|
||||
kte::HighlighterRegistry::RegisterTreeSitter("c", &tree_sitter_c);
|
||||
```
|
||||
- Current adapter is a stub scaffold; it compiles and integrates
|
||||
cleanly when enabled, but
|
||||
intentionally emits no spans until Tree-sitter node-to-token
|
||||
mapping is implemented.
|
||||
|
||||
+9
-4
@@ -23,29 +23,34 @@ Current themes (alphabetically):
|
||||
- **gruvbox** — Retro groove color scheme (light/dark variants)
|
||||
- **kanagawa-paper** — Inspired by traditional Japanese art
|
||||
- **lcars** — Star Trek LCARS interface style
|
||||
- **leuchtturm** — Modern, clean theme (light/dark variants)
|
||||
- **nord** — Arctic, north-bluish color palette
|
||||
- **old-book** — Sepia-toned vintage book aesthetic (light/dark
|
||||
variants)
|
||||
- **orbital** — Space-themed dark palette
|
||||
- **plan9** — Minimalist Plan 9 from Bell Labs inspired
|
||||
- **solarized** — Ethan Schoonover's Solarized (light/dark variants)
|
||||
- **tufte** — Edward Tufte-inspired minimalist theme (light/dark variants)
|
||||
- **weyland-yutani** — Alien franchise corporate aesthetic
|
||||
- **zenburn** — Low-contrast, easy-on-the-eyes theme
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
|
||||
Themes are configured via `$HOME/.config/kte/kge.ini`:
|
||||
Themes are configured via `$HOME/.config/kte/kge.toml`:
|
||||
|
||||
```ini
|
||||
theme = nord
|
||||
background = dark
|
||||
```toml
|
||||
[appearance]
|
||||
theme = "nord"
|
||||
background = "dark"
|
||||
```
|
||||
|
||||
- `theme` — The theme name (e.g., "nord", "gruvbox", "solarized")
|
||||
- `background` — Either "dark" or "light" (for themes supporting both
|
||||
variants)
|
||||
|
||||
Legacy `kge.ini` format is also supported (see CONFIG.md).
|
||||
|
||||
Themes can also be switched at runtime using the `:theme <name>`
|
||||
command.
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,7 @@
|
||||
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
|
||||
outputs =
|
||||
inputs@{ self, nixpkgs, ... }:
|
||||
outputs = { self, nixpkgs, ... }:
|
||||
let
|
||||
eachSystem = nixpkgs.lib.genAttrs nixpkgs.lib.systems.flakeExposed;
|
||||
pkgsFor = system: import nixpkgs { inherit system; };
|
||||
@@ -13,8 +12,31 @@
|
||||
packages = eachSystem (system: rec {
|
||||
default = kte;
|
||||
full = kge;
|
||||
kte = (pkgsFor system).callPackage ./default.nix { graphical = false; };
|
||||
kge = (pkgsFor system).callPackage ./default.nix { graphical = true; };
|
||||
kte = (pkgsFor system).callPackage ./default.nix { graphical = false; graphical-qt = false; };
|
||||
kge = (pkgsFor system).callPackage ./default.nix { graphical = true; graphical-qt = false; };
|
||||
qt = (pkgsFor system).callPackage ./default.nix { graphical = true; graphical-qt = true; };
|
||||
});
|
||||
|
||||
devShells = eachSystem (system:
|
||||
let pkgs = pkgsFor system;
|
||||
in {
|
||||
default = pkgs.mkShell {
|
||||
inputsFrom = [ self.packages.${system}.kge ];
|
||||
packages = with pkgs; [ gdb valgrind ];
|
||||
};
|
||||
terminal = pkgs.mkShell {
|
||||
inputsFrom = [ self.packages.${system}.kte ];
|
||||
};
|
||||
qt = pkgs.mkShell {
|
||||
inputsFrom = [ self.packages.${system}.qt ];
|
||||
packages = with pkgs; [ gdb valgrind ];
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
overlays.default = final: prev: {
|
||||
kte = self.packages.${final.system}.kte;
|
||||
kge = self.packages.${final.system}.kge;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
+1
-1
@@ -11884,4 +11884,4 @@ static const unsigned int DefaultFontRegularCompressedData[72616 / 4] =
|
||||
0x0070a96e,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -5586,4 +5586,4 @@ static const unsigned int DefaultFontBoldCompressedData[32380 / 4] =
|
||||
0x0e01060e, 0xff01b82a, 0x8d04b085,
|
||||
0x440002b1, 0x066405b3, 0x00444400, 0x01000000, 0x00000000, 0xfacafa05, 0x00004aa6,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5918,4 +5918,4 @@ static const unsigned int DefaultFontRegularCompressedData[34064 / 4] =
|
||||
0x20080082, 0x01060eb3, 0x01b82a0e,
|
||||
0x04b085ff, 0x0002b18d, 0x6405b344, 0x44440006, 0x00000000, 0x00000001, 0xccfa0500, 0x0030ee4f,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+1768
File diff suppressed because it is too large
Load Diff
+1203
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user