Compare commits
115 Commits
syntax-hig
...
v1.11.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| cb1d16ebbc | |||
| cbbde43dc2 | |||
| 45b2b88623 | |||
| 389dcf9cc7 | |||
| c98d9e717a | |||
| c864af7daa | |||
| 64022766c5 | |||
| d706b6db44 | |||
| bce9b3b33e | |||
| 56dc904432 | |||
| a8197939f8 | |||
| 9f722ec2bb | |||
| 094020dab5 | |||
| 09e4cd7ec6 | |||
| 49fa7ff8a7 | |||
| 38915484ac | |||
| 87b1e6f502 | |||
| ae822083c2 | |||
| 0c93d619c8 | |||
| 483ff18b0d | |||
| cd33e8feb1 | |||
| 0bfe75fbf0 | |||
| d15b241140 | |||
| ceef6af3ae | |||
| e62cf3ee28 |
3
.idea/codeStyles/Project.xml
generated
3
.idea/codeStyles/Project.xml
generated
@@ -141,6 +141,9 @@
|
||||
<pair source="c++m" header="" fileNamingConvention="NONE" />
|
||||
</extensions>
|
||||
</files>
|
||||
<codeStyleSettings language="Markdown">
|
||||
<option name="RIGHT_MARGIN" value="72" />
|
||||
</codeStyleSettings>
|
||||
<codeStyleSettings language="ObjectiveC">
|
||||
<indentOptions>
|
||||
<option name="INDENT_SIZE" value="8" />
|
||||
|
||||
1
.idea/codeStyles/codeStyleConfig.xml
generated
1
.idea/codeStyles/codeStyleConfig.xml
generated
@@ -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>
|
||||
3
.idea/editor.xml
generated
3
.idea/editor.xml
generated
@@ -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" />
|
||||
|
||||
2
.idea/kte.iml
generated
2
.idea/kte.iml
generated
@@ -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="" />
|
||||
|
||||
@@ -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,15 +0,0 @@
|
||||
/*
|
||||
* AppendBuffer.h - selector header to choose GapBuffer or PieceTable
|
||||
*/
|
||||
#ifndef KTE_APPENDBUFFER_H
|
||||
#define KTE_APPENDBUFFER_H
|
||||
|
||||
#ifdef KTE_USE_PIECE_TABLE
|
||||
#include "PieceTable.h"
|
||||
using AppendBuffer = PieceTable;
|
||||
#else
|
||||
#include "GapBuffer.h"
|
||||
using AppendBuffer = GapBuffer;
|
||||
#endif
|
||||
|
||||
#endif // KTE_APPENDBUFFER_H
|
||||
618
Buffer.cc
618
Buffer.cc
@@ -2,10 +2,28 @@
|
||||
#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"
|
||||
|
||||
|
||||
Buffer::Buffer()
|
||||
@@ -16,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;
|
||||
@@ -33,6 +222,8 @@ Buffer::Buffer(const Buffer &other)
|
||||
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_;
|
||||
@@ -40,9 +231,34 @@ Buffer::Buffer(const Buffer &other)
|
||||
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_;
|
||||
filetype_ = other.filetype_;
|
||||
// Fresh undo system for the copy
|
||||
undo_tree_ = std::make_unique<UndoTree>();
|
||||
undo_sys_ = std::make_unique<UndoSystem>(*this, *undo_tree_);
|
||||
|
||||
// Recreate a highlighter engine for this copy based on filetype/syntax state
|
||||
if (syntax_enabled_) {
|
||||
// Allocate engine and install an appropriate highlighter
|
||||
highlighter_ = std::make_unique<kte::HighlighterEngine>();
|
||||
if (!filetype_.empty()) {
|
||||
auto hl = kte::HighlighterRegistry::CreateFor(filetype_);
|
||||
if (hl) {
|
||||
highlighter_->SetHighlighter(std::move(hl));
|
||||
} else {
|
||||
// Unsupported filetype -> NullHighlighter keeps syntax pipeline active
|
||||
highlighter_->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
||||
}
|
||||
} else {
|
||||
// No filetype -> keep syntax enabled but use NullHighlighter
|
||||
highlighter_->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
||||
}
|
||||
// Fresh engine has empty caches; nothing to invalidate
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +274,8 @@ Buffer::operator=(const Buffer &other)
|
||||
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_;
|
||||
@@ -65,9 +283,30 @@ Buffer::operator=(const Buffer &other)
|
||||
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_;
|
||||
filetype_ = other.filetype_;
|
||||
// Recreate undo system for this instance
|
||||
undo_tree_ = std::make_unique<UndoTree>();
|
||||
undo_sys_ = std::make_unique<UndoSystem>(*this, *undo_tree_);
|
||||
|
||||
// Recreate highlighter engine consistent with syntax settings
|
||||
highlighter_.reset();
|
||||
if (syntax_enabled_) {
|
||||
highlighter_ = std::make_unique<kte::HighlighterEngine>();
|
||||
if (!filetype_.empty()) {
|
||||
auto hl = kte::HighlighterRegistry::CreateFor(filetype_);
|
||||
if (hl) {
|
||||
highlighter_->SetHighlighter(std::move(hl));
|
||||
} else {
|
||||
highlighter_->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
||||
}
|
||||
} else {
|
||||
highlighter_->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
||||
}
|
||||
}
|
||||
return *this;
|
||||
}
|
||||
|
||||
@@ -91,6 +330,15 @@ Buffer::Buffer(Buffer &&other) noexcept
|
||||
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_;
|
||||
filetype_ = std::move(other.filetype_);
|
||||
highlighter_ = std::move(other.highlighter_);
|
||||
content_ = std::move(other.content_);
|
||||
rows_cache_dirty_ = other.rows_cache_dirty_;
|
||||
// Update UndoSystem's buffer reference to point to this object
|
||||
if (undo_sys_) {
|
||||
undo_sys_->UpdateBufferReference(*this);
|
||||
@@ -122,6 +370,15 @@ Buffer::operator=(Buffer &&other) noexcept
|
||||
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_;
|
||||
filetype_ = std::move(other.filetype_);
|
||||
highlighter_ = std::move(other.highlighter_);
|
||||
content_ = std::move(other.content_);
|
||||
rows_cache_dirty_ = other.rows_cache_dirty_;
|
||||
// Update UndoSystem's buffer reference to point to this object
|
||||
if (undo_sys_) {
|
||||
undo_sys_->UpdateBufferReference(*this);
|
||||
@@ -173,59 +430,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;
|
||||
{
|
||||
// Read entire file into PieceTable as-is
|
||||
std::string data;
|
||||
in.seekg(0, std::ios::end);
|
||||
std::streamoff sz = in.tellg();
|
||||
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) {
|
||||
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();
|
||||
data.resize(static_cast<std::size_t>(sz));
|
||||
in.seekg(0, std::ios::beg);
|
||||
if (!in) {
|
||||
err = "Failed to seek to beginning of file: " + norm;
|
||||
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
|
||||
return false;
|
||||
}
|
||||
|
||||
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();
|
||||
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;
|
||||
}
|
||||
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());
|
||||
// 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();
|
||||
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_)
|
||||
@@ -252,24 +516,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;
|
||||
@@ -298,29 +556,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;
|
||||
}
|
||||
|
||||
@@ -333,7 +583,7 @@ Buffer::AsString() const
|
||||
if (this->Dirty()) {
|
||||
ss << "*";
|
||||
}
|
||||
ss << ">: " << rows_.size() << " lines";
|
||||
ss << ">: " << content_.LineCount() << " lines";
|
||||
return ss.str();
|
||||
}
|
||||
|
||||
@@ -344,111 +594,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;
|
||||
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);
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -457,9 +762,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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -468,9 +782,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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
380
Buffer.h
380
Buffer.h
@@ -1,21 +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
|
||||
*/
|
||||
#ifndef KTE_BUFFER_H
|
||||
#define KTE_BUFFER_H
|
||||
#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 "HighlighterEngine.h"
|
||||
#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 {
|
||||
@@ -38,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
|
||||
{
|
||||
@@ -59,7 +126,7 @@ public:
|
||||
|
||||
[[nodiscard]] std::size_t Nrows() const
|
||||
{
|
||||
return nrows_;
|
||||
return content_LineCount_();
|
||||
}
|
||||
|
||||
|
||||
@@ -75,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;
|
||||
@@ -104,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;
|
||||
}
|
||||
|
||||
@@ -230,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_;
|
||||
@@ -372,25 +433,165 @@ 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)
|
||||
[[nodiscard]] std::uint64_t Version() const { return version_; }
|
||||
[[nodiscard]] std::uint64_t Version() const
|
||||
{
|
||||
return version_;
|
||||
}
|
||||
|
||||
void SetSyntaxEnabled(bool on) { syntax_enabled_ = on; }
|
||||
[[nodiscard]] bool SyntaxEnabled() const { return syntax_enabled_; }
|
||||
|
||||
void SetFiletype(const std::string &ft) { filetype_ = ft; }
|
||||
[[nodiscard]] const std::string &Filetype() const { return filetype_; }
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] bool SyntaxEnabled() const
|
||||
{
|
||||
return syntax_enabled_;
|
||||
}
|
||||
|
||||
|
||||
void SetFiletype(const std::string &ft)
|
||||
{
|
||||
filetype_ = ft;
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] const std::string &Filetype() const
|
||||
{
|
||||
return filetype_;
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] kte::HighlighterEngine *Highlighter()
|
||||
{
|
||||
return highlighter_.get();
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] const kte::HighlighterEngine *Highlighter() const
|
||||
{
|
||||
return highlighter_.get();
|
||||
}
|
||||
|
||||
kte::HighlighterEngine *Highlighter() { return highlighter_.get(); }
|
||||
const kte::HighlighterEngine *Highlighter() const { return highlighter_.get(); }
|
||||
|
||||
void EnsureHighlighter()
|
||||
{
|
||||
if (!highlighter_) highlighter_ = std::make_unique<kte::HighlighterEngine>();
|
||||
if (!highlighter_)
|
||||
highlighter_ = std::make_unique<kte::HighlighterEngine>();
|
||||
}
|
||||
|
||||
|
||||
// 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);
|
||||
@@ -405,34 +606,77 @@ 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 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;
|
||||
std::string filetype_;
|
||||
std::unique_ptr<kte::HighlighterEngine> highlighter_;
|
||||
};
|
||||
// Non-owning pointer to swap recorder managed by Editor/SwapManager
|
||||
kte::SwapRecorder *swap_rec_ = nullptr;
|
||||
|
||||
#endif // KTE_BUFFER_H
|
||||
mutable std::mutex buffer_mutex_;
|
||||
};
|
||||
115
CLAUDE.md
Normal file
115
CLAUDE.md
Normal file
@@ -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)
|
||||
400
CMakeLists.txt
400
CMakeLists.txt
@@ -3,16 +3,28 @@ project(kte)
|
||||
|
||||
include(GNUInstallDirs)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(KTE_VERSION "1.1.2")
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(KTE_VERSION "1.11.2")
|
||||
|
||||
# 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" OFF)
|
||||
|
||||
if (ENABLE_ASAN)
|
||||
message(STATUS "ASan enabled")
|
||||
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -fsanitize=address -fno-omit-frame-pointer")
|
||||
# Ensure the sanitizer is linked too (especially important on some platforms)
|
||||
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -fsanitize=address")
|
||||
endif ()
|
||||
|
||||
if (CMAKE_HOST_UNIX)
|
||||
message(STATUS "Build system is POSIX.")
|
||||
@@ -20,6 +32,10 @@ else ()
|
||||
message(STATUS "Build system is NOT POSIX.")
|
||||
endif ()
|
||||
|
||||
add_compile_options(
|
||||
|
||||
)
|
||||
|
||||
if (MSVC)
|
||||
add_compile_options("/W4" "$<$<CONFIG:RELEASE>:/O2>")
|
||||
else ()
|
||||
@@ -27,37 +43,109 @@ 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 ()
|
||||
endif ()
|
||||
add_compile_definitions(KGE_PLATFORM=${CMAKE_HOST_SYSTEM_NAME})
|
||||
add_compile_definitions(KTE_VERSION_STR="v${KTE_VERSION}")
|
||||
if (KTE_ENABLE_TREESITTER)
|
||||
add_compile_definitions(KTE_ENABLE_TREESITTER)
|
||||
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)
|
||||
find_package(Curses REQUIRED)
|
||||
include_directories(${CURSES_INCLUDE_DIR})
|
||||
|
||||
# On Alpine Linux, CMake's FindCurses looks in wrong paths
|
||||
# Manually find the correct ncurses library
|
||||
if (EXISTS "/etc/alpine-release")
|
||||
find_library(NCURSESW_LIB NAMES ncursesw PATHS /usr/lib /lib REQUIRED)
|
||||
set(CURSES_LIBRARIES ${NCURSESW_LIB})
|
||||
message(STATUS "Alpine Linux detected, using ncurses at: ${NCURSESW_LIB}")
|
||||
endif ()
|
||||
|
||||
set(SYNTAX_SOURCES
|
||||
syntax/GoHighlighter.cc
|
||||
syntax/CppHighlighter.cc
|
||||
syntax/JsonHighlighter.cc
|
||||
syntax/ErlangHighlighter.cc
|
||||
syntax/MarkdownHighlighter.cc
|
||||
syntax/TreeSitterHighlighter.cc
|
||||
syntax/LispHighlighter.cc
|
||||
syntax/HighlighterEngine.cc
|
||||
syntax/RustHighlighter.cc
|
||||
syntax/HighlighterRegistry.cc
|
||||
syntax/SqlHighlighter.cc
|
||||
syntax/NullHighlighter.cc
|
||||
syntax/ForthHighlighter.cc
|
||||
syntax/PythonHighlighter.cc
|
||||
syntax/ShellHighlighter.cc
|
||||
)
|
||||
|
||||
if (KTE_ENABLE_TREESITTER)
|
||||
list(APPEND SYNTAX_SOURCES
|
||||
TreeSitterHighlighter.cc)
|
||||
endif ()
|
||||
|
||||
set(FONT_SOURCES
|
||||
fonts/Font.cc
|
||||
fonts/FontRegistry.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
|
||||
@@ -67,28 +155,85 @@ set(COMMON_SOURCES
|
||||
UndoNode.cc
|
||||
UndoTree.cc
|
||||
UndoSystem.cc
|
||||
HighlighterEngine.cc
|
||||
CppHighlighter.cc
|
||||
HighlighterRegistry.cc
|
||||
NullHighlighter.cc
|
||||
JsonHighlighter.cc
|
||||
MarkdownHighlighter.cc
|
||||
ShellHighlighter.cc
|
||||
GoHighlighter.cc
|
||||
PythonHighlighter.cc
|
||||
RustHighlighter.cc
|
||||
LispHighlighter.cc
|
||||
|
||||
${SYNTAX_SOURCES}
|
||||
)
|
||||
|
||||
set(SYNTAX_HEADERS
|
||||
syntax/GoHighlighter.h
|
||||
syntax/HighlighterEngine.h
|
||||
syntax/ShellHighlighter.h
|
||||
syntax/MarkdownHighlighter.h
|
||||
syntax/LispHighlighter.h
|
||||
syntax/SqlHighlighter.h
|
||||
syntax/ForthHighlighter.h
|
||||
syntax/JsonHighlighter.h
|
||||
syntax/TreeSitterHighlighter.h
|
||||
syntax/NullHighlighter.h
|
||||
syntax/CppHighlighter.h
|
||||
syntax/ErlangHighlighter.h
|
||||
syntax/LanguageHighlighter.h
|
||||
syntax/RustHighlighter.h
|
||||
syntax/PythonHighlighter.h
|
||||
)
|
||||
|
||||
if (KTE_ENABLE_TREESITTER)
|
||||
list(APPEND THEME_HEADERS
|
||||
TreeSitterHighlighter.h)
|
||||
endif ()
|
||||
|
||||
set(THEME_HEADERS
|
||||
themes/ThemeHelpers.h
|
||||
themes/EInk.h
|
||||
themes/Gruvbox.h
|
||||
themes/Solarized.h
|
||||
themes/Plan9.h
|
||||
themes/Nord.h
|
||||
themes/Everforest.h
|
||||
themes/KanagawaPaper.h
|
||||
themes/LCARS.h
|
||||
themes/OldBook.h
|
||||
themes/Amber.h
|
||||
themes/Orbital.h
|
||||
themes/WeylandYutani.h
|
||||
themes/Zenburn.h
|
||||
)
|
||||
|
||||
set(FONT_HEADERS
|
||||
fonts/Font.h
|
||||
fonts/FontRegistry.h
|
||||
fonts/FontRegistry.h
|
||||
fonts/FontList.h
|
||||
fonts/B612Mono.h
|
||||
fonts/BrassMono.h
|
||||
fonts/CrimsonPro.h
|
||||
fonts/ETBook.h
|
||||
fonts/BrassMonoCode.h
|
||||
fonts/FiraCode.h
|
||||
fonts/Go.h
|
||||
fonts/IBMPlexMono.h
|
||||
fonts/Idealist.h
|
||||
fonts/Inconsolata.h
|
||||
fonts/InconsolataExpanded.h
|
||||
fonts/Iosevka.h
|
||||
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
|
||||
@@ -102,20 +247,35 @@ set(COMMON_HEADERS
|
||||
UndoTree.h
|
||||
UndoSystem.h
|
||||
Highlight.h
|
||||
LanguageHighlighter.h
|
||||
HighlighterEngine.h
|
||||
CppHighlighter.h
|
||||
HighlighterRegistry.h
|
||||
NullHighlighter.h
|
||||
JsonHighlighter.h
|
||||
MarkdownHighlighter.h
|
||||
ShellHighlighter.h
|
||||
GoHighlighter.h
|
||||
PythonHighlighter.h
|
||||
RustHighlighter.h
|
||||
LispHighlighter.h
|
||||
|
||||
${SYNTAX_HEADERS}
|
||||
)
|
||||
|
||||
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
|
||||
main.cc
|
||||
@@ -123,15 +283,29 @@ 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")
|
||||
set(TREESITTER_LIBRARY "" CACHE FILEPATH "Path to tree-sitter library (.a/.dylib)")
|
||||
if (TREESITTER_INCLUDE_DIR)
|
||||
target_include_directories(kte PRIVATE ${TREESITTER_INCLUDE_DIR})
|
||||
endif ()
|
||||
if (TREESITTER_LIBRARY)
|
||||
target_link_libraries(kte ${TREESITTER_LIBRARY})
|
||||
endif ()
|
||||
endif ()
|
||||
|
||||
install(TARGETS kte
|
||||
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
|
||||
)
|
||||
@@ -140,57 +314,119 @@ 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
|
||||
|
||||
# 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)
|
||||
# Allow test-only introspection hooks (guarded in headers) without affecting production builds.
|
||||
target_compile_definitions(kte_tests PRIVATE KTE_TESTS=1)
|
||||
|
||||
# Allow tests to include project headers like "Buffer.h"
|
||||
target_include_directories(kte_tests PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
|
||||
# Keep tests free of ncurses/GUI deps
|
||||
if (KTE_ENABLE_TREESITTER)
|
||||
if (TREESITTER_INCLUDE_DIR)
|
||||
target_include_directories(kte_tests PRIVATE ${TREESITTER_INCLUDE_DIR})
|
||||
endif ()
|
||||
if (TREESITTER_LIBRARY)
|
||||
target_link_libraries(kte_tests ${TREESITTER_LIBRARY})
|
||||
endif ()
|
||||
endif ()
|
||||
|
||||
if (KTE_UNDO_DEBUG)
|
||||
target_compile_definitions(test_undo PRIVATE KTE_UNDO_DEBUG=1)
|
||||
# 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 ()
|
||||
|
||||
|
||||
target_link_libraries(test_undo ${CURSES_LIBRARIES})
|
||||
endif ()
|
||||
|
||||
if (${BUILD_GUI})
|
||||
target_sources(kte PRIVATE
|
||||
Font.h
|
||||
GUIConfig.cc
|
||||
GUIConfig.h
|
||||
GUIRenderer.cc
|
||||
GUIRenderer.h
|
||||
GUIInputHandler.cc
|
||||
GUIInputHandler.h
|
||||
GUIFrontend.cc
|
||||
GUIFrontend.h)
|
||||
target_compile_definitions(kte PRIVATE KTE_BUILD_GUI=1)
|
||||
target_link_libraries(kte imgui)
|
||||
if (BUILD_GUI)
|
||||
# ImGui::CreateContext();
|
||||
# ImGuiIO& io = ImGui::GetIO();
|
||||
|
||||
# // Set custom ini filename path to ~/.config/kte/imgui.ini
|
||||
# if (const char* home = std::getenv("HOME")) {
|
||||
# static std::string ini_path = std::string(home) + "/.config/kte/imgui.ini";
|
||||
# io.IniFilename = ini_path.c_str();
|
||||
# }
|
||||
|
||||
# io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; // Enable Keyboard Controls
|
||||
# io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad; // Enable Gamepad Controls
|
||||
# Do not enable GUI in the terminal-first 'kte' binary; GUI is built as separate 'kge'.
|
||||
# This avoids referencing GUI classes from kte and keeps dependencies minimal.
|
||||
|
||||
# kge (GUI-first) executable
|
||||
add_executable(kge
|
||||
main.cc
|
||||
${COMMON_SOURCES}
|
||||
${GUI_SOURCES}
|
||||
${COMMON_HEADERS}
|
||||
GUIConfig.cc
|
||||
GUIConfig.h
|
||||
GUIRenderer.cc
|
||||
GUIRenderer.h
|
||||
GUIInputHandler.cc
|
||||
GUIInputHandler.h
|
||||
GUIFrontend.cc
|
||||
GUIFrontend.h)
|
||||
${GUI_HEADERS}
|
||||
|
||||
)
|
||||
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 ()
|
||||
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)
|
||||
@@ -210,12 +446,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
|
||||
@@ -239,4 +481,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 ()
|
||||
|
||||
116
CONFIG.md
Normal file
116
CONFIG.md
Normal file
@@ -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).
|
||||
2009
Command.cc
2009
Command.cc
File diff suppressed because it is too large
Load Diff
30
Command.h
30
Command.h
@@ -1,8 +1,7 @@
|
||||
/*
|
||||
* Command.h - command model and registry for editor actions
|
||||
*/
|
||||
#ifndef KTE_COMMAND_H
|
||||
#define KTE_COMMAND_H
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <string>
|
||||
@@ -28,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
|
||||
@@ -45,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)
|
||||
@@ -58,6 +62,8 @@ enum class CommandId {
|
||||
MoveEnd,
|
||||
PageUp,
|
||||
PageDown,
|
||||
ScrollUp, // scroll viewport up (towards beginning) without moving cursor
|
||||
ScrollDown, // scroll viewport down (towards end) without moving cursor
|
||||
WordPrev,
|
||||
WordNext,
|
||||
DeleteWordPrev, // delete previous word (ESC BACKSPACE)
|
||||
@@ -89,15 +95,30 @@ 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
|
||||
ThemeSetByName,
|
||||
// Font by name (GUI)
|
||||
FontSetByName,
|
||||
// Font size (GUI)
|
||||
FontSetSize,
|
||||
// Background mode (GUI)
|
||||
BackgroundSet,
|
||||
// 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,
|
||||
};
|
||||
|
||||
|
||||
@@ -123,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;
|
||||
};
|
||||
|
||||
|
||||
@@ -150,5 +174,3 @@ void InstallDefaultCommands();
|
||||
bool Execute(Editor &ed, CommandId id, const std::string &arg = std::string(), int count = 0);
|
||||
|
||||
bool Execute(Editor &ed, const std::string &name, const std::string &arg = std::string(), int count = 0);
|
||||
|
||||
#endif // KTE_COMMAND_H
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
#include "CppHighlighter.h"
|
||||
#include "Buffer.h"
|
||||
#include <cctype>
|
||||
|
||||
namespace kte {
|
||||
|
||||
static bool is_digit(char c) { return c >= '0' && c <= '9'; }
|
||||
|
||||
CppHighlighter::CppHighlighter()
|
||||
{
|
||||
const char *kw[] = {
|
||||
"if","else","for","while","do","switch","case","default","break","continue",
|
||||
"return","goto","struct","class","namespace","using","template","typename",
|
||||
"public","private","protected","virtual","override","const","constexpr","auto",
|
||||
"static","inline","operator","new","delete","try","catch","throw","friend",
|
||||
"enum","union","extern","volatile","mutable","noexcept","sizeof","this"
|
||||
};
|
||||
for (auto s: kw) keywords_.insert(s);
|
||||
const char *types[] = {
|
||||
"int","long","short","char","signed","unsigned","float","double","void",
|
||||
"bool","wchar_t","size_t","ptrdiff_t","uint8_t","uint16_t","uint32_t","uint64_t",
|
||||
"int8_t","int16_t","int32_t","int64_t"
|
||||
};
|
||||
for (auto s: types) types_.insert(s);
|
||||
}
|
||||
|
||||
bool CppHighlighter::is_ident_start(char c) { return std::isalpha(static_cast<unsigned char>(c)) || c == '_'; }
|
||||
bool CppHighlighter::is_ident_char(char c) { return std::isalnum(static_cast<unsigned char>(c)) || c == '_'; }
|
||||
|
||||
void CppHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
// Stateless entry simply delegates to stateful with a clean previous state
|
||||
StatefulHighlighter::LineState prev;
|
||||
(void)HighlightLineStateful(buf, row, prev, out);
|
||||
}
|
||||
|
||||
StatefulHighlighter::LineState CppHighlighter::HighlightLineStateful(const Buffer &buf,
|
||||
int row,
|
||||
const LineState &prev,
|
||||
std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
const auto &rows = buf.Rows();
|
||||
StatefulHighlighter::LineState state = prev;
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size()) return state;
|
||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
||||
if (s.empty()) return state;
|
||||
|
||||
auto push = [&](int a, int b, TokenKind k){ if (b> a) out.push_back({a,b,k}); };
|
||||
int n = static_cast<int>(s.size());
|
||||
int bol = 0; while (bol < n && (s[bol] == ' ' || s[bol] == '\t')) ++bol;
|
||||
int i = 0;
|
||||
|
||||
// Continue multi-line raw string from previous line
|
||||
if (state.in_raw_string) {
|
||||
std::string needle = ")" + state.raw_delim + "\"";
|
||||
auto pos = s.find(needle);
|
||||
if (pos == std::string::npos) {
|
||||
push(0, n, TokenKind::String);
|
||||
state.in_raw_string = true;
|
||||
return state;
|
||||
} else {
|
||||
int end = static_cast<int>(pos + needle.size());
|
||||
push(0, end, TokenKind::String);
|
||||
i = end;
|
||||
state.in_raw_string = false;
|
||||
state.raw_delim.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Continue multi-line block comment from previous line
|
||||
if (state.in_block_comment) {
|
||||
int j = i;
|
||||
while (i + 1 < n) {
|
||||
if (s[i] == '*' && s[i+1] == '/') { i += 2; push(j, i, TokenKind::Comment); state.in_block_comment = false; break; }
|
||||
++i;
|
||||
}
|
||||
if (state.in_block_comment) { push(j, n, TokenKind::Comment); return state; }
|
||||
}
|
||||
|
||||
while (i < n) {
|
||||
char c = s[i];
|
||||
// Preprocessor at beginning of line (after leading whitespace)
|
||||
if (i == bol && c == '#') { push(0, n, TokenKind::Preproc); break; }
|
||||
|
||||
// Whitespace
|
||||
if (c == ' ' || c == '\t') {
|
||||
int j = i+1; while (j < n && (s[j] == ' ' || s[j] == '\t')) ++j; push(i,j,TokenKind::Whitespace); i=j; continue;
|
||||
}
|
||||
|
||||
// Line comment
|
||||
if (c == '/' && i+1 < n && s[i+1] == '/') { push(i, n, TokenKind::Comment); break; }
|
||||
|
||||
// Block comment
|
||||
if (c == '/' && i+1 < n && s[i+1] == '*') {
|
||||
int j = i+2;
|
||||
bool closed = false;
|
||||
while (j + 1 <= n) {
|
||||
if (j + 1 < n && s[j] == '*' && s[j+1] == '/') { j += 2; closed = true; break; }
|
||||
++j;
|
||||
}
|
||||
if (closed) { push(i, j, TokenKind::Comment); i = j; continue; }
|
||||
// Spill to next lines
|
||||
push(i, n, TokenKind::Comment);
|
||||
state.in_block_comment = true;
|
||||
return state;
|
||||
}
|
||||
|
||||
// Raw string start: very simple detection: R"delim(
|
||||
if (c == 'R' && i+1 < n && s[i+1] == '"') {
|
||||
int k = i + 2;
|
||||
std::string delim;
|
||||
while (k < n && s[k] != '(') { delim.push_back(s[k]); ++k; }
|
||||
if (k < n && s[k] == '(') {
|
||||
int body_start = k + 1;
|
||||
std::string needle = ")" + delim + "\"";
|
||||
auto pos = s.find(needle, static_cast<std::size_t>(body_start));
|
||||
if (pos == std::string::npos) {
|
||||
push(i, n, TokenKind::String);
|
||||
state.in_raw_string = true;
|
||||
state.raw_delim = delim;
|
||||
return state;
|
||||
} else {
|
||||
int end = static_cast<int>(pos + needle.size());
|
||||
push(i, end, TokenKind::String);
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// If malformed, just treat 'R' as identifier fallback
|
||||
}
|
||||
|
||||
// Regular string literal
|
||||
if (c == '"') {
|
||||
int j = i+1; bool esc=false; while (j < n) { char d = s[j++]; if (esc) { esc=false; continue; } if (d == '\\') { esc=true; continue; } if (d == '"') break; }
|
||||
push(i, j, TokenKind::String); i = j; continue;
|
||||
}
|
||||
|
||||
// Char literal
|
||||
if (c == '\'') {
|
||||
int j = i+1; bool esc=false; while (j < n) { char d = s[j++]; if (esc) { esc=false; continue; } if (d == '\\') { esc=true; continue; } if (d == '\'') break; }
|
||||
push(i, j, TokenKind::Char); i = j; continue;
|
||||
}
|
||||
|
||||
// Number literal (simple)
|
||||
if (is_digit(c) || (c == '.' && i+1 < n && is_digit(s[i+1]))) {
|
||||
int j = i+1; while (j < n && (std::isalnum(static_cast<unsigned char>(s[j])) || s[j]=='.' || s[j]=='x' || s[j]=='X' || s[j]=='b' || s[j]=='B' || s[j]=='_')) ++j;
|
||||
push(i, j, TokenKind::Number); i = j; continue;
|
||||
}
|
||||
|
||||
// Identifier / keyword / type
|
||||
if (is_ident_start(c)) {
|
||||
int j = i+1; while (j < n && is_ident_char(s[j])) ++j; std::string id = s.substr(i, j-i);
|
||||
TokenKind k = TokenKind::Identifier; if (keywords_.count(id)) k = TokenKind::Keyword; else if (types_.count(id)) k = TokenKind::Type; push(i, j, k); i = j; continue;
|
||||
}
|
||||
|
||||
// Operators and punctuation (single char for now)
|
||||
TokenKind kind = TokenKind::Operator;
|
||||
if (std::ispunct(static_cast<unsigned char>(c)) && c != '_' && c != '#') {
|
||||
if (c==';' || c==',' || c=='(' || c==')' || c=='{' || c=='}' || c=='[' || c==']') kind = TokenKind::Punctuation;
|
||||
push(i, i+1, kind); ++i; continue;
|
||||
}
|
||||
|
||||
// Fallback
|
||||
push(i, i+1, TokenKind::Default); ++i;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
} // namespace kte
|
||||
@@ -1,34 +0,0 @@
|
||||
// CppHighlighter.h - minimal stateless C/C++ line highlighter
|
||||
#pragma once
|
||||
|
||||
#include <regex>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
#include "LanguageHighlighter.h"
|
||||
|
||||
class Buffer;
|
||||
|
||||
namespace kte {
|
||||
|
||||
class CppHighlighter final : public StatefulHighlighter {
|
||||
public:
|
||||
CppHighlighter();
|
||||
~CppHighlighter() override = default;
|
||||
|
||||
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
|
||||
LineState HighlightLineStateful(const Buffer &buf,
|
||||
int row,
|
||||
const LineState &prev,
|
||||
std::vector<HighlightSpan> &out) const override;
|
||||
|
||||
private:
|
||||
std::unordered_set<std::string> keywords_;
|
||||
std::unordered_set<std::string> types_;
|
||||
|
||||
static bool is_ident_start(char c);
|
||||
static bool is_ident_char(char c);
|
||||
};
|
||||
|
||||
} // namespace kte
|
||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@@ -0,0 +1,23 @@
|
||||
# Minimal Dockerfile for building and testing kte on Linux
|
||||
# This container provides a build environment with all dependencies.
|
||||
# Mount the source tree at /kte when running the container.
|
||||
FROM alpine:3.19
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache \
|
||||
g++ \
|
||||
cmake \
|
||||
make \
|
||||
ncurses-dev \
|
||||
sdl2-dev \
|
||||
mesa-dev \
|
||||
freetype-dev \
|
||||
libx11-dev \
|
||||
libxext-dev
|
||||
|
||||
# Set working directory where source will be mounted
|
||||
WORKDIR /kte
|
||||
|
||||
# Default command: build and run tests
|
||||
# Add DirectFB include path for SDL2 compatibility on Alpine
|
||||
CMD ["sh", "-c", "cmake -B build -DBUILD_GUI=ON -DBUILD_TESTS=ON -DCMAKE_CXX_FLAGS='-I/usr/include/directfb' && cmake --build build --target kte && cmake --build build --target kge && cmake --build build --target kte_tests && ./build/kte_tests"]
|
||||
404
Editor.cc
404
Editor.cc
@@ -1,14 +1,53 @@
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <utility>
|
||||
|
||||
#include "Editor.h"
|
||||
#include "HighlighterRegistry.h"
|
||||
#include "CppHighlighter.h"
|
||||
#include "NullHighlighter.h"
|
||||
#include "syntax/HighlighterRegistry.h"
|
||||
#include "syntax/CppHighlighter.h"
|
||||
#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,45 +164,64 @@ 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();
|
||||
bufs.push_back(buf);
|
||||
// Attach swap recorder
|
||||
if (swap_) {
|
||||
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();
|
||||
bufs.push_back(std::move(buf));
|
||||
if (swap_) {
|
||||
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;
|
||||
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);
|
||||
@@ -177,6 +238,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;
|
||||
}
|
||||
}
|
||||
@@ -185,13 +248,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);
|
||||
@@ -210,18 +273,211 @@ 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 = bufs[curbuf_];
|
||||
if (b.SyntaxEnabled()) {
|
||||
b.EnsureHighlighter();
|
||||
if (auto *eng = b.Highlighter()) {
|
||||
if (!eng->HasHighlighter()) {
|
||||
// Try to set based on existing filetype; fall back to NullHighlighter
|
||||
if (!b.Filetype().empty()) {
|
||||
auto hl = kte::HighlighterRegistry::CreateFor(b.Filetype());
|
||||
if (hl) {
|
||||
eng->SetHighlighter(std::move(hl));
|
||||
} else {
|
||||
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
||||
}
|
||||
} else {
|
||||
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
||||
}
|
||||
eng->InvalidateFrom(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -229,14 +485,22 @@ 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);
|
||||
}
|
||||
bufs.erase(bufs.begin() + static_cast<std::ptrdiff_t>(index));
|
||||
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;
|
||||
}
|
||||
@@ -254,8 +518,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;
|
||||
}
|
||||
198
Editor.h
198
Editor.h
@@ -1,15 +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.
|
||||
*/
|
||||
#ifndef KTE_EDITOR_H
|
||||
#define KTE_EDITOR_H
|
||||
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
#include <ctime>
|
||||
#include <deque>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "Buffer.h"
|
||||
#include "Swap.h"
|
||||
|
||||
|
||||
class Editor {
|
||||
@@ -32,6 +69,16 @@ public:
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] std::size_t ContentRows() const
|
||||
{
|
||||
// Always compute from current rows_ to avoid stale values.
|
||||
// Reserve 1 row for status line.
|
||||
if (rows_ == 0)
|
||||
return 1;
|
||||
return std::max<std::size_t>(1, rows_ - 1);
|
||||
}
|
||||
|
||||
|
||||
// Mode and flags (mirroring legacy fields)
|
||||
void SetMode(int m)
|
||||
{
|
||||
@@ -148,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);
|
||||
@@ -172,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;
|
||||
@@ -184,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_;
|
||||
@@ -410,7 +521,7 @@ public:
|
||||
// Buffers
|
||||
[[nodiscard]] std::size_t BufferCount() const
|
||||
{
|
||||
return buffers_.size();
|
||||
return Buffers().size();
|
||||
}
|
||||
|
||||
|
||||
@@ -420,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;
|
||||
@@ -436,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);
|
||||
|
||||
@@ -447,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();
|
||||
}
|
||||
|
||||
|
||||
@@ -482,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
|
||||
@@ -490,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::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 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;
|
||||
@@ -519,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_;
|
||||
@@ -552,5 +736,3 @@ private:
|
||||
std::string replace_find_tmp_;
|
||||
std::string replace_with_tmp_;
|
||||
};
|
||||
|
||||
#endif // KTE_EDITOR_H
|
||||
|
||||
313
ErrorHandler.cc
Normal file
313
ErrorHandler.cc
Normal file
@@ -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
ErrorHandler.h
Normal file
106
ErrorHandler.h
Normal file
@@ -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
|
||||
157
ErrorRecovery.cc
Normal file
157
ErrorRecovery.cc
Normal file
@@ -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
ErrorRecovery.h
Normal file
170
ErrorRecovery.h
Normal file
@@ -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
|
||||
@@ -1,9 +1,7 @@
|
||||
/*
|
||||
* Frontend.h - top-level container that couples Input + Renderer and runs the loop
|
||||
*/
|
||||
#ifndef KTE_FRONTEND_H
|
||||
#define KTE_FRONTEND_H
|
||||
|
||||
#pragma once
|
||||
|
||||
class Editor;
|
||||
class InputHandler;
|
||||
@@ -14,7 +12,7 @@ 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;
|
||||
@@ -22,5 +20,3 @@ public:
|
||||
// Shutdown/cleanup
|
||||
virtual void Shutdown() = 0;
|
||||
};
|
||||
|
||||
#endif // KTE_FRONTEND_H
|
||||
|
||||
141
GUIConfig.cc
141
GUIConfig.cc
@@ -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 ';'
|
||||
@@ -102,6 +209,14 @@ GUIConfig::LoadFromFile(const std::string &path)
|
||||
if (v > 0.0f) {
|
||||
font_size = v;
|
||||
}
|
||||
} 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") {
|
||||
@@ -124,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;
|
||||
}
|
||||
|
||||
26
GUIConfig.h
26
GUIConfig.h
@@ -1,8 +1,9 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
#ifndef KTE_GUI_CONFIG_H
|
||||
#define KTE_GUI_CONFIG_H
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
@@ -16,20 +17,25 @@ public:
|
||||
int columns = 80;
|
||||
int rows = 42;
|
||||
float font_size = (float) KTE_FONT_SIZE;
|
||||
std::string font = "default";
|
||||
std::string theme = "nord";
|
||||
// Background mode for themes that support light/dark variants
|
||||
// Values: "dark" (default), "light"
|
||||
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);
|
||||
|
||||
#endif // KTE_GUI_CONFIG_H
|
||||
// Load from explicit INI path (legacy). Returns true if file existed and was parsed.
|
||||
bool LoadFromINI(const std::string &path);
|
||||
};
|
||||
|
||||
319
GUIFrontend.cc
319
GUIFrontend.cc
@@ -1,319 +0,0 @@
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <cstring>
|
||||
#include <cstdlib>
|
||||
#include <algorithm>
|
||||
|
||||
#include <SDL.h>
|
||||
#include <SDL_opengl.h>
|
||||
#include <imgui.h>
|
||||
#include <backends/imgui_impl_sdl2.h>
|
||||
#include <backends/imgui_impl_opengl3.h>
|
||||
|
||||
#include "Editor.h"
|
||||
#include "Command.h"
|
||||
#include "GUIFrontend.h"
|
||||
#include "Font.h" // embedded default font (DefaultFontRegular)
|
||||
#include "GUIConfig.h"
|
||||
#include "GUITheme.h"
|
||||
#include "HighlighterRegistry.h"
|
||||
#include "NullHighlighter.h"
|
||||
|
||||
|
||||
#ifndef KTE_FONT_SIZE
|
||||
#define KTE_FONT_SIZE 16.0f
|
||||
#endif
|
||||
|
||||
static const char *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);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
#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();
|
||||
(void) io;
|
||||
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
|
||||
|
||||
// Initialize GUI font from embedded default (use configured size or compiled default)
|
||||
LoadGuiFont_(nullptr, (float) cfg.font_size);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Execute pending mapped inputs (drain queue)
|
||||
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;
|
||||
}
|
||||
|
||||
// Start a new ImGui frame
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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*/, float size_px)
|
||||
{
|
||||
const ImGuiIO &io = ImGui::GetIO();
|
||||
io.Fonts->Clear();
|
||||
const ImFont *font = io.Fonts->AddFontFromMemoryCompressedTTF(
|
||||
DefaultFontBoldCompressedData,
|
||||
DefaultFontBoldCompressedSize,
|
||||
size_px);
|
||||
if (!font) {
|
||||
font = io.Fonts->AddFontDefault();
|
||||
}
|
||||
(void) font;
|
||||
io.Fonts->Build();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// No runtime font reload or system font resolution in this simplified build.
|
||||
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* GUIFrontend - couples GUIInputHandler + GUIRenderer and owns SDL2/ImGui lifecycle
|
||||
*/
|
||||
#ifndef KTE_GUI_FRONTEND_H
|
||||
#define KTE_GUI_FRONTEND_H
|
||||
|
||||
#include "Frontend.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);
|
||||
|
||||
GUIInputHandler input_{};
|
||||
GUIRenderer renderer_{};
|
||||
SDL_Window *window_ = nullptr;
|
||||
SDL_GLContext gl_ctx_ = nullptr;
|
||||
int width_ = 1280;
|
||||
int height_ = 800;
|
||||
};
|
||||
|
||||
#endif // KTE_GUI_FRONTEND_H
|
||||
758
GUIRenderer.cc
758
GUIRenderer.cc
@@ -1,758 +0,0 @@
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <limits>
|
||||
#include <string>
|
||||
|
||||
#include <imgui.h>
|
||||
#include <regex>
|
||||
|
||||
#include "GUIRenderer.h"
|
||||
#include "Highlight.h"
|
||||
#include "GUITheme.h"
|
||||
#include "Buffer.h"
|
||||
#include "Command.h"
|
||||
#include "Editor.h"
|
||||
|
||||
|
||||
// Version string expected to be provided by build system as KTE_VERSION_STR
|
||||
#ifndef KTE_VERSION_STR
|
||||
# define KTE_VERSION_STR "dev"
|
||||
#endif
|
||||
|
||||
// ImGui compatibility: some bundled ImGui versions (or builds without docking)
|
||||
// don't define ImGuiWindowFlags_NoDocking. Treat it as 0 in that case.
|
||||
#ifndef ImGuiWindowFlags_NoDocking
|
||||
# define ImGuiWindowFlags_NoDocking 0
|
||||
#endif
|
||||
|
||||
|
||||
void
|
||||
GUIRenderer::Draw(Editor &ed)
|
||||
{
|
||||
// Make the editor window occupy the entire GUI container/viewport
|
||||
ImGuiViewport *vp = ImGui::GetMainViewport();
|
||||
// On HiDPI/Retina, snap to integer pixels to prevent any draw vs hit-test
|
||||
// mismatches that can appear on the very first maximized frame.
|
||||
ImVec2 main_pos = vp->Pos;
|
||||
ImVec2 main_sz = vp->Size;
|
||||
main_pos.x = std::floor(main_pos.x + 0.5f);
|
||||
main_pos.y = std::floor(main_pos.y + 0.5f);
|
||||
main_sz.x = std::floor(main_sz.x + 0.5f);
|
||||
main_sz.y = std::floor(main_sz.y + 0.5f);
|
||||
ImGui::SetNextWindowPos(main_pos);
|
||||
ImGui::SetNextWindowSize(main_sz);
|
||||
|
||||
ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar
|
||||
| ImGuiWindowFlags_NoScrollbar
|
||||
| ImGuiWindowFlags_NoScrollWithMouse
|
||||
| ImGuiWindowFlags_NoResize
|
||||
| ImGuiWindowFlags_NoMove
|
||||
| ImGuiWindowFlags_NoCollapse
|
||||
| ImGuiWindowFlags_NoSavedSettings
|
||||
| ImGuiWindowFlags_NoBringToFrontOnFocus
|
||||
| ImGuiWindowFlags_NoNavFocus;
|
||||
|
||||
// Reduce padding so the buffer content uses the whole area
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.f, 6.f));
|
||||
|
||||
ImGui::Begin("kte", nullptr, flags);
|
||||
|
||||
const Buffer *buf = ed.CurrentBuffer();
|
||||
if (!buf) {
|
||||
ImGui::TextUnformatted("[no buffer]");
|
||||
} else {
|
||||
const auto &lines = buf->Rows();
|
||||
// Reserve space for status bar at bottom
|
||||
ImGui::BeginChild("scroll", ImVec2(0, -ImGui::GetFrameHeightWithSpacing()), false,
|
||||
ImGuiWindowFlags_HorizontalScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
||||
// Detect click-to-move inside this scroll region
|
||||
ImVec2 list_origin = ImGui::GetCursorScreenPos();
|
||||
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
|
||||
std::size_t cy = buf->Cury();
|
||||
std::size_t cx = buf->Curx();
|
||||
const float line_h = ImGui::GetTextLineHeight();
|
||||
const float row_h = ImGui::GetTextLineHeightWithSpacing();
|
||||
const float space_w = ImGui::CalcTextSize(" ").x;
|
||||
// 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.
|
||||
// This prevents clicks/wheel from being immediately overridden by stale offsets.
|
||||
bool forced_scroll = false;
|
||||
{
|
||||
static long prev_buf_rowoffs = -1; // previous frame's Buffer::Rowoffs
|
||||
static long prev_buf_coloffs = -1; // previous frame's Buffer::Coloffs
|
||||
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 buf_rowoffs = static_cast<long>(buf->Rowoffs());
|
||||
const long buf_coloffs = static_cast<long>(buf->Coloffs());
|
||||
const long scroll_top = static_cast<long>(scroll_y / row_h);
|
||||
const long scroll_left = static_cast<long>(scroll_x / space_w);
|
||||
|
||||
// Detect programmatic change (e.g., keyboard navigation ensured visibility)
|
||||
if (prev_buf_rowoffs >= 0 && buf_rowoffs != prev_buf_rowoffs) {
|
||||
ImGui::SetScrollY(static_cast<float>(buf_rowoffs) * row_h);
|
||||
scroll_y = ImGui::GetScrollY();
|
||||
forced_scroll = true;
|
||||
}
|
||||
if (prev_buf_coloffs >= 0 && buf_coloffs != prev_buf_coloffs) {
|
||||
ImGui::SetScrollX(static_cast<float>(buf_coloffs) * space_w);
|
||||
scroll_x = ImGui::GetScrollX();
|
||||
forced_scroll = true;
|
||||
}
|
||||
// If user scrolled, update buffer offsets accordingly
|
||||
if (prev_scroll_y >= 0.0f && scroll_y != prev_scroll_y) {
|
||||
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) {
|
||||
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
|
||||
mbuf->SetOffsets(mbuf->Rowoffs(),
|
||||
static_cast<std::size_t>(std::max(0L, scroll_left)));
|
||||
}
|
||||
}
|
||||
|
||||
// Update trackers for next frame
|
||||
prev_buf_rowoffs = static_cast<long>(buf->Rowoffs());
|
||||
prev_buf_coloffs = static_cast<long>(buf->Coloffs());
|
||||
prev_scroll_y = ImGui::GetScrollY();
|
||||
prev_scroll_x = ImGui::GetScrollX();
|
||||
}
|
||||
// Synchronize cursor and scrolling.
|
||||
// Ensure the cursor is visible even on the first frame or when it didn't move,
|
||||
// unless we already forced scrolling from Buffer::Rowoffs this frame.
|
||||
{
|
||||
// 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;
|
||||
|
||||
if (!forced_scroll) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle mouse click before rendering to avoid dependent on drawn items
|
||||
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
||||
ImVec2 mp = ImGui::GetIO().MousePos;
|
||||
// Compute viewport-relative row so (0) is top row of the visible area
|
||||
float vy_f = (mp.y - list_origin.y - scroll_y) / row_h;
|
||||
long vy = static_cast<long>(vy_f);
|
||||
if (vy < 0)
|
||||
vy = 0;
|
||||
|
||||
// Clamp vy within visible content height to avoid huge jumps
|
||||
ImVec2 cr_min = ImGui::GetWindowContentRegionMin();
|
||||
ImVec2 cr_max = ImGui::GetWindowContentRegionMax();
|
||||
float child_h = (cr_max.y - cr_min.y);
|
||||
long vis_rows = static_cast<long>(child_h / row_h);
|
||||
if (vis_rows < 1)
|
||||
vis_rows = 1;
|
||||
if (vy >= vis_rows)
|
||||
vy = vis_rows - 1;
|
||||
|
||||
// Translate viewport row to buffer row using Buffer::Rowoffs
|
||||
std::size_t by = buf->Rowoffs() + static_cast<std::size_t>(vy);
|
||||
if (by >= lines.size()) {
|
||||
if (!lines.empty())
|
||||
by = lines.size() - 1;
|
||||
else
|
||||
by = 0;
|
||||
}
|
||||
|
||||
// Compute desired pixel X inside the viewport content (subtract horizontal scroll)
|
||||
float px = (mp.x - list_origin.x - scroll_x);
|
||||
if (px < 0.0f)
|
||||
px = 0.0f;
|
||||
|
||||
// Empty buffer guard: if there are no lines yet, just move to 0:0
|
||||
if (lines.empty()) {
|
||||
Execute(ed, CommandId::MoveCursorTo, std::string("0:0"));
|
||||
} else {
|
||||
// Convert pixel X to a render-column target including horizontal col offset
|
||||
// Use our own tab expansion of width 8 to match command layer logic.
|
||||
std::string line_clicked = static_cast<std::string>(lines[by]);
|
||||
const std::size_t tabw = 8;
|
||||
// We iterate source columns computing absolute rendered column (rx_abs) from 0,
|
||||
// then translate to viewport-space by subtracting Coloffs.
|
||||
std::size_t coloffs = buf->Coloffs();
|
||||
std::size_t rx_abs = 0; // absolute rendered column
|
||||
std::size_t i = 0; // source column iterator
|
||||
|
||||
// Fast-forward i until rx_abs >= coloffs to align with leftmost visible column
|
||||
if (!line_clicked.empty() && coloffs > 0) {
|
||||
while (i < line_clicked.size() && rx_abs < coloffs) {
|
||||
if (line_clicked[i] == '\t') {
|
||||
rx_abs += (tabw - (rx_abs % tabw));
|
||||
} else {
|
||||
rx_abs += 1;
|
||||
}
|
||||
++i;
|
||||
}
|
||||
}
|
||||
|
||||
// Now search for closest source column to clicked px within/after viewport
|
||||
std::size_t best_col = i; // default to first visible column
|
||||
float best_dist = std::numeric_limits<float>::infinity();
|
||||
while (true) {
|
||||
// For i in [current..size], evaluate candidate including the implicit end position
|
||||
std::size_t rx_view = (rx_abs >= coloffs) ? (rx_abs - coloffs) : 0;
|
||||
float rx_px = static_cast<float>(rx_view) * space_w;
|
||||
float dist = std::fabs(px - rx_px);
|
||||
if (dist <= best_dist) {
|
||||
best_dist = dist;
|
||||
best_col = i;
|
||||
}
|
||||
if (i == line_clicked.size())
|
||||
break;
|
||||
// advance to next source column
|
||||
if (line_clicked[i] == '\t') {
|
||||
rx_abs += (tabw - (rx_abs % tabw));
|
||||
} else {
|
||||
rx_abs += 1;
|
||||
}
|
||||
++i;
|
||||
}
|
||||
|
||||
// Dispatch absolute buffer coordinates (row:col)
|
||||
char tmp[64];
|
||||
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, best_col);
|
||||
Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
|
||||
}
|
||||
}
|
||||
// Cache current horizontal offset in rendered columns
|
||||
const std::size_t coloffs_now = buf->Coloffs();
|
||||
for (std::size_t i = rowoffs; i < lines.size(); ++i) {
|
||||
// Capture the screen position before drawing the line
|
||||
ImVec2 line_pos = ImGui::GetCursorScreenPos();
|
||||
std::string line = static_cast<std::string>(lines[i]);
|
||||
|
||||
// Expand tabs to spaces with width=8 and apply horizontal scroll offset
|
||||
const std::size_t tabw = 8;
|
||||
std::string expanded;
|
||||
expanded.reserve(line.size() + 16);
|
||||
std::size_t rx_abs_draw = 0; // rendered column for drawing
|
||||
// Compute search highlight ranges for this line in source indices
|
||||
bool search_mode = ed.SearchActive() && !ed.SearchQuery().empty();
|
||||
std::vector<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);
|
||||
}
|
||||
} catch (const std::regex_error &) {
|
||||
// ignore invalid patterns here; status line already shows the error
|
||||
}
|
||||
} else {
|
||||
const std::string &q = ed.SearchQuery();
|
||||
std::size_t pos = 0;
|
||||
while (!q.empty() && (pos = line.find(q, pos)) != std::string::npos) {
|
||||
hl_src_ranges.emplace_back(pos, pos + q.size());
|
||||
pos += q.size();
|
||||
}
|
||||
}
|
||||
}
|
||||
auto src_to_rx = [&](std::size_t upto_src_exclusive) -> std::size_t {
|
||||
std::size_t rx = 0;
|
||||
std::size_t s = 0;
|
||||
while (s < upto_src_exclusive && s < line.size()) {
|
||||
if (line[s] == '\t')
|
||||
rx += (tabw - (rx % tabw));
|
||||
else
|
||||
rx += 1;
|
||||
++s;
|
||||
}
|
||||
return rx;
|
||||
};
|
||||
// Draw background highlights (under text)
|
||||
if (search_mode && !hl_src_ranges.empty()) {
|
||||
// Current match emphasis
|
||||
bool has_current = ed.SearchMatchLen() > 0 && ed.SearchMatchY() == i;
|
||||
std::size_t cur_x = has_current ? ed.SearchMatchX() : 0;
|
||||
std::size_t cur_end = has_current ? (ed.SearchMatchX() + ed.SearchMatchLen()) : 0;
|
||||
for (const auto &rg: hl_src_ranges) {
|
||||
std::size_t sx = rg.first, ex = rg.second;
|
||||
std::size_t rx_start = src_to_rx(sx);
|
||||
std::size_t rx_end = src_to_rx(ex);
|
||||
// Apply horizontal scroll offset
|
||||
if (rx_end <= coloffs_now)
|
||||
continue; // fully left of view
|
||||
std::size_t vx0 = (rx_start > coloffs_now) ? (rx_start - coloffs_now) : 0;
|
||||
std::size_t vx1 = rx_end - coloffs_now;
|
||||
ImVec2 p0 = ImVec2(line_pos.x + static_cast<float>(vx0) * space_w, line_pos.y);
|
||||
ImVec2 p1 = ImVec2(line_pos.x + static_cast<float>(vx1) * space_w,
|
||||
line_pos.y + line_h);
|
||||
// Choose color: current match stronger
|
||||
bool is_current = has_current && sx == cur_x && ex == cur_end;
|
||||
ImU32 col = is_current
|
||||
? IM_COL32(255, 220, 120, 140)
|
||||
: IM_COL32(200, 200, 0, 90);
|
||||
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);
|
||||
}
|
||||
}
|
||||
// Emit entire line 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;
|
||||
} else {
|
||||
expanded.push_back(c);
|
||||
rx_abs_draw += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Draw syntax-colored runs (text above background highlights)
|
||||
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
|
||||
const kte::LineHighlight &lh = buf->Highlighter()->GetLine(*buf, static_cast<int>(i), buf->Version());
|
||||
// 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;
|
||||
for (std::size_t k = 0; k < sidx && k < line.size(); ++k) {
|
||||
rx += (line[k] == '\t') ? (tabw - (rx % tabw)) : 1;
|
||||
}
|
||||
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;
|
||||
std::size_t vx0 = (rx_s > coloffs_now) ? (rx_s - coloffs_now) : 0;
|
||||
std::size_t vx1 = (rx_e > coloffs_now) ? (rx_e - coloffs_now) : 0;
|
||||
if (vx0 >= expanded.size()) continue;
|
||||
vx1 = std::min<std::size_t>(vx1, expanded.size());
|
||||
if (vx1 <= vx0) continue;
|
||||
ImU32 col = ImGui::GetColorU32(kte::SyntaxInk(sp.kind));
|
||||
ImVec2 p = ImVec2(line_pos.x + static_cast<float>(vx0) * space_w, line_pos.y);
|
||||
ImGui::GetWindowDrawList()->AddText(p, col, expanded.c_str() + vx0, expanded.c_str() + vx1);
|
||||
}
|
||||
// We drew text via draw list (no layout advance). Manually advance the cursor to the next line.
|
||||
ImGui::SetCursorScreenPos(ImVec2(line_pos.x, line_pos.y + line_h));
|
||||
} else {
|
||||
// No syntax: draw as one run
|
||||
ImGui::TextUnformatted(expanded.c_str());
|
||||
}
|
||||
|
||||
// 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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
ImGui::EndChild();
|
||||
|
||||
// Status bar spanning full width
|
||||
ImGui::Separator();
|
||||
|
||||
// Compute full content width and draw a filled background rectangle
|
||||
ImVec2 win_pos = ImGui::GetWindowPos();
|
||||
ImVec2 cr_min = ImGui::GetWindowContentRegionMin();
|
||||
ImVec2 cr_max = ImGui::GetWindowContentRegionMax();
|
||||
float x0 = win_pos.x + cr_min.x;
|
||||
float x1 = win_pos.x + cr_max.x;
|
||||
ImVec2 cursor = ImGui::GetCursorScreenPos();
|
||||
float bar_h = ImGui::GetFrameHeight();
|
||||
ImVec2 p0(x0, cursor.y);
|
||||
ImVec2 p1(x1, cursor.y + bar_h);
|
||||
ImU32 bg_col = ImGui::GetColorU32(ImGuiCol_HeaderActive);
|
||||
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, bg_col);
|
||||
// If a prompt is active, replace the entire status bar with the prompt text
|
||||
if (ed.PromptActive()) {
|
||||
std::string label = ed.PromptLabel();
|
||||
std::string ptext = ed.PromptText();
|
||||
auto kind = ed.CurrentPromptKind();
|
||||
if (kind == Editor::PromptKind::OpenFile || kind == Editor::PromptKind::SaveAs ||
|
||||
kind == Editor::PromptKind::Chdir) {
|
||||
const char *home_c = std::getenv("HOME");
|
||||
if (home_c && *home_c) {
|
||||
std::string home(home_c);
|
||||
if (ptext.rfind(home, 0) == 0) {
|
||||
std::string rest = ptext.substr(home.size());
|
||||
if (rest.empty())
|
||||
ptext = "~";
|
||||
else if (!rest.empty() && (rest[0] == '/' || rest[0] == '\\'))
|
||||
ptext = std::string("~") + rest;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
float pad = 6.f;
|
||||
float left_x = p0.x + pad;
|
||||
float right_x = p1.x - pad;
|
||||
float max_px = std::max(0.0f, right_x - left_x);
|
||||
|
||||
std::string prefix;
|
||||
if (kind == Editor::PromptKind::Command) {
|
||||
prefix = ": ";
|
||||
} else if (!label.empty()) {
|
||||
prefix = label + ": ";
|
||||
}
|
||||
|
||||
// Compose showing right-end of filename portion when too long for space
|
||||
std::string final_msg;
|
||||
ImVec2 prefix_sz = ImGui::CalcTextSize(prefix.c_str());
|
||||
float avail_px = std::max(0.0f, max_px - prefix_sz.x);
|
||||
if ((kind == Editor::PromptKind::OpenFile || kind == Editor::PromptKind::SaveAs || kind ==
|
||||
Editor::PromptKind::Chdir) && avail_px > 0.0f) {
|
||||
// Trim from left until it fits by pixel width
|
||||
std::string tail = ptext;
|
||||
ImVec2 tail_sz = ImGui::CalcTextSize(tail.c_str());
|
||||
if (tail_sz.x > avail_px) {
|
||||
// Remove leading chars until it fits
|
||||
// Use a simple loop; text lengths are small here
|
||||
size_t start = 0;
|
||||
// To avoid O(n^2) worst-case, remove chunks
|
||||
while (start < tail.size()) {
|
||||
// Estimate how many chars to skip based on ratio
|
||||
float ratio = tail_sz.x / avail_px;
|
||||
size_t skip = ratio > 1.5f
|
||||
? std::min(tail.size() - start,
|
||||
(size_t) std::max<size_t>(
|
||||
1, (size_t) (tail.size() / 4)))
|
||||
: 1;
|
||||
start += skip;
|
||||
std::string candidate = tail.substr(start);
|
||||
ImVec2 cand_sz = ImGui::CalcTextSize(candidate.c_str());
|
||||
if (cand_sz.x <= avail_px) {
|
||||
tail = candidate;
|
||||
tail_sz = cand_sz;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ImGui::CalcTextSize(tail.c_str()).x > avail_px && !tail.empty()) {
|
||||
// As a last resort, ensure fit by chopping exactly
|
||||
// binary reduce
|
||||
size_t lo = 0, hi = tail.size();
|
||||
while (lo < hi) {
|
||||
size_t mid = (lo + hi) / 2;
|
||||
std::string cand = tail.substr(mid);
|
||||
if (ImGui::CalcTextSize(cand.c_str()).x <= avail_px)
|
||||
hi = mid;
|
||||
else
|
||||
lo = mid + 1;
|
||||
}
|
||||
tail = tail.substr(lo);
|
||||
}
|
||||
}
|
||||
final_msg = prefix + tail;
|
||||
} else {
|
||||
final_msg = prefix + ptext;
|
||||
}
|
||||
|
||||
ImVec2 msg_sz = ImGui::CalcTextSize(final_msg.c_str());
|
||||
ImGui::PushClipRect(ImVec2(p0.x, p0.y), ImVec2(p1.x, p1.y), true);
|
||||
ImGui::SetCursorScreenPos(ImVec2(left_x, p0.y + (bar_h - msg_sz.y) * 0.5f));
|
||||
ImGui::TextUnformatted(final_msg.c_str());
|
||||
ImGui::PopClipRect();
|
||||
// Advance cursor to after the bar to keep layout consistent
|
||||
ImGui::Dummy(ImVec2(x1 - x0, bar_h));
|
||||
} else {
|
||||
// Build left text
|
||||
std::string left;
|
||||
left.reserve(256);
|
||||
left += "kge"; // GUI app name
|
||||
left += " ";
|
||||
left += KTE_VERSION_STR;
|
||||
std::string fname;
|
||||
try {
|
||||
fname = ed.DisplayNameFor(*buf);
|
||||
} catch (...) {
|
||||
fname = buf->Filename();
|
||||
try {
|
||||
fname = std::filesystem::path(fname).filename().string();
|
||||
} catch (...) {}
|
||||
}
|
||||
left += " ";
|
||||
// Insert buffer position prefix "[x/N] " before filename
|
||||
{
|
||||
std::size_t total = ed.BufferCount();
|
||||
if (total > 0) {
|
||||
std::size_t idx1 = ed.CurrentBufferIndex() + 1; // 1-based for display
|
||||
left += "[";
|
||||
left += std::to_string(static_cast<unsigned long long>(idx1));
|
||||
left += "/";
|
||||
left += std::to_string(static_cast<unsigned long long>(total));
|
||||
left += "] ";
|
||||
}
|
||||
}
|
||||
left += fname;
|
||||
if (buf->Dirty())
|
||||
left += " *";
|
||||
// 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";
|
||||
}
|
||||
|
||||
// Build right text (cursor/mark)
|
||||
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;
|
||||
char rbuf[128];
|
||||
if (have_mark)
|
||||
std::snprintf(rbuf, sizeof(rbuf), "%d,%d | M: %d,%d", row1, col1, mrow1, mcol1);
|
||||
else
|
||||
std::snprintf(rbuf, sizeof(rbuf), "%d,%d | M: not set", row1, col1);
|
||||
std::string right = rbuf;
|
||||
|
||||
// Middle message: if a prompt is active, show "Label: text"; otherwise show status
|
||||
std::string msg;
|
||||
if (ed.PromptActive()) {
|
||||
msg = ed.PromptLabel();
|
||||
if (!msg.empty())
|
||||
msg += ": ";
|
||||
msg += ed.PromptText();
|
||||
} else {
|
||||
msg = ed.Status();
|
||||
}
|
||||
|
||||
// Measurements
|
||||
ImVec2 left_sz = ImGui::CalcTextSize(left.c_str());
|
||||
ImVec2 right_sz = ImGui::CalcTextSize(right.c_str());
|
||||
float pad = 6.f;
|
||||
float left_x = p0.x + pad;
|
||||
float right_x = p1.x - pad - right_sz.x;
|
||||
if (right_x < left_x + left_sz.x + pad) {
|
||||
// Not enough room; clip left to fit
|
||||
float max_left = std::max(0.0f, right_x - left_x - pad);
|
||||
if (max_left < left_sz.x && max_left > 10.0f) {
|
||||
// Render a clipped left using a child region
|
||||
ImGui::SetCursorScreenPos(ImVec2(left_x, p0.y + (bar_h - left_sz.y) * 0.5f));
|
||||
ImGui::PushClipRect(ImVec2(left_x, p0.y), ImVec2(right_x - pad, p1.y), true);
|
||||
ImGui::TextUnformatted(left.c_str());
|
||||
ImGui::PopClipRect();
|
||||
}
|
||||
} else {
|
||||
// Draw left normally
|
||||
ImGui::SetCursorScreenPos(ImVec2(left_x, p0.y + (bar_h - left_sz.y) * 0.5f));
|
||||
ImGui::TextUnformatted(left.c_str());
|
||||
}
|
||||
|
||||
// Draw right
|
||||
ImGui::SetCursorScreenPos(ImVec2(std::max(right_x, left_x),
|
||||
p0.y + (bar_h - right_sz.y) * 0.5f));
|
||||
ImGui::TextUnformatted(right.c_str());
|
||||
|
||||
// Draw middle message centered in remaining space
|
||||
if (!msg.empty()) {
|
||||
float mid_left = left_x + left_sz.x + pad;
|
||||
float mid_right = std::max(right_x - pad, mid_left);
|
||||
float mid_w = std::max(0.0f, mid_right - mid_left);
|
||||
if (mid_w > 1.0f) {
|
||||
ImVec2 msg_sz = ImGui::CalcTextSize(msg.c_str());
|
||||
float msg_x = mid_left + std::max(0.0f, (mid_w - msg_sz.x) * 0.5f);
|
||||
// Clip to middle region
|
||||
ImGui::PushClipRect(ImVec2(mid_left, p0.y), ImVec2(mid_right, p1.y), true);
|
||||
ImGui::SetCursorScreenPos(ImVec2(msg_x, p0.y + (bar_h - msg_sz.y) * 0.5f));
|
||||
ImGui::TextUnformatted(msg.c_str());
|
||||
ImGui::PopClipRect();
|
||||
}
|
||||
}
|
||||
// Advance cursor to after the bar to keep layout consistent
|
||||
ImGui::Dummy(ImVec2(x1 - x0, bar_h));
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::End();
|
||||
ImGui::PopStyleVar(3);
|
||||
|
||||
// --- Visual File Picker overlay (GUI only) ---
|
||||
if (ed.FilePickerVisible()) {
|
||||
// Centered popup-style window that always fits within the current viewport
|
||||
ImGuiViewport *vp2 = ImGui::GetMainViewport();
|
||||
|
||||
// Desired size, min size, and margins
|
||||
const ImVec2 want(800.0f, 500.0f);
|
||||
const ImVec2 min_sz(240.0f, 160.0f);
|
||||
const float margin = 20.0f; // space from viewport edges
|
||||
|
||||
// Compute the maximum allowed size (viewport minus margins) and make sure it's not negative
|
||||
ImVec2 max_sz(std::max(32.0f, vp2->Size.x - 2.0f * margin),
|
||||
std::max(32.0f, vp2->Size.y - 2.0f * margin));
|
||||
|
||||
// Clamp desired size to [min_sz, max_sz]
|
||||
ImVec2 size(std::min(want.x, max_sz.x), std::min(want.y, max_sz.y));
|
||||
size.x = std::max(size.x, std::min(min_sz.x, max_sz.x));
|
||||
size.y = std::max(size.y, std::min(min_sz.y, max_sz.y));
|
||||
|
||||
// Center within the viewport using the final size
|
||||
ImVec2 pos(vp2->Pos.x + std::max(margin, (vp2->Size.x - size.x) * 0.5f),
|
||||
vp2->Pos.y + std::max(margin, (vp2->Size.y - size.y) * 0.5f));
|
||||
|
||||
// On HiDPI displays (macOS Retina), ensure integer pixel alignment to avoid
|
||||
// potential hit-test vs draw mismatches from sub-pixel positions.
|
||||
pos.x = std::floor(pos.x + 0.5f);
|
||||
pos.y = std::floor(pos.y + 0.5f);
|
||||
size.x = std::floor(size.x + 0.5f);
|
||||
size.y = std::floor(size.y + 0.5f);
|
||||
|
||||
ImGui::SetNextWindowPos(pos, ImGuiCond_Always);
|
||||
ImGui::SetNextWindowSize(size, ImGuiCond_Always);
|
||||
ImGuiWindowFlags wflags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse |
|
||||
ImGuiWindowFlags_NoDocking;
|
||||
bool open = true;
|
||||
if (ImGui::Begin("File Picker", &open, wflags)) {
|
||||
// Current directory
|
||||
std::string curdir = ed.FilePickerDir();
|
||||
if (curdir.empty()) {
|
||||
try {
|
||||
curdir = std::filesystem::current_path().string();
|
||||
} catch (...) {
|
||||
curdir = ".";
|
||||
}
|
||||
ed.SetFilePickerDir(curdir);
|
||||
}
|
||||
ImGui::TextUnformatted(curdir.c_str());
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Up")) {
|
||||
try {
|
||||
std::filesystem::path p(curdir);
|
||||
if (p.has_parent_path()) {
|
||||
ed.SetFilePickerDir(p.parent_path().string());
|
||||
}
|
||||
} catch (...) {}
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Close")) {
|
||||
ed.SetFilePickerVisible(false);
|
||||
}
|
||||
|
||||
ImGui::Separator();
|
||||
|
||||
// Header
|
||||
ImGui::TextUnformatted("Name");
|
||||
ImGui::Separator();
|
||||
|
||||
// Scrollable list
|
||||
ImGui::BeginChild("picker-list", ImVec2(0, 0), true);
|
||||
|
||||
// Build entries: directories first then files, alphabetical
|
||||
struct Entry {
|
||||
std::string name;
|
||||
std::filesystem::path path;
|
||||
bool is_dir;
|
||||
};
|
||||
std::vector<Entry> entries;
|
||||
entries.reserve(256);
|
||||
// Optional parent entry
|
||||
try {
|
||||
std::filesystem::path base(curdir);
|
||||
std::error_code ec;
|
||||
for (auto it = std::filesystem::directory_iterator(base, ec);
|
||||
!ec && it != std::filesystem::directory_iterator(); it.increment(ec)) {
|
||||
const auto &p = it->path();
|
||||
std::string nm;
|
||||
try {
|
||||
nm = p.filename().string();
|
||||
} catch (...) {
|
||||
continue;
|
||||
}
|
||||
if (nm == "." || nm == "..")
|
||||
continue;
|
||||
bool is_dir = false;
|
||||
std::error_code ec2;
|
||||
is_dir = it->is_directory(ec2);
|
||||
entries.push_back({nm, p, is_dir});
|
||||
}
|
||||
} catch (...) {
|
||||
// ignore listing errors; show empty
|
||||
}
|
||||
std::sort(entries.begin(), entries.end(), [](const Entry &a, const Entry &b) {
|
||||
if (a.is_dir != b.is_dir)
|
||||
return a.is_dir && !b.is_dir;
|
||||
return a.name < b.name;
|
||||
});
|
||||
|
||||
// Draw rows
|
||||
int idx = 0;
|
||||
for (const auto &e: entries) {
|
||||
ImGui::PushID(idx++); // ensure unique/stable IDs even if names repeat
|
||||
std::string label;
|
||||
label.reserve(e.name.size() + 4);
|
||||
if (e.is_dir)
|
||||
label += "[";
|
||||
label += e.name;
|
||||
if (e.is_dir)
|
||||
label += "]";
|
||||
|
||||
// Render selectable row
|
||||
ImGui::Selectable(label.c_str(), false, ImGuiSelectableFlags_AllowDoubleClick);
|
||||
|
||||
// Activate based strictly on hover + mouse, to avoid any off-by-one due to click routing
|
||||
if (ImGui::IsItemHovered()) {
|
||||
if (e.is_dir && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) {
|
||||
// Enter directory on double-click
|
||||
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.SetFilePickerVisible(false);
|
||||
}
|
||||
}
|
||||
ImGui::PopID();
|
||||
}
|
||||
|
||||
ImGui::EndChild();
|
||||
}
|
||||
ImGui::End();
|
||||
if (!open) {
|
||||
ed.SetFilePickerVisible(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
/*
|
||||
* GUIRenderer - ImGui-based renderer for GUI mode
|
||||
*/
|
||||
#ifndef KTE_GUI_RENDERER_H
|
||||
#define KTE_GUI_RENDERER_H
|
||||
|
||||
#include "Renderer.h"
|
||||
|
||||
class GUIRenderer final : public Renderer {
|
||||
public:
|
||||
GUIRenderer() = default;
|
||||
|
||||
~GUIRenderer() override = default;
|
||||
|
||||
void Draw(Editor &ed) override;
|
||||
};
|
||||
|
||||
#endif // KTE_GUI_RENDERER_H
|
||||
1657
GUITheme.h
1657
GUITheme.h
File diff suppressed because it is too large
Load Diff
204
GapBuffer.cc
204
GapBuffer.cc
@@ -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_)
|
||||
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)
|
||||
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) {
|
||||
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)
|
||||
return;
|
||||
ensureCapacityFor(len);
|
||||
if (size_ > 0) {
|
||||
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)
|
||||
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';
|
||||
}
|
||||
80
GapBuffer.h
80
GapBuffer.h
@@ -1,80 +0,0 @@
|
||||
/*
|
||||
* GapBuffer.h - C++ replacement for abuf append/prepend buffer utilities
|
||||
*/
|
||||
#ifndef KTE_GAPBUFFER_H
|
||||
#define KTE_GAPBUFFER_H
|
||||
|
||||
#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
|
||||
};
|
||||
|
||||
#endif // KTE_GAPBUFFER_H
|
||||
@@ -1,48 +0,0 @@
|
||||
#include "GoHighlighter.h"
|
||||
#include "Buffer.h"
|
||||
#include <cctype>
|
||||
|
||||
namespace kte {
|
||||
|
||||
static void push(std::vector<HighlightSpan> &out, int a, int b, TokenKind k){ if (b>a) out.push_back({a,b,k}); }
|
||||
static bool is_ident_start(char c){ return std::isalpha(static_cast<unsigned char>(c)) || c=='_'; }
|
||||
static bool is_ident_char(char c){ return std::isalnum(static_cast<unsigned char>(c)) || c=='_'; }
|
||||
|
||||
GoHighlighter::GoHighlighter()
|
||||
{
|
||||
const char* kw[] = {"break","case","chan","const","continue","default","defer","else","fallthrough","for","func","go","goto","if","import","interface","map","package","range","return","select","struct","switch","type","var"};
|
||||
for (auto s: kw) kws_.insert(s);
|
||||
const char* tp[] = {"bool","byte","complex64","complex128","error","float32","float64","int","int8","int16","int32","int64","rune","string","uint","uint8","uint16","uint32","uint64","uintptr"};
|
||||
for (auto s: tp) types_.insert(s);
|
||||
}
|
||||
|
||||
void GoHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
const auto &rows = buf.Rows();
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size()) return;
|
||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
||||
int n = static_cast<int>(s.size());
|
||||
int i = 0;
|
||||
int bol=0; while (bol<n && (s[bol]==' '||s[bol]=='\t')) ++bol;
|
||||
// line comment
|
||||
while (i < n) {
|
||||
char c = s[i];
|
||||
if (c==' '||c=='\t') { int j=i+1; while (j<n && (s[j]==' '||s[j]=='\t')) ++j; push(out,i,j,TokenKind::Whitespace); i=j; continue; }
|
||||
if (c=='/' && i+1<n && s[i+1]=='/') { push(out,i,n,TokenKind::Comment); break; }
|
||||
if (c=='/' && i+1<n && s[i+1]=='*') {
|
||||
int j=i+2; bool closed=false; while (j+1<=n) { if (j+1<n && s[j]=='*' && s[j+1]=='/') { j+=2; closed=true; break; } ++j; }
|
||||
if (!closed) { push(out,i,n,TokenKind::Comment); break; } else { push(out,i,j,TokenKind::Comment); i=j; continue; }
|
||||
}
|
||||
if (c=='"' || c=='`') {
|
||||
char q=c; int j=i+1; bool esc=false; if (q=='`') { while (j<n && s[j] != '`') ++j; if (j<n) ++j; }
|
||||
else { while (j<n){ char d=s[j++]; if (esc){esc=false; continue;} if (d=='\\'){esc=true; continue;} if (d=='"') break;} }
|
||||
push(out,i,j,TokenKind::String); i=j; continue;
|
||||
}
|
||||
if (std::isdigit(static_cast<unsigned char>(c))) { int j=i+1; while (j<n && (std::isalnum(static_cast<unsigned char>(s[j]))||s[j]=='.'||s[j]=='x'||s[j]=='X'||s[j]=='_')) ++j; push(out,i,j,TokenKind::Number); i=j; continue; }
|
||||
if (is_ident_start(c)) { int j=i+1; while (j<n && is_ident_char(s[j])) ++j; std::string id=s.substr(i,j-i); TokenKind k=TokenKind::Identifier; if (kws_.count(id)) k=TokenKind::Keyword; else if (types_.count(id)) k=TokenKind::Type; push(out,i,j,k); i=j; continue; }
|
||||
if (std::ispunct(static_cast<unsigned char>(c))) { TokenKind k=TokenKind::Operator; if (c==';'||c==','||c=='('||c==')'||c=='{'||c=='}'||c=='['||c==']') k=TokenKind::Punctuation; push(out,i,i+1,k); ++i; continue; }
|
||||
push(out,i,i+1,TokenKind::Default); ++i;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace kte
|
||||
38
HelpText.cc
38
HelpText.cc
@@ -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 (eink, gruvbox, nord, plan9, solarized)\n"
|
||||
" : background MODE Set background: light | dark (affects eink, gruvbox, 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,9 +1,7 @@
|
||||
/*
|
||||
* HelpText.h - embedded/customizable help content
|
||||
*/
|
||||
#ifndef KTE_HELPTEXT_H
|
||||
#define KTE_HELPTEXT_H
|
||||
|
||||
#pragma once
|
||||
#include <string>
|
||||
|
||||
class HelpText {
|
||||
@@ -13,5 +11,3 @@ public:
|
||||
// (in HelpText.cc) without touching the help command logic.
|
||||
static std::string Text();
|
||||
};
|
||||
|
||||
#endif // KTE_HELPTEXT_H
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
#include <vector>
|
||||
|
||||
namespace kte {
|
||||
|
||||
// Token kinds shared between renderers and highlighters
|
||||
enum class TokenKind {
|
||||
Default,
|
||||
@@ -35,5 +34,4 @@ struct LineHighlight {
|
||||
std::vector<HighlightSpan> spans;
|
||||
std::uint64_t version{0}; // buffer version used for this line
|
||||
};
|
||||
|
||||
} // namespace kte
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
#include "HighlighterEngine.h"
|
||||
#include "Buffer.h"
|
||||
#include "LanguageHighlighter.h"
|
||||
|
||||
namespace kte {
|
||||
|
||||
HighlighterEngine::HighlighterEngine() = default;
|
||||
HighlighterEngine::~HighlighterEngine() = default;
|
||||
|
||||
void
|
||||
HighlighterEngine::SetHighlighter(std::unique_ptr<LanguageHighlighter> hl)
|
||||
{
|
||||
hl_ = std::move(hl);
|
||||
cache_.clear();
|
||||
state_cache_.clear();
|
||||
}
|
||||
|
||||
const LineHighlight &
|
||||
HighlighterEngine::GetLine(const Buffer &buf, int row, std::uint64_t buf_version) const
|
||||
{
|
||||
auto it = cache_.find(row);
|
||||
if (it != cache_.end()) {
|
||||
if (it->second.version == buf_version) {
|
||||
return it->second;
|
||||
}
|
||||
}
|
||||
LineHighlight updated;
|
||||
updated.version = buf_version;
|
||||
updated.spans.clear();
|
||||
if (!hl_) {
|
||||
auto &slot = cache_[row];
|
||||
slot = std::move(updated);
|
||||
return cache_[row];
|
||||
}
|
||||
|
||||
if (auto *stateful = dynamic_cast<StatefulHighlighter *>(hl_.get())) {
|
||||
// Find nearest cached state at or before row-1 with matching version
|
||||
StatefulHighlighter::LineState prev_state;
|
||||
int start_row = -1;
|
||||
if (!state_cache_.empty()) {
|
||||
// linear search over map (unordered), track best candidate
|
||||
int best = -1;
|
||||
for (const auto &kv : state_cache_) {
|
||||
int r = kv.first;
|
||||
if (r <= row - 1 && kv.second.version == buf_version) {
|
||||
if (r > best) {
|
||||
best = r;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (best >= 0) {
|
||||
start_row = best;
|
||||
prev_state = state_cache_.at(best).state;
|
||||
}
|
||||
}
|
||||
|
||||
// Walk from start_row+1 up to row computing states; only collect spans at the target row
|
||||
for (int r = start_row + 1; r <= row; ++r) {
|
||||
std::vector<HighlightSpan> tmp;
|
||||
std::vector<HighlightSpan> &out = (r == row) ? updated.spans : tmp;
|
||||
auto next_state = stateful->HighlightLineStateful(buf, r, prev_state, out);
|
||||
// store state for this row (state after finishing r)
|
||||
StateEntry se;
|
||||
se.version = buf_version;
|
||||
se.state = next_state;
|
||||
state_cache_[r] = se;
|
||||
prev_state = next_state;
|
||||
}
|
||||
} else {
|
||||
// Stateless path
|
||||
hl_->HighlightLine(buf, row, updated.spans);
|
||||
}
|
||||
|
||||
auto &slot = cache_[row];
|
||||
slot = std::move(updated);
|
||||
return cache_[row];
|
||||
}
|
||||
|
||||
void
|
||||
HighlighterEngine::InvalidateFrom(int row)
|
||||
{
|
||||
if (cache_.empty()) return;
|
||||
// Simple implementation: erase all rows >= row
|
||||
for (auto it = cache_.begin(); it != cache_.end(); ) {
|
||||
if (it->first >= row) it = cache_.erase(it); else ++it;
|
||||
}
|
||||
if (!state_cache_.empty()) {
|
||||
for (auto it = state_cache_.begin(); it != state_cache_.end(); ) {
|
||||
if (it->first >= row) it = state_cache_.erase(it); else ++it;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace kte
|
||||
@@ -1,45 +0,0 @@
|
||||
// HighlighterEngine.h - caching layer for per-line highlights
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include "Highlight.h"
|
||||
#include "LanguageHighlighter.h"
|
||||
|
||||
class Buffer;
|
||||
|
||||
namespace kte {
|
||||
|
||||
class HighlighterEngine {
|
||||
public:
|
||||
HighlighterEngine();
|
||||
~HighlighterEngine();
|
||||
|
||||
void SetHighlighter(std::unique_ptr<LanguageHighlighter> hl);
|
||||
|
||||
// Retrieve highlights for a given line and buffer version.
|
||||
// If cache is stale, recompute using the current highlighter.
|
||||
const LineHighlight &GetLine(const Buffer &buf, int row, std::uint64_t buf_version) const;
|
||||
|
||||
// Invalidate cached lines from row (inclusive)
|
||||
void InvalidateFrom(int row);
|
||||
|
||||
bool HasHighlighter() const { return static_cast<bool>(hl_); }
|
||||
|
||||
private:
|
||||
std::unique_ptr<LanguageHighlighter> hl_;
|
||||
// Simple cache by row index (mutable to allow caching in const GetLine)
|
||||
mutable std::unordered_map<int, LineHighlight> cache_;
|
||||
// For stateful highlighters, remember per-line state (state after finishing that row)
|
||||
struct StateEntry {
|
||||
std::uint64_t version{0};
|
||||
// Using the interface type; forward-declare via header
|
||||
StatefulHighlighter::LineState state;
|
||||
};
|
||||
mutable std::unordered_map<int, StateEntry> state_cache_;
|
||||
};
|
||||
|
||||
} // namespace kte
|
||||
@@ -1,93 +0,0 @@
|
||||
#include "HighlighterRegistry.h"
|
||||
#include "CppHighlighter.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <filesystem>
|
||||
|
||||
// Forward declare simple highlighters implemented in this project
|
||||
namespace kte {
|
||||
class JSONHighlighter; class MarkdownHighlighter; class ShellHighlighter;
|
||||
class GoHighlighter; class PythonHighlighter; class RustHighlighter; class LispHighlighter;
|
||||
}
|
||||
|
||||
// Headers for the above
|
||||
#include "JsonHighlighter.h"
|
||||
#include "MarkdownHighlighter.h"
|
||||
#include "ShellHighlighter.h"
|
||||
#include "GoHighlighter.h"
|
||||
#include "PythonHighlighter.h"
|
||||
#include "RustHighlighter.h"
|
||||
#include "LispHighlighter.h"
|
||||
|
||||
namespace kte {
|
||||
|
||||
static std::string to_lower(std::string_view s) {
|
||||
std::string r(s);
|
||||
std::transform(r.begin(), r.end(), r.begin(), [](unsigned char c){ return static_cast<char>(std::tolower(c)); });
|
||||
return r;
|
||||
}
|
||||
|
||||
std::string HighlighterRegistry::Normalize(std::string_view ft)
|
||||
{
|
||||
std::string f = to_lower(ft);
|
||||
if (f == "c" || f == "c++" || f == "cc" || f == "hpp" || f == "hh" || f == "h" || f == "cxx") return "cpp";
|
||||
if (f == "cpp") return "cpp";
|
||||
if (f == "json") return "json";
|
||||
if (f == "markdown" || f == "md" || f == "mkd" || f == "mdown") return "markdown";
|
||||
if (f == "shell" || f == "sh" || f == "bash" || f == "zsh" || f == "ksh" || f == "fish") return "shell";
|
||||
if (f == "go" || f == "golang") return "go";
|
||||
if (f == "py" || f == "python") return "python";
|
||||
if (f == "rs" || f == "rust") return "rust";
|
||||
if (f == "lisp" || f == "scheme" || f == "scm" || f == "rkt" || f == "el" || f == "clj" || f == "cljc" || f == "cl") return "lisp";
|
||||
return f;
|
||||
}
|
||||
|
||||
std::unique_ptr<LanguageHighlighter> HighlighterRegistry::CreateFor(std::string_view filetype)
|
||||
{
|
||||
std::string ft = Normalize(filetype);
|
||||
if (ft == "cpp") return std::make_unique<CppHighlighter>();
|
||||
if (ft == "json") return std::make_unique<JSONHighlighter>();
|
||||
if (ft == "markdown") return std::make_unique<MarkdownHighlighter>();
|
||||
if (ft == "shell") return std::make_unique<ShellHighlighter>();
|
||||
if (ft == "go") return std::make_unique<GoHighlighter>();
|
||||
if (ft == "python") return std::make_unique<PythonHighlighter>();
|
||||
if (ft == "rust") return std::make_unique<RustHighlighter>();
|
||||
if (ft == "lisp") return std::make_unique<LispHighlighter>();
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
static std::string shebang_to_ft(std::string_view first_line) {
|
||||
if (first_line.size() < 2 || first_line.substr(0,2) != "#!") return "";
|
||||
std::string low = to_lower(first_line);
|
||||
if (low.find("python") != std::string::npos) return "python";
|
||||
if (low.find("bash") != std::string::npos) return "shell";
|
||||
if (low.find("sh") != std::string::npos) return "shell";
|
||||
if (low.find("zsh") != std::string::npos) return "shell";
|
||||
if (low.find("fish") != std::string::npos) return "shell";
|
||||
if (low.find("scheme") != std::string::npos || low.find("racket") != std::string::npos || low.find("guile") != std::string::npos) return "lisp";
|
||||
return "";
|
||||
}
|
||||
|
||||
std::string HighlighterRegistry::DetectForPath(std::string_view path, std::string_view first_line)
|
||||
{
|
||||
// Extension
|
||||
std::string p(path);
|
||||
std::error_code ec;
|
||||
std::string ext = std::filesystem::path(p).extension().string();
|
||||
for (auto &ch: ext) ch = static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
|
||||
if (!ext.empty()) {
|
||||
if (ext == ".c" || ext == ".cc" || ext == ".cpp" || ext == ".cxx" || ext == ".h" || ext == ".hpp" || ext == ".hh") return "cpp";
|
||||
if (ext == ".json") return "json";
|
||||
if (ext == ".md" || ext == ".markdown" || ext == ".mkd") return "markdown";
|
||||
if (ext == ".sh" || ext == ".bash" || ext == ".zsh" || ext == ".ksh" || ext == ".fish") return "shell";
|
||||
if (ext == ".go") return "go";
|
||||
if (ext == ".py") return "python";
|
||||
if (ext == ".rs") return "rust";
|
||||
if (ext == ".lisp" || ext == ".scm" || ext == ".rkt" || ext == ".el" || ext == ".clj" || ext == ".cljc" || ext == ".cl") return "lisp";
|
||||
}
|
||||
// Shebang
|
||||
std::string ft = shebang_to_ft(first_line);
|
||||
return ft;
|
||||
}
|
||||
|
||||
} // namespace kte
|
||||
@@ -1,26 +0,0 @@
|
||||
// HighlighterRegistry.h - create/detect language highlighters
|
||||
#pragma once
|
||||
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
|
||||
#include "LanguageHighlighter.h"
|
||||
|
||||
namespace kte {
|
||||
|
||||
class HighlighterRegistry {
|
||||
public:
|
||||
// Create a highlighter for normalized filetype id (e.g., "cpp", "json", "markdown", "shell", "go", "python", "rust", "lisp").
|
||||
static std::unique_ptr<LanguageHighlighter> CreateFor(std::string_view filetype);
|
||||
|
||||
// Detect filetype by path extension and shebang (first line).
|
||||
// Returns normalized id or empty string if unknown.
|
||||
static std::string DetectForPath(std::string_view path, std::string_view first_line);
|
||||
|
||||
// Normalize various aliases/extensions to canonical ids.
|
||||
static std::string Normalize(std::string_view ft);
|
||||
};
|
||||
|
||||
} // namespace kte
|
||||
674
ImGuiFrontend.cc
Normal file
674
ImGuiFrontend.cc
Normal file
@@ -0,0 +1,674 @@
|
||||
#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()));
|
||||
|
||||
// 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) {
|
||||
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;
|
||||
}
|
||||
59
ImGuiFrontend.h
Normal file
59
ImGuiFrontend.h
Normal file
@@ -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_;
|
||||
};
|
||||
@@ -3,9 +3,11 @@
|
||||
#include <ncurses.h>
|
||||
|
||||
#include <SDL.h>
|
||||
#include <imgui.h>
|
||||
|
||||
#include "GUIInputHandler.h"
|
||||
#include "ImGuiInputHandler.h"
|
||||
#include "KKeymap.h"
|
||||
#include "Editor.h"
|
||||
|
||||
|
||||
static bool
|
||||
@@ -13,20 +15,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) {
|
||||
@@ -44,51 +43,73 @@ 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};
|
||||
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;
|
||||
}
|
||||
|
||||
// Movement and basic keys
|
||||
// These keys exit k-prefix mode if active (user pressed C-k then a special key).
|
||||
switch (key) {
|
||||
case SDLK_LEFT:
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveLeft, "", 0};
|
||||
return true;
|
||||
case SDLK_RIGHT:
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveRight, "", 0};
|
||||
return true;
|
||||
case SDLK_UP:
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveUp, "", 0};
|
||||
return true;
|
||||
case SDLK_DOWN:
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveDown, "", 0};
|
||||
return true;
|
||||
case SDLK_HOME:
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveHome, "", 0};
|
||||
return true;
|
||||
case SDLK_END:
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveEnd, "", 0};
|
||||
return true;
|
||||
case SDLK_PAGEUP:
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::PageUp, "", 0};
|
||||
return true;
|
||||
case SDLK_PAGEDOWN:
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::PageDown, "", 0};
|
||||
return true;
|
||||
case SDLK_DELETE:
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::DeleteChar, "", 0};
|
||||
return true;
|
||||
case SDLK_BACKSPACE:
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::Backspace, "", 0};
|
||||
return true;
|
||||
case SDLK_TAB:
|
||||
@@ -102,10 +123,17 @@ map_key(const SDL_Keycode key,
|
||||
break; // fall through so k-prefix handler can process
|
||||
case SDLK_RETURN:
|
||||
case SDLK_KP_ENTER:
|
||||
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;
|
||||
@@ -115,7 +143,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;
|
||||
@@ -135,10 +162,24 @@ 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);
|
||||
// Diagnostics for u/U
|
||||
@@ -155,54 +196,40 @@ map_key(const SDL_Keycode key,
|
||||
}
|
||||
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;
|
||||
@@ -246,29 +273,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
|
||||
@@ -278,27 +293,41 @@ 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: {
|
||||
// Map vertical wheel to line-wise cursor movement (MoveUp/MoveDown)
|
||||
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::MoveUp : CommandId::MoveDown;
|
||||
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 < repeat; ++i) {
|
||||
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 false;
|
||||
}
|
||||
case SDL_KEYDOWN: {
|
||||
@@ -308,6 +337,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)) {
|
||||
@@ -328,7 +389,9 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
||||
segment = std::string_view(text).substr(start);
|
||||
}
|
||||
if (!segment.empty()) {
|
||||
MappedInput ins{true, CommandId::InsertText, std::string(segment), 0};
|
||||
MappedInput ins{
|
||||
true, CommandId::InsertText, std::string(segment), 0
|
||||
};
|
||||
q_.push(ins);
|
||||
}
|
||||
if (has_nl) {
|
||||
@@ -345,28 +408,27 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
bool suppress_req = false;
|
||||
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") {
|
||||
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.
|
||||
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)) {
|
||||
suppress_text_input_once_ = true;
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
@@ -387,7 +449,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;
|
||||
}
|
||||
@@ -411,35 +474,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,
|
||||
@@ -455,9 +509,21 @@ 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);
|
||||
// Diagnostics: log any k-prefix TEXTINPUT suffix mapping
|
||||
char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e)
|
||||
? static_cast<char>(ascii_key)
|
||||
@@ -469,6 +535,8 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
||||
std::fflush(stderr);
|
||||
if (mapped) {
|
||||
mi = {true, id, "", 0};
|
||||
if (ed_)
|
||||
ed_->SetStatus(""); // clear "C-k _" hint after suffix
|
||||
produced = true;
|
||||
break; // handled; do not insert text
|
||||
} else {
|
||||
@@ -479,12 +547,17 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
||||
: '?';
|
||||
std::string arg(1, c);
|
||||
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;
|
||||
}
|
||||
@@ -524,7 +597,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;
|
||||
}
|
||||
@@ -554,24 +628,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_) {
|
||||
count = (uarg_value_ > 0) ? uarg_value_ : 4;
|
||||
} else {
|
||||
count = uarg_value_;
|
||||
if (uarg_negative_)
|
||||
count = -count;
|
||||
}
|
||||
mi.count = count;
|
||||
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);
|
||||
}
|
||||
@@ -580,7 +636,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,9 +1,7 @@
|
||||
/*
|
||||
* GUIInputHandler - ImGui/SDL2-based input mapping for GUI mode
|
||||
* ImGuiInputHandler - ImGui/SDL2-based input mapping for GUI mode
|
||||
*/
|
||||
#ifndef KTE_GUI_INPUT_HANDLER_H
|
||||
#define KTE_GUI_INPUT_HANDLER_H
|
||||
|
||||
#pragma once
|
||||
#include <mutex>
|
||||
#include <queue>
|
||||
|
||||
@@ -12,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.
|
||||
@@ -28,19 +33,17 @@ private:
|
||||
std::mutex mu_;
|
||||
std::queue<MappedInput> q_;
|
||||
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
|
||||
|
||||
#endif // KTE_GUI_INPUT_HANDLER_H
|
||||
// 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
|
||||
};
|
||||
1017
ImGuiRenderer.cc
Normal file
1017
ImGuiRenderer.cc
Normal file
File diff suppressed because it is too large
Load Diff
37
ImGuiRenderer.h
Normal file
37
ImGuiRenderer.h
Normal file
@@ -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;
|
||||
};
|
||||
@@ -1,13 +1,13 @@
|
||||
/*
|
||||
* InputHandler.h - input abstraction and mapping to commands
|
||||
*/
|
||||
#ifndef KTE_INPUT_HANDLER_H
|
||||
#define KTE_INPUT_HANDLER_H
|
||||
|
||||
#pragma once
|
||||
#include <string>
|
||||
|
||||
#include "Command.h"
|
||||
|
||||
class Editor; // fwd decl
|
||||
|
||||
|
||||
// Result of translating raw input into an editor command.
|
||||
struct MappedInput {
|
||||
@@ -21,9 +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;
|
||||
};
|
||||
|
||||
#endif // KTE_INPUT_HANDLER_H
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
#include "JsonHighlighter.h"
|
||||
#include "Buffer.h"
|
||||
#include <cctype>
|
||||
|
||||
namespace kte {
|
||||
|
||||
static bool is_digit(char c) { return c >= '0' && c <= '9'; }
|
||||
|
||||
void JSONHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
const auto &rows = buf.Rows();
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size()) return;
|
||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
||||
int n = static_cast<int>(s.size());
|
||||
auto push = [&](int a, int b, TokenKind k){ if (b> a) out.push_back({a,b,k}); };
|
||||
|
||||
int i = 0;
|
||||
while (i < n) {
|
||||
char c = s[i];
|
||||
if (c == ' ' || c == '\t') { int j=i+1; while (j<n && (s[j]==' '||s[j]=='\t')) ++j; push(i,j,TokenKind::Whitespace); i=j; continue; }
|
||||
if (c == '"') {
|
||||
int j = i+1; bool esc=false; while (j < n) { char d = s[j++]; if (esc) { esc=false; continue; } if (d == '\\') { esc=true; continue; } if (d == '"') break; }
|
||||
push(i, j, TokenKind::String); i = j; continue;
|
||||
}
|
||||
if (is_digit(c) || (c=='-' && i+1<n && is_digit(s[i+1]))) {
|
||||
int j=i+1; while (j<n && (std::isdigit(static_cast<unsigned char>(s[j]))||s[j]=='.'||s[j]=='e'||s[j]=='E'||s[j]=='+'||s[j]=='-'||s[j]=='_')) ++j; push(i,j,TokenKind::Number); i=j; continue;
|
||||
}
|
||||
// booleans/null
|
||||
if (std::isalpha(static_cast<unsigned char>(c))) {
|
||||
int j=i+1; while (j<n && std::isalpha(static_cast<unsigned char>(s[j]))) ++j;
|
||||
std::string id = s.substr(i, j-i);
|
||||
if (id == "true" || id == "false" || id == "null") push(i,j,TokenKind::Constant); else push(i,j,TokenKind::Identifier);
|
||||
i=j; continue;
|
||||
}
|
||||
// punctuation
|
||||
if (c=='{'||c=='}'||c=='['||c==']'||c==','||c==':' ) { push(i,i+1,TokenKind::Punctuation); ++i; continue; }
|
||||
// fallback
|
||||
push(i,i+1,TokenKind::Default); ++i;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace kte
|
||||
22
KKeymap.cc
22
KKeymap.cc
@@ -17,6 +17,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;
|
||||
@@ -42,6 +45,15 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
|
||||
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;
|
||||
@@ -72,6 +84,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 +123,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,6 +229,10 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
/*
|
||||
* KKeymap.h - mapping for k-command (C-k prefix) keys to CommandId
|
||||
*/
|
||||
#ifndef KTE_KKEYMAP_H
|
||||
#define KTE_KKEYMAP_H
|
||||
|
||||
#pragma once
|
||||
#include "Command.h"
|
||||
|
||||
|
||||
@@ -30,5 +28,3 @@ KLowerAscii(const int key)
|
||||
return key + ('a' - 'A');
|
||||
return key;
|
||||
}
|
||||
|
||||
#endif // KTE_KKEYMAP_H
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
// LanguageHighlighter.h - interface for line-based highlighters
|
||||
#pragma once
|
||||
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
#include <string>
|
||||
|
||||
#include "Highlight.h"
|
||||
|
||||
class Buffer;
|
||||
|
||||
namespace kte {
|
||||
|
||||
class LanguageHighlighter {
|
||||
public:
|
||||
virtual ~LanguageHighlighter() = default;
|
||||
// Produce highlight spans for a given buffer row. Implementations should append to out.
|
||||
virtual void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const = 0;
|
||||
virtual bool Stateful() const { return false; }
|
||||
};
|
||||
|
||||
// Optional extension for stateful highlighters (e.g., multi-line comments/strings).
|
||||
// Engines may detect and use this via dynamic_cast without breaking stateless impls.
|
||||
class StatefulHighlighter : public LanguageHighlighter {
|
||||
public:
|
||||
struct LineState {
|
||||
bool in_block_comment{false};
|
||||
bool in_raw_string{false};
|
||||
// For raw strings, remember the delimiter between the opening R"delim( and closing )delim"
|
||||
std::string raw_delim;
|
||||
};
|
||||
|
||||
// Highlight one line given the previous line state; return the resulting state after this line.
|
||||
// Implementations should append spans for this line to out and compute the next state.
|
||||
virtual LineState HighlightLineStateful(const Buffer &buf,
|
||||
int row,
|
||||
const LineState &prev,
|
||||
std::vector<HighlightSpan> &out) const = 0;
|
||||
|
||||
bool Stateful() const override { return true; }
|
||||
};
|
||||
|
||||
} // namespace kte
|
||||
@@ -1,41 +0,0 @@
|
||||
#include "LispHighlighter.h"
|
||||
#include "Buffer.h"
|
||||
#include <cctype>
|
||||
|
||||
namespace kte {
|
||||
|
||||
static void push(std::vector<HighlightSpan> &out, int a, int b, TokenKind k){ if (b>a) out.push_back({a,b,k}); }
|
||||
|
||||
LispHighlighter::LispHighlighter()
|
||||
{
|
||||
const char* kw[] = {"defun","lambda","let","let*","define","set!","if","cond","begin","quote","quasiquote","unquote","unquote-splicing","loop","do","and","or","not"};
|
||||
for (auto s: kw) kws_.insert(s);
|
||||
}
|
||||
|
||||
void LispHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
const auto &rows = buf.Rows();
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size()) return;
|
||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
||||
int n = static_cast<int>(s.size());
|
||||
int i = 0;
|
||||
int bol = 0; while (bol<n && (s[bol]==' '||s[bol]=='\t')) ++bol;
|
||||
if (bol < n && s[bol] == ';') { push(out, bol, n, TokenKind::Comment); if (bol>0) push(out,0,bol,TokenKind::Whitespace); return; }
|
||||
while (i < n) {
|
||||
char c = s[i];
|
||||
if (c==' '||c=='\t') { int j=i+1; while (j<n && (s[j]==' '||s[j]=='\t')) ++j; push(out,i,j,TokenKind::Whitespace); i=j; continue; }
|
||||
if (c==';') { push(out,i,n,TokenKind::Comment); break; }
|
||||
if (c=='"') { int j=i+1; bool esc=false; while (j<n){ char d=s[j++]; if (esc){esc=false; continue;} if (d=='\\'){esc=true; continue;} if (d=='"') break; } push(out,i,j,TokenKind::String); i=j; continue; }
|
||||
if (std::isalpha(static_cast<unsigned char>(c)) || c=='*' || c=='-' || c=='+' || c=='/' || c=='_' ) {
|
||||
int j=i+1; while (j<n && (std::isalnum(static_cast<unsigned char>(s[j])) || s[j]=='*' || s[j]=='-' || s[j]=='+' || s[j]=='/' || s[j]=='_' || s[j]=='!')) ++j;
|
||||
std::string id=s.substr(i,j-i);
|
||||
TokenKind k = kws_.count(id) ? TokenKind::Keyword : TokenKind::Identifier;
|
||||
push(out,i,j,k); i=j; continue;
|
||||
}
|
||||
if (std::isdigit(static_cast<unsigned char>(c))) { int j=i+1; while (j<n && (std::isdigit(static_cast<unsigned char>(s[j]))||s[j]=='.')) ++j; push(out,i,j,TokenKind::Number); i=j; continue; }
|
||||
if (std::ispunct(static_cast<unsigned char>(c))) { TokenKind k=TokenKind::Punctuation; push(out,i,i+1,k); ++i; continue; }
|
||||
push(out,i,i+1,TokenKind::Default); ++i;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace kte
|
||||
@@ -1,88 +0,0 @@
|
||||
#include "MarkdownHighlighter.h"
|
||||
#include "Buffer.h"
|
||||
#include <cctype>
|
||||
|
||||
namespace kte {
|
||||
|
||||
static void push_span(std::vector<HighlightSpan> &out, int a, int b, TokenKind k) {
|
||||
if (b > a) out.push_back({a,b,k});
|
||||
}
|
||||
|
||||
void MarkdownHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
LineState st; // not used in stateless entry
|
||||
(void)HighlightLineStateful(buf, row, st, out);
|
||||
}
|
||||
|
||||
StatefulHighlighter::LineState MarkdownHighlighter::HighlightLineStateful(const Buffer &buf, int row, const LineState &prev, std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
StatefulHighlighter::LineState state = prev;
|
||||
const auto &rows = buf.Rows();
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size()) return state;
|
||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
||||
int n = static_cast<int>(s.size());
|
||||
|
||||
// Reuse in_block_comment flag as "in fenced code" state.
|
||||
if (state.in_block_comment) {
|
||||
// If line contains closing fence ``` then close after it
|
||||
auto pos = s.find("```");
|
||||
if (pos == std::string::npos) {
|
||||
push_span(out, 0, n, TokenKind::String);
|
||||
state.in_block_comment = true;
|
||||
return state;
|
||||
} else {
|
||||
int end = static_cast<int>(pos + 3);
|
||||
push_span(out, 0, end, TokenKind::String);
|
||||
// rest of line processed normally after fence
|
||||
int i = end;
|
||||
// whitespace
|
||||
if (i < n) push_span(out, i, n, TokenKind::Default);
|
||||
state.in_block_comment = false;
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
// Detect fenced code block start at beginning (allow leading spaces)
|
||||
int bol = 0; while (bol < n && (s[bol]==' '||s[bol]=='\t')) ++bol;
|
||||
if (bol + 3 <= n && s.compare(bol, 3, "```") == 0) {
|
||||
push_span(out, bol, n, TokenKind::String);
|
||||
state.in_block_comment = true; // enter fenced mode
|
||||
return state;
|
||||
}
|
||||
|
||||
// Headings: lines starting with 1-6 '#'
|
||||
if (bol < n && s[bol] == '#') {
|
||||
int j = bol; while (j < n && s[j] == '#') ++j; // hashes
|
||||
// include following space and text as Keyword to stand out
|
||||
push_span(out, bol, n, TokenKind::Keyword);
|
||||
return state;
|
||||
}
|
||||
|
||||
// Process inline: emphasis and code spans
|
||||
int i = 0;
|
||||
while (i < n) {
|
||||
char c = s[i];
|
||||
if (c == '`') {
|
||||
int j = i + 1; while (j < n && s[j] != '`') ++j; if (j < n) ++j;
|
||||
push_span(out, i, j, TokenKind::String); i = j; continue;
|
||||
}
|
||||
if (c == '*' || c == '_') {
|
||||
// bold/italic markers: treat the marker and until next same marker as Type to highlight
|
||||
char m = c; int j = i + 1; while (j < n && s[j] != m) ++j; if (j < n) ++j;
|
||||
push_span(out, i, j, TokenKind::Type); i = j; continue;
|
||||
}
|
||||
// links []() minimal: treat [text](url) as Function
|
||||
if (c == '[') {
|
||||
int j = i + 1; while (j < n && s[j] != ']') ++j; if (j < n) ++j; // include ]
|
||||
if (j < n && s[j] == '(') { while (j < n && s[j] != ')') ++j; if (j < n) ++j; }
|
||||
push_span(out, i, j, TokenKind::Function); i = j; continue;
|
||||
}
|
||||
// whitespace
|
||||
if (c == ' ' || c == '\t') { int j=i+1; while (j<n && (s[j]==' '||s[j]=='\t')) ++j; push_span(out, i, j, TokenKind::Whitespace); i=j; continue; }
|
||||
// fallback: default single char
|
||||
push_span(out, i, i+1, TokenKind::Default); ++i;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
} // namespace kte
|
||||
@@ -1,14 +0,0 @@
|
||||
// MarkdownHighlighter.h - simple Markdown highlighter
|
||||
#pragma once
|
||||
|
||||
#include "LanguageHighlighter.h"
|
||||
|
||||
namespace kte {
|
||||
|
||||
class MarkdownHighlighter final : public StatefulHighlighter {
|
||||
public:
|
||||
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
|
||||
LineState HighlightLineStateful(const Buffer &buf, int row, const LineState &prev, std::vector<HighlightSpan> &out) const override;
|
||||
};
|
||||
|
||||
} // namespace kte
|
||||
@@ -1,16 +0,0 @@
|
||||
#include "NullHighlighter.h"
|
||||
#include "Buffer.h"
|
||||
|
||||
namespace kte {
|
||||
|
||||
void NullHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
const auto &rows = buf.Rows();
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size()) return;
|
||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
||||
int n = static_cast<int>(s.size());
|
||||
if (n <= 0) return;
|
||||
out.push_back({0, n, TokenKind::Default});
|
||||
}
|
||||
|
||||
} // namespace kte
|
||||
79
OptimizedSearch.cc
Normal file
79
OptimizedSearch.cc
Normal file
@@ -0,0 +1,79 @@
|
||||
#include "OptimizedSearch.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
|
||||
void
|
||||
OptimizedSearch::build_bad_char(const std::string &pattern)
|
||||
{
|
||||
if (pattern == last_pat_)
|
||||
return;
|
||||
last_pat_ = pattern;
|
||||
std::fill(bad_char_.begin(), bad_char_.end(), -1);
|
||||
for (std::size_t i = 0; i < pattern.size(); ++i) {
|
||||
bad_char_[static_cast<unsigned char>(pattern[i])] = static_cast<int>(i);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
std::size_t
|
||||
OptimizedSearch::find_first(const std::string &text, const std::string &pattern, std::size_t start)
|
||||
{
|
||||
const std::size_t n = text.size();
|
||||
const std::size_t m = pattern.size();
|
||||
if (m == 0)
|
||||
return start <= n ? start : std::string::npos;
|
||||
if (m > n || start >= n)
|
||||
return std::string::npos;
|
||||
build_bad_char(pattern);
|
||||
std::size_t s = start;
|
||||
while (s <= n - m) {
|
||||
std::size_t j = m;
|
||||
while (j > 0 && pattern[j - 1] == text[s + j - 1]) {
|
||||
--j;
|
||||
}
|
||||
if (j == 0) {
|
||||
return s; // match found
|
||||
}
|
||||
unsigned char badc = static_cast<unsigned char>(text[s + j - 1]);
|
||||
int bcidx = bad_char_[badc];
|
||||
std::size_t shift = (j - 1 > static_cast<std::size_t>(bcidx))
|
||||
? (j - 1 - static_cast<std::size_t>(bcidx))
|
||||
: 1;
|
||||
s += shift;
|
||||
}
|
||||
return std::string::npos;
|
||||
}
|
||||
|
||||
|
||||
std::vector<std::size_t>
|
||||
OptimizedSearch::find_all(const std::string &text, const std::string &pattern, std::size_t start)
|
||||
{
|
||||
std::vector<std::size_t> res;
|
||||
const std::size_t n = text.size();
|
||||
const std::size_t m = pattern.size();
|
||||
if (m == 0)
|
||||
return res;
|
||||
if (m > n || start >= n)
|
||||
return res;
|
||||
build_bad_char(pattern);
|
||||
std::size_t s = start;
|
||||
while (s <= n - m) {
|
||||
std::size_t j = m;
|
||||
while (j > 0 && pattern[j - 1] == text[s + j - 1]) {
|
||||
--j;
|
||||
}
|
||||
if (j == 0) {
|
||||
res.push_back(s);
|
||||
s += m; // non-overlapping
|
||||
continue;
|
||||
}
|
||||
unsigned char badc = static_cast<unsigned char>(text[s + j - 1]);
|
||||
int bcidx = bad_char_[badc];
|
||||
std::size_t shift = (j - 1 > static_cast<std::size_t>(bcidx))
|
||||
? (j - 1 - static_cast<std::size_t>(bcidx))
|
||||
: 1;
|
||||
s += shift;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
23
OptimizedSearch.h
Normal file
23
OptimizedSearch.h
Normal file
@@ -0,0 +1,23 @@
|
||||
// OptimizedSearch.h - Boyer–Moore (bad character) based substring search
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
class OptimizedSearch {
|
||||
public:
|
||||
OptimizedSearch() = default;
|
||||
|
||||
// Find first occurrence at or after start. Returns npos if not found.
|
||||
std::size_t find_first(const std::string &text, const std::string &pattern, std::size_t start = 0);
|
||||
|
||||
// Find all non-overlapping matches at or after start. Returns starting indices.
|
||||
std::vector<std::size_t> find_all(const std::string &text, const std::string &pattern, std::size_t start = 0);
|
||||
|
||||
private:
|
||||
std::array<int, 256> bad_char_{};
|
||||
std::string last_pat_;
|
||||
|
||||
void build_bad_char(const std::string &pattern);
|
||||
};
|
||||
575
PieceTable.cc
575
PieceTable.cc
@@ -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_ = {};
|
||||
}
|
||||
|
||||
|
||||
@@ -171,6 +221,9 @@ PieceTable::addPieceBack(const Source src, const std::size_t start, const std::s
|
||||
last.len += len;
|
||||
total_size_ += len;
|
||||
dirty_ = true;
|
||||
version_++;
|
||||
range_cache_ = {};
|
||||
find_cache_ = {};
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -179,6 +232,10 @@ 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;
|
||||
InvalidateLineIndex();
|
||||
version_++;
|
||||
range_cache_ = {};
|
||||
find_cache_ = {};
|
||||
}
|
||||
|
||||
|
||||
@@ -197,18 +254,26 @@ PieceTable::addPieceFront(Source src, std::size_t start, std::size_t len)
|
||||
first.len += len;
|
||||
total_size_ += len;
|
||||
dirty_ = true;
|
||||
version_++;
|
||||
range_cache_ = {};
|
||||
find_cache_ = {};
|
||||
return;
|
||||
}
|
||||
}
|
||||
pieces_.insert(pieces_.begin(), Piece{src, start, len});
|
||||
total_size_ += len;
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
133
PieceTable.h
133
PieceTable.h
@@ -1,12 +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
|
||||
*/
|
||||
#ifndef KTE_PIECETABLE_H
|
||||
#define KTE_PIECETABLE_H
|
||||
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <ostream>
|
||||
#include <vector>
|
||||
#include <limits>
|
||||
#include <mutex>
|
||||
|
||||
|
||||
class PieceTable {
|
||||
@@ -15,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);
|
||||
@@ -70,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 };
|
||||
|
||||
@@ -85,6 +159,24 @@ 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_;
|
||||
@@ -92,7 +184,38 @@ private:
|
||||
|
||||
mutable std::string materialized_;
|
||||
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;
|
||||
};
|
||||
|
||||
#endif // KTE_PIECETABLE_H
|
||||
// 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_;
|
||||
};
|
||||
@@ -1,85 +0,0 @@
|
||||
#include "PythonHighlighter.h"
|
||||
#include "Buffer.h"
|
||||
#include <cctype>
|
||||
|
||||
namespace kte {
|
||||
|
||||
static void push(std::vector<HighlightSpan> &out, int a, int b, TokenKind k){ if (b>a) out.push_back({a,b,k}); }
|
||||
static bool is_ident_start(char c){ return std::isalpha(static_cast<unsigned char>(c)) || c=='_'; }
|
||||
static bool is_ident_char(char c){ return std::isalnum(static_cast<unsigned char>(c)) || c=='_'; }
|
||||
|
||||
PythonHighlighter::PythonHighlighter()
|
||||
{
|
||||
const char* kw[] = {"and","as","assert","break","class","continue","def","del","elif","else","except","False","finally","for","from","global","if","import","in","is","lambda","None","nonlocal","not","or","pass","raise","return","True","try","while","with","yield"};
|
||||
for (auto s: kw) kws_.insert(s);
|
||||
}
|
||||
|
||||
void PythonHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
LineState st; (void)HighlightLineStateful(buf, row, st, out);
|
||||
}
|
||||
|
||||
StatefulHighlighter::LineState PythonHighlighter::HighlightLineStateful(const Buffer &buf, int row, const LineState &prev, std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
StatefulHighlighter::LineState state = prev;
|
||||
const auto &rows = buf.Rows();
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size()) return state;
|
||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
||||
int n = static_cast<int>(s.size());
|
||||
|
||||
// Triple-quoted string continuation uses in_raw_string with raw_delim either "'''" or "\"\"\""
|
||||
if (state.in_raw_string && (state.raw_delim == "'''" || state.raw_delim == "\"\"\"")) {
|
||||
auto pos = s.find(state.raw_delim);
|
||||
if (pos == std::string::npos) {
|
||||
push(out, 0, n, TokenKind::String);
|
||||
return state; // still inside
|
||||
} else {
|
||||
int end = static_cast<int>(pos + static_cast<int>(state.raw_delim.size()));
|
||||
push(out, 0, end, TokenKind::String);
|
||||
// remainder processed normally
|
||||
s = s.substr(end);
|
||||
n = static_cast<int>(s.size());
|
||||
state.in_raw_string = false; state.raw_delim.clear();
|
||||
// Continue parsing remainder as a separate small loop
|
||||
int base = end; // original offset, but we already emitted to 'out' with base=0; following spans should be from 'end'
|
||||
// For simplicity, mark rest as Default
|
||||
if (n>0) push(out, base, base + n, TokenKind::Default);
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
int i = 0;
|
||||
// Detect comment start '#', ignoring inside strings
|
||||
while (i < n) {
|
||||
char c = s[i];
|
||||
if (c==' '||c=='\t') { int j=i+1; while (j<n && (s[j]==' '||s[j]=='\t')) ++j; push(out,i,j,TokenKind::Whitespace); i=j; continue; }
|
||||
if (c=='#') { push(out,i,n,TokenKind::Comment); break; }
|
||||
// Strings: triple quotes and single-line
|
||||
if (c=='"' || c=='\'') {
|
||||
char q=c;
|
||||
// triple?
|
||||
if (i+2 < n && s[i+1]==q && s[i+2]==q) {
|
||||
std::string delim(3, q);
|
||||
int j = i+3; // search for closing triple
|
||||
auto pos = s.find(delim, static_cast<std::size_t>(j));
|
||||
if (pos == std::string::npos) {
|
||||
push(out,i,n,TokenKind::String);
|
||||
state.in_raw_string = true; state.raw_delim = delim; return state;
|
||||
} else {
|
||||
int end = static_cast<int>(pos + 3);
|
||||
push(out,i,end,TokenKind::String); i=end; continue;
|
||||
}
|
||||
} else {
|
||||
int j=i+1; bool esc=false; while (j<n) { char d=s[j++]; if (esc){esc=false; continue;} if (d=='\\'){esc=true; continue;} if (d==q) break; }
|
||||
push(out,i,j,TokenKind::String); i=j; continue;
|
||||
}
|
||||
}
|
||||
if (std::isdigit(static_cast<unsigned char>(c))) { int j=i+1; while (j<n && (std::isalnum(static_cast<unsigned char>(s[j]))||s[j]=='.'||s[j]=='_' )) ++j; push(out,i,j,TokenKind::Number); i=j; continue; }
|
||||
if (is_ident_start(c)) { int j=i+1; while (j<n && is_ident_char(s[j])) ++j; std::string id=s.substr(i,j-i); TokenKind k=TokenKind::Identifier; if (kws_.count(id)) k=TokenKind::Keyword; push(out,i,j,k); i=j; continue; }
|
||||
if (std::ispunct(static_cast<unsigned char>(c))) { TokenKind k=TokenKind::Operator; if (c==':'||c==','||c=='('||c==')'||c=='['||c==']') k=TokenKind::Punctuation; push(out,i,i+1,k); ++i; continue; }
|
||||
push(out,i,i+1,TokenKind::Default); ++i;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
} // namespace kte
|
||||
@@ -1,18 +0,0 @@
|
||||
// PythonHighlighter.h - simple Python highlighter with triple-quote state
|
||||
#pragma once
|
||||
|
||||
#include "LanguageHighlighter.h"
|
||||
#include <unordered_set>
|
||||
|
||||
namespace kte {
|
||||
|
||||
class PythonHighlighter final : public StatefulHighlighter {
|
||||
public:
|
||||
PythonHighlighter();
|
||||
void HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const override;
|
||||
LineState HighlightLineStateful(const Buffer &buf, int row, const LineState &prev, std::vector<HighlightSpan> &out) const override;
|
||||
private:
|
||||
std::unordered_set<std::string> kws_;
|
||||
};
|
||||
|
||||
} // namespace kte
|
||||
983
QtFrontend.cc
Normal file
983
QtFrontend.cc
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
36
QtFrontend.h
Normal file
36
QtFrontend.h
Normal file
@@ -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;
|
||||
};
|
||||
536
QtInputHandler.cc
Normal file
536
QtInputHandler.cc
Normal file
@@ -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;
|
||||
}
|
||||
40
QtInputHandler.h
Normal file
40
QtInputHandler.h
Normal file
@@ -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;
|
||||
};
|
||||
76
QtRenderer.cc
Normal file
76
QtRenderer.cc
Normal file
@@ -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();
|
||||
}
|
||||
27
QtRenderer.h
Normal file
27
QtRenderer.h
Normal file
@@ -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
|
||||
};
|
||||
43
README.md
43
README.md
@@ -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
REWRITE.md
Normal file
2502
REWRITE.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,12 @@ ROADMAP / TODO:
|
||||
|
||||
- [x] Search + Replace
|
||||
- [x] Regex search + replace
|
||||
- [ ] The undo system should actually work
|
||||
- [x] Able to mark buffers as read-only
|
||||
- [x] Built-in help text
|
||||
- [x] Shorten paths in the homedir with ~
|
||||
- [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,9 +1,7 @@
|
||||
/*
|
||||
* Renderer.h - rendering abstraction
|
||||
*/
|
||||
#ifndef KTE_RENDERER_H
|
||||
#define KTE_RENDERER_H
|
||||
|
||||
#pragma once
|
||||
|
||||
class Editor;
|
||||
|
||||
@@ -13,5 +11,3 @@ public:
|
||||
|
||||
virtual void Draw(Editor &ed) = 0;
|
||||
};
|
||||
|
||||
#endif // KTE_RENDERER_H
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
#include "RustHighlighter.h"
|
||||
#include "Buffer.h"
|
||||
#include <cctype>
|
||||
|
||||
namespace kte {
|
||||
|
||||
static void push(std::vector<HighlightSpan> &out, int a, int b, TokenKind k){ if (b>a) out.push_back({a,b,k}); }
|
||||
static bool is_ident_start(char c){ return std::isalpha(static_cast<unsigned char>(c)) || c=='_'; }
|
||||
static bool is_ident_char(char c){ return std::isalnum(static_cast<unsigned char>(c)) || c=='_'; }
|
||||
|
||||
RustHighlighter::RustHighlighter()
|
||||
{
|
||||
const char* kw[] = {"as","break","const","continue","crate","else","enum","extern","false","fn","for","if","impl","in","let","loop","match","mod","move","mut","pub","ref","return","self","Self","static","struct","super","trait","true","type","unsafe","use","where","while","dyn","async","await","try"};
|
||||
for (auto s: kw) kws_.insert(s);
|
||||
const char* tp[] = {"u8","u16","u32","u64","u128","usize","i8","i16","i32","i64","i128","isize","f32","f64","bool","char","str"};
|
||||
for (auto s: tp) types_.insert(s);
|
||||
}
|
||||
|
||||
void RustHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
const auto &rows = buf.Rows();
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size()) return;
|
||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
||||
int n = static_cast<int>(s.size());
|
||||
int i = 0;
|
||||
while (i < n) {
|
||||
char c = s[i];
|
||||
if (c==' '||c=='\t') { int j=i+1; while (j<n && (s[j]==' '||s[j]=='\t')) ++j; push(out,i,j,TokenKind::Whitespace); i=j; continue; }
|
||||
if (c=='/' && i+1<n && s[i+1]=='/') { push(out,i,n,TokenKind::Comment); break; }
|
||||
if (c=='/' && i+1<n && s[i+1]=='*') { int j=i+2; bool closed=false; while (j+1<=n) { if (j+1<n && s[j]=='*' && s[j+1]=='/') { j+=2; closed=true; break; } ++j; } if (!closed) { push(out,i,n,TokenKind::Comment); break; } else { push(out,i,j,TokenKind::Comment); i=j; continue; } }
|
||||
if (c=='"') { int j=i+1; bool esc=false; while (j<n){ char d=s[j++]; if (esc){esc=false; continue;} if (d=='\\'){esc=true; continue;} if (d=='"') break; } push(out,i,j,TokenKind::String); i=j; continue; }
|
||||
if (std::isdigit(static_cast<unsigned char>(c))) { int j=i+1; while (j<n && (std::isalnum(static_cast<unsigned char>(s[j]))||s[j]=='.'||s[j]=='_' )) ++j; push(out,i,j,TokenKind::Number); i=j; continue; }
|
||||
if (is_ident_start(c)) { int j=i+1; while (j<n && is_ident_char(s[j])) ++j; std::string id=s.substr(i,j-i); TokenKind k=TokenKind::Identifier; if (kws_.count(id)) k=TokenKind::Keyword; else if (types_.count(id)) k=TokenKind::Type; push(out,i,j,k); i=j; continue; }
|
||||
if (std::ispunct(static_cast<unsigned char>(c))) { TokenKind k=TokenKind::Operator; if (c==';'||c==','||c=='('||c==')'||c=='{'||c=='}'||c=='['||c==']') k=TokenKind::Punctuation; push(out,i,i+1,k); ++i; continue; }
|
||||
push(out,i,i+1,TokenKind::Default); ++i;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace kte
|
||||
@@ -1,43 +0,0 @@
|
||||
#include "ShellHighlighter.h"
|
||||
#include "Buffer.h"
|
||||
#include <cctype>
|
||||
|
||||
namespace kte {
|
||||
|
||||
static void push(std::vector<HighlightSpan> &out, int a, int b, TokenKind k){ if (b>a) out.push_back({a,b,k}); }
|
||||
|
||||
void ShellHighlighter::HighlightLine(const Buffer &buf, int row, std::vector<HighlightSpan> &out) const
|
||||
{
|
||||
const auto &rows = buf.Rows();
|
||||
if (row < 0 || static_cast<std::size_t>(row) >= rows.size()) return;
|
||||
std::string s = static_cast<std::string>(rows[static_cast<std::size_t>(row)]);
|
||||
int n = static_cast<int>(s.size());
|
||||
int i = 0;
|
||||
// if first non-space is '#', whole line is comment
|
||||
int bol = 0; while (bol < n && (s[bol]==' '||s[bol]=='\t')) ++bol;
|
||||
if (bol < n && s[bol] == '#') { push(out, bol, n, TokenKind::Comment); if (bol>0) push(out,0,bol,TokenKind::Whitespace); return; }
|
||||
while (i < n) {
|
||||
char c = s[i];
|
||||
if (c == ' ' || c == '\t') { int j=i+1; while (j<n && (s[j]==' '||s[j]=='\t')) ++j; push(out,i,j,TokenKind::Whitespace); i=j; continue; }
|
||||
if (c == '#') { push(out, i, n, TokenKind::Comment); break; }
|
||||
if (c == '\'' || c == '"') {
|
||||
char q = c; int j = i+1; bool esc=false; while (j<n) { char d=s[j++]; if (q=='"') { if (esc) {esc=false; continue;} if (d=='\\'){esc=true; continue;} if (d=='"') break; } else { if (d=='\'') break; } }
|
||||
push(out,i,j,TokenKind::String); i=j; continue;
|
||||
}
|
||||
// simple keywords
|
||||
if (std::isalpha(static_cast<unsigned char>(c))) {
|
||||
int j=i+1; while (j<n && (std::isalnum(static_cast<unsigned char>(s[j]))||s[j]=='_')) ++j; std::string id=s.substr(i,j-i);
|
||||
static const char* kws[] = {"if","then","fi","for","in","do","done","case","esac","while","function","elif","else"};
|
||||
bool kw=false; for (auto k: kws) if (id==k) { kw=true; break; }
|
||||
push(out,i,j, kw?TokenKind::Keyword:TokenKind::Identifier); i=j; continue;
|
||||
}
|
||||
if (std::ispunct(static_cast<unsigned char>(c))) {
|
||||
TokenKind k = TokenKind::Operator;
|
||||
if (c=='('||c==')'||c=='{'||c=='}'||c==','||c==';') k=TokenKind::Punctuation;
|
||||
push(out,i,i+1,k); ++i; continue;
|
||||
}
|
||||
push(out,i,i+1,TokenKind::Default); ++i;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace kte
|
||||
253
Swap.h
Normal file
253
Swap.h
Normal file
@@ -0,0 +1,253 @@
|
||||
// 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);
|
||||
|
||||
// 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
|
||||
19
SwapRecorder.h
Normal file
19
SwapRecorder.h
Normal file
@@ -0,0 +1,19 @@
|
||||
// SwapRecorder.h - minimal swap journal recording interface for Buffer mutations
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <string_view>
|
||||
|
||||
namespace kte {
|
||||
// SwapRecorder is a tiny, non-blocking callback interface.
|
||||
// Implementations must return quickly; Buffer calls these hooks after a
|
||||
// mutation succeeds.
|
||||
class SwapRecorder {
|
||||
public:
|
||||
virtual ~SwapRecorder() = default;
|
||||
|
||||
virtual void OnInsert(int row, int col, std::string_view bytes) = 0;
|
||||
|
||||
virtual void OnDelete(int row, int col, std::size_t len) = 0;
|
||||
};
|
||||
} // namespace kte
|
||||
76
SyscallWrappers.cc
Normal file
76
SyscallWrappers.cc
Normal file
@@ -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
|
||||
47
SyscallWrappers.h
Normal file
47
SyscallWrappers.h
Normal file
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
/*
|
||||
* TerminalFrontend - couples TerminalInputHandler + TerminalRenderer and owns ncurses lifecycle
|
||||
*/
|
||||
#ifndef KTE_TERMINAL_FRONTEND_H
|
||||
#define KTE_TERMINAL_FRONTEND_H
|
||||
|
||||
#pragma once
|
||||
#include <termios.h>
|
||||
#include <signal.h>
|
||||
|
||||
#include "Frontend.h"
|
||||
#include "TerminalInputHandler.h"
|
||||
@@ -17,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;
|
||||
|
||||
@@ -31,6 +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_{};
|
||||
};
|
||||
|
||||
#endif // KTE_TERMINAL_FRONTEND_H
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
#include "TerminalInputHandler.h"
|
||||
#include "KKeymap.h"
|
||||
#include "Command.h"
|
||||
#include "Editor.h"
|
||||
|
||||
namespace {
|
||||
constexpr int
|
||||
@@ -21,74 +23,127 @@ static bool
|
||||
map_key_to_command(const int ch,
|
||||
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
|
||||
// These keys exit k-prefix mode if active (user pressed C-k then a special key).
|
||||
switch (ch) {
|
||||
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 → map to MoveUp/MoveDown one line per wheel notch
|
||||
// Mouse wheel → scroll viewport without moving cursor
|
||||
#ifdef BUTTON4_PRESSED
|
||||
if (ev.bstate & (BUTTON4_PRESSED | BUTTON4_RELEASED | BUTTON4_CLICKED)) {
|
||||
out = {true, CommandId::MoveUp, "", 0};
|
||||
out = {true, CommandId::ScrollUp, "", 0};
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
#ifdef BUTTON5_PRESSED
|
||||
if (ev.bstate & (BUTTON5_PRESSED | BUTTON5_RELEASED | BUTTON5_CLICKED)) {
|
||||
out = {true, CommandId::MoveDown, "", 0};
|
||||
out = {true, CommandId::ScrollDown, "", 0};
|
||||
return true;
|
||||
}
|
||||
#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};
|
||||
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
|
||||
out.hasCommand = false;
|
||||
return true;
|
||||
}
|
||||
case KEY_LEFT:
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveLeft, "", 0};
|
||||
return true;
|
||||
case KEY_RIGHT:
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveRight, "", 0};
|
||||
return true;
|
||||
case KEY_UP:
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveUp, "", 0};
|
||||
return true;
|
||||
case KEY_DOWN:
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveDown, "", 0};
|
||||
return true;
|
||||
case KEY_HOME:
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveHome, "", 0};
|
||||
return true;
|
||||
case KEY_END:
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::MoveEnd, "", 0};
|
||||
return true;
|
||||
case KEY_PPAGE:
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::PageUp, "", 0};
|
||||
return true;
|
||||
case KEY_NPAGE:
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::PageDown, "", 0};
|
||||
return true;
|
||||
case KEY_DC:
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::DeleteChar, "", 0};
|
||||
return true;
|
||||
case KEY_RESIZE:
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::Refresh, "", 0};
|
||||
return true;
|
||||
default:
|
||||
@@ -99,6 +154,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;
|
||||
@@ -108,58 +164,32 @@ map_key_to_command(const int ch,
|
||||
if (ch == CTRL('K')) {
|
||||
// C-k prefix
|
||||
k_prefix = true;
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::KPrefix, "", 0};
|
||||
return true;
|
||||
}
|
||||
if (ch == CTRL('G')) {
|
||||
// cancel
|
||||
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";
|
||||
@@ -170,22 +200,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;
|
||||
}
|
||||
@@ -202,6 +250,7 @@ map_key_to_command(const int ch,
|
||||
// Enter
|
||||
if (ch == '\n' || ch == '\r') {
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::Newline, "", 0};
|
||||
return true;
|
||||
}
|
||||
@@ -212,6 +261,12 @@ map_key_to_command(const int ch,
|
||||
// Handle ESC + BACKSPACE (meta-backspace, Alt-Backspace)
|
||||
if (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';
|
||||
}
|
||||
@@ -220,49 +275,27 @@ 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;
|
||||
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};
|
||||
// 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// Printable ASCII
|
||||
if (ch >= 0x20 && ch <= 0x7E) {
|
||||
@@ -288,29 +321,12 @@ TerminalInputHandler::decode_(MappedInput &out)
|
||||
bool consumed = map_key_to_command(
|
||||
ch,
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
/*
|
||||
* TerminalInputHandler - ncurses-based input handling for terminal mode
|
||||
*/
|
||||
#ifndef KTE_TERMINAL_INPUT_HANDLER_H
|
||||
#define KTE_TERMINAL_INPUT_HANDLER_H
|
||||
|
||||
#pragma once
|
||||
#include "InputHandler.h"
|
||||
|
||||
|
||||
@@ -13,6 +11,13 @@ public:
|
||||
|
||||
~TerminalInputHandler() override;
|
||||
|
||||
|
||||
void Attach(Editor *ed) override
|
||||
{
|
||||
ed_ = ed;
|
||||
}
|
||||
|
||||
|
||||
bool Poll(MappedInput &out) override;
|
||||
|
||||
private:
|
||||
@@ -20,16 +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;
|
||||
|
||||
#endif // KTE_TERMINAL_INPUT_HANDLER_H
|
||||
Editor *ed_ = nullptr; // attached editor for uarg handling
|
||||
};
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
#include <clocale>
|
||||
#define _XOPEN_SOURCE_EXTENDED 1
|
||||
#include <cwchar>
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
@@ -34,6 +37,8 @@ TerminalRenderer::Draw(Editor &ed)
|
||||
|
||||
const Buffer *buf = ed.CurrentBuffer();
|
||||
int content_rows = rows - 1; // last line is status
|
||||
if (content_rows < 1)
|
||||
content_rows = 1;
|
||||
|
||||
int saved_cur_y = -1, saved_cur_x = -1; // logical cursor position within content area
|
||||
if (buf) {
|
||||
@@ -42,6 +47,13 @@ TerminalRenderer::Draw(Editor &ed)
|
||||
std::size_t coloffs = buf->Coloffs();
|
||||
|
||||
const int tabw = 8;
|
||||
// Phase 3: prefetch visible viewport highlights (current terminal area)
|
||||
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
|
||||
int fr = static_cast<int>(rowoffs);
|
||||
int rc = std::max(0, content_rows);
|
||||
buf->Highlighter()->PrefetchViewport(*buf, fr, rc, buf->Version());
|
||||
}
|
||||
|
||||
for (int r = 0; r < content_rows; ++r) {
|
||||
move(r, 0);
|
||||
std::size_t li = rowoffs + static_cast<std::size_t>(r);
|
||||
@@ -95,59 +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;
|
||||
|
||||
// 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]);
|
||||
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
|
||||
const kte::LineHighlight *lh_ptr = nullptr;
|
||||
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
|
||||
lh_ptr = &buf->Highlighter()->GetLine(*buf, static_cast<int>(li), buf->Version());
|
||||
// Syntax highlighting: fetch per-line spans (sanitized copy)
|
||||
std::vector<kte::HighlightSpan> sane_spans;
|
||||
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->
|
||||
HasHighlighter()) {
|
||||
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) 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)
|
||||
if (sane_spans.empty())
|
||||
return kte::TokenKind::Default;
|
||||
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
|
||||
@@ -160,107 +281,144 @@ TerminalRenderer::Draw(Editor &ed)
|
||||
}
|
||||
// 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;
|
||||
&&
|
||||
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)
|
||||
attr |= A_STANDOUT;
|
||||
a |= 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));
|
||||
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
|
||||
// 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 rx = buf->Rx(); // render x computed by command layer
|
||||
std::size_t cx = buf->Curx();
|
||||
int cur_y = static_cast<int>(cy) - static_cast<int>(buf->Rowoffs());
|
||||
int cur_x = static_cast<int>(rx) - static_cast<int>(buf->Coloffs());
|
||||
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;
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
/*
|
||||
* TerminalRenderer - ncurses-based renderer for terminal mode
|
||||
*/
|
||||
#ifndef KTE_TERMINAL_RENDERER_H
|
||||
#define KTE_TERMINAL_RENDERER_H
|
||||
|
||||
#pragma once
|
||||
#include "Renderer.h"
|
||||
|
||||
|
||||
@@ -15,5 +13,3 @@ public:
|
||||
|
||||
void Draw(Editor &ed) override;
|
||||
};
|
||||
|
||||
#endif // KTE_TERMINAL_RENDERER_H
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
/*
|
||||
* TestFrontend.h - headless frontend for testing with programmable input
|
||||
*/
|
||||
#ifndef KTE_TEST_FRONTEND_H
|
||||
#define KTE_TEST_FRONTEND_H
|
||||
|
||||
#pragma once
|
||||
#include "Frontend.h"
|
||||
#include "TestInputHandler.h"
|
||||
#include "TestRenderer.h"
|
||||
@@ -15,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;
|
||||
|
||||
@@ -37,5 +35,3 @@ private:
|
||||
TestInputHandler input_{};
|
||||
TestRenderer renderer_{};
|
||||
};
|
||||
|
||||
#endif // KTE_TEST_FRONTEND_H
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
/*
|
||||
* TestInputHandler.h - programmable input handler for testing
|
||||
*/
|
||||
#ifndef KTE_TEST_INPUT_HANDLER_H
|
||||
#define KTE_TEST_INPUT_HANDLER_H
|
||||
|
||||
#pragma once
|
||||
#include <queue>
|
||||
|
||||
#include "InputHandler.h"
|
||||
@@ -30,5 +28,3 @@ public:
|
||||
private:
|
||||
std::queue<MappedInput> queue_;
|
||||
};
|
||||
|
||||
#endif // KTE_TEST_INPUT_HANDLER_H
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
/*
|
||||
* TestRenderer.h - minimal renderer for testing (no actual display)
|
||||
*/
|
||||
#ifndef KTE_TEST_RENDERER_H
|
||||
#define KTE_TEST_RENDERER_H
|
||||
|
||||
#pragma once
|
||||
#include <cstddef>
|
||||
|
||||
#include "Renderer.h"
|
||||
@@ -32,5 +30,3 @@ public:
|
||||
private:
|
||||
std::size_t draw_count_ = 0;
|
||||
};
|
||||
|
||||
#endif // KTE_TEST_RENDERER_H
|
||||
|
||||
10
UndoNode.h
10
UndoNode.h
@@ -1,6 +1,4 @@
|
||||
#ifndef KTE_UNDONODE_H
|
||||
#define KTE_UNDONODE_H
|
||||
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
@@ -11,16 +9,16 @@ enum class UndoType : std::uint8_t {
|
||||
Paste,
|
||||
Newline,
|
||||
DeleteRow,
|
||||
InsertRow,
|
||||
};
|
||||
|
||||
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 *parent = nullptr; // previous state; null means pre-first-edit
|
||||
UndoNode *child = nullptr; // next in current timeline
|
||||
UndoNode *next = nullptr; // redo branch
|
||||
};
|
||||
|
||||
|
||||
#endif // KTE_UNDONODE_H
|
||||
|
||||
63
UndoNodePool.h
Normal file
63
UndoNodePool.h
Normal file
@@ -0,0 +1,63 @@
|
||||
#pragma once
|
||||
#include <stack>
|
||||
#include <vector>
|
||||
#include <memory>
|
||||
#include "UndoNode.h"
|
||||
|
||||
// Pool allocator for UndoNode to eliminate frequent malloc/free.
|
||||
// Uses fixed-size blocks to keep node addresses stable.
|
||||
class UndoNodePool {
|
||||
public:
|
||||
explicit UndoNodePool(std::size_t block_size = 64)
|
||||
: block_size_(block_size) {}
|
||||
|
||||
|
||||
UndoNode *acquire()
|
||||
{
|
||||
if (available_.empty())
|
||||
allocate_block();
|
||||
auto *node = available_.top();
|
||||
available_.pop();
|
||||
// Node comes zeroed; ensure links are reset
|
||||
node->text.clear();
|
||||
node->parent = nullptr;
|
||||
node->child = nullptr;
|
||||
node->next = nullptr;
|
||||
node->row = node->col = 0;
|
||||
node->type = UndoType{};
|
||||
return node;
|
||||
}
|
||||
|
||||
|
||||
void release(UndoNode *node)
|
||||
{
|
||||
if (!node)
|
||||
return;
|
||||
// Clear heavy fields to free memory held by strings
|
||||
node->text.clear();
|
||||
node->parent = nullptr;
|
||||
node->child = nullptr;
|
||||
node->next = nullptr;
|
||||
node->row = node->col = 0;
|
||||
node->type = UndoType{};
|
||||
available_.push(node);
|
||||
}
|
||||
|
||||
private:
|
||||
void allocate_block()
|
||||
{
|
||||
// allocate a new block; keep ownership so memory stays valid
|
||||
std::unique_ptr<UndoNode[]> block(new UndoNode[block_size_]);
|
||||
UndoNode *base = block.get();
|
||||
blocks_.push_back(std::move(block));
|
||||
for (std::size_t i = 0; i < block_size_; ++i) {
|
||||
// ensure the node is reset; rely on default constructor/zero init
|
||||
available_.push(&base[i]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
std::size_t block_size_;
|
||||
std::vector<std::unique_ptr<UndoNode[]> > blocks_;
|
||||
std::stack<UndoNode *> available_;
|
||||
};
|
||||
286
UndoSystem.cc
286
UndoSystem.cc
@@ -8,60 +8,93 @@ 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)
|
||||
{
|
||||
#ifdef KTE_UNDO_DEBUG
|
||||
debug_log("Begin");
|
||||
#endif
|
||||
// Reuse pending if batching conditions are met
|
||||
if (!buf_)
|
||||
return;
|
||||
const int row = static_cast<int>(buf_->Cury());
|
||||
const int col = static_cast<int>(buf_->Curx());
|
||||
if (tree_.pending && tree_.pending->type == type && tree_.pending->row == row) {
|
||||
if (type == UndoType::Delete) {
|
||||
// Support batching both forward deletes (DeleteChar) and backspace (prepend case)
|
||||
// Forward delete: cursor stays at anchor col; keep batching when col == anchor
|
||||
const auto anchor = static_cast<std::size_t>(tree_.pending->col);
|
||||
if (anchor == static_cast<std::size_t>(col)) {
|
||||
pending_prepend_ = false;
|
||||
return; // keep batching forward delete
|
||||
|
||||
// Some operations should always be standalone undo steps.
|
||||
const bool always_standalone = (type == UndoType::Newline || type == UndoType::DeleteRow || type ==
|
||||
UndoType::InsertRow);
|
||||
if (always_standalone) {
|
||||
commit();
|
||||
}
|
||||
// Backspace: cursor moved left by exactly one position relative to current anchor.
|
||||
// Extend batch by shifting anchor left and prepending the deleted byte.
|
||||
if (static_cast<std::size_t>(col) + 1 == anchor) {
|
||||
tree_.pending->col = col;
|
||||
pending_prepend_ = true;
|
||||
|
||||
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;
|
||||
}
|
||||
} else {
|
||||
std::size_t expected = static_cast<std::size_t>(tree_.pending->col) + tree_.pending->text.
|
||||
size();
|
||||
if (expected == static_cast<std::size_t>(col)) {
|
||||
pending_prepend_ = false;
|
||||
return; // keep batching
|
||||
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;
|
||||
}
|
||||
// Otherwise commit any existing batch and start a new node
|
||||
case UndoType::Newline:
|
||||
case UndoType::DeleteRow:
|
||||
case UndoType::InsertRow:
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Can't coalesce: seal the previous pending step.
|
||||
commit();
|
||||
auto *node = new UndoNode();
|
||||
node->type = type;
|
||||
node->row = row;
|
||||
node->col = col;
|
||||
node->child = nullptr;
|
||||
node->next = nullptr;
|
||||
tree_.pending = node;
|
||||
pending_prepend_ = false;
|
||||
}
|
||||
|
||||
#ifdef KTE_UNDO_DEBUG
|
||||
debug_log("Begin:new");
|
||||
#endif
|
||||
// Assert pending is detached from the tree
|
||||
assert(tree_.pending && "pending must exist after Begin");
|
||||
assert(tree_.pending != tree_.root);
|
||||
assert(tree_.pending != tree_.current);
|
||||
assert(tree_.pending != tree_.saved);
|
||||
assert(!is_descendant(tree_.root, tree_.pending));
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -70,15 +103,11 @@ UndoSystem::Append(char ch)
|
||||
{
|
||||
if (!tree_.pending)
|
||||
return;
|
||||
if (pending_prepend_ && tree_.pending->type == UndoType::Delete) {
|
||||
// Prepend for backspace so that text is in increasing column order
|
||||
if (pending_mode_ == PendingAppendMode::Prepend) {
|
||||
tree_.pending->text.insert(tree_.pending->text.begin(), ch);
|
||||
} else {
|
||||
tree_.pending->text.push_back(ch);
|
||||
}
|
||||
#ifdef KTE_UNDO_DEBUG
|
||||
debug_log("Append:ch");
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -87,108 +116,131 @@ UndoSystem::Append(std::string_view 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());
|
||||
#ifdef KTE_UNDO_DEBUG
|
||||
debug_log("Append:sv");
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
UndoSystem::commit()
|
||||
{
|
||||
#ifdef KTE_UNDO_DEBUG
|
||||
debug_log("commit:enter");
|
||||
#endif
|
||||
if (!tree_.pending)
|
||||
return;
|
||||
|
||||
// If we have redo branches from current, discard them (non-linear behavior)
|
||||
if (tree_.current && tree_.current->child) {
|
||||
free_node(tree_.current->child);
|
||||
tree_.current->child = nullptr;
|
||||
// We diverged; saved snapshot cannot be on discarded branch anymore
|
||||
if (tree_.saved) {
|
||||
// If saved is not equal to current, keep it; if it was on discarded branch we cannot easily detect now.
|
||||
// For simplicity, leave saved as-is; dirty flag uses pointer equality.
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Attach pending as next state
|
||||
if (!tree_.root) {
|
||||
tree_.root = tree_.pending;
|
||||
tree_.pending->parent = nullptr;
|
||||
tree_.current = tree_.pending;
|
||||
} else if (!tree_.current) {
|
||||
// Should not happen if root exists, but handle gracefully
|
||||
// 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 {
|
||||
// Attach as primary child (head of redo list)
|
||||
tree_.pending->next = nullptr;
|
||||
// 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();
|
||||
#ifdef KTE_UNDO_DEBUG
|
||||
debug_log("commit:done");
|
||||
#endif
|
||||
// post-conditions
|
||||
assert(tree_.pending == nullptr && "pending must be cleared after commit");
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
UndoSystem::undo()
|
||||
{
|
||||
// Close any pending batch
|
||||
// Seal any in-progress typed run before undo.
|
||||
commit();
|
||||
if (!tree_.current)
|
||||
return;
|
||||
UndoNode *parent = find_parent(tree_.root, tree_.current);
|
||||
UndoNode *node = tree_.current;
|
||||
// Apply inverse of current node
|
||||
apply(node, -1);
|
||||
tree_.current = parent;
|
||||
update_dirty_flag();
|
||||
#ifdef KTE_UNDO_DEBUG
|
||||
debug_log("undo");
|
||||
#endif
|
||||
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)
|
||||
{
|
||||
// Redo next child along current timeline
|
||||
if (tree_.pending) {
|
||||
// If app added pending edits, finalize them before redo chain
|
||||
commit();
|
||||
}
|
||||
UndoNode *next = nullptr;
|
||||
UndoNode **head = nullptr;
|
||||
if (!tree_.current) {
|
||||
next = tree_.root; // if nothing yet, try applying first node
|
||||
head = &tree_.root;
|
||||
} else {
|
||||
next = tree_.current->child;
|
||||
head = &tree_.current->child;
|
||||
}
|
||||
if (!next)
|
||||
if (!head || !*head)
|
||||
return;
|
||||
apply(next, +1);
|
||||
tree_.current = next;
|
||||
update_dirty_flag();
|
||||
#ifdef KTE_UNDO_DEBUG
|
||||
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");
|
||||
#endif
|
||||
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()
|
||||
{
|
||||
commit();
|
||||
tree_.saved = tree_.current;
|
||||
update_dirty_flag();
|
||||
#ifdef KTE_UNDO_DEBUG
|
||||
debug_log("mark_saved");
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -199,26 +251,21 @@ UndoSystem::discard_pending()
|
||||
delete tree_.pending;
|
||||
tree_.pending = nullptr;
|
||||
}
|
||||
#ifdef KTE_UNDO_DEBUG
|
||||
debug_log("discard_pending");
|
||||
#endif
|
||||
pending_mode_ = PendingAppendMode::Append;
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
UndoSystem::clear()
|
||||
{
|
||||
if (tree_.root) {
|
||||
discard_pending();
|
||||
free_node(tree_.root);
|
||||
}
|
||||
if (tree_.pending) {
|
||||
delete tree_.pending;
|
||||
}
|
||||
tree_.root = tree_.current = tree_.saved = tree_.pending = nullptr;
|
||||
tree_.root = nullptr;
|
||||
tree_.current = nullptr;
|
||||
tree_.saved = nullptr;
|
||||
active_group_id_ = 0;
|
||||
next_group_id_ = 1;
|
||||
update_dirty_flag();
|
||||
#ifdef KTE_UNDO_DEBUG
|
||||
debug_log("clear");
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
@@ -227,34 +274,55 @@ 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;
|
||||
}
|
||||
@@ -354,6 +422,8 @@ UndoSystem::type_str(UndoType t)
|
||||
return "Newline";
|
||||
case UndoType::DeleteRow:
|
||||
return "DeleteRow";
|
||||
case UndoType::InsertRow:
|
||||
return "InsertRow";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
78
UndoSystem.h
78
UndoSystem.h
@@ -1,6 +1,45 @@
|
||||
#ifndef KTE_UNDOSYSTEM_H
|
||||
#define KTE_UNDOSYSTEM_H
|
||||
|
||||
/*
|
||||
* 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>
|
||||
#include <cstdint>
|
||||
@@ -14,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);
|
||||
@@ -24,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();
|
||||
|
||||
@@ -34,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);
|
||||
|
||||
@@ -50,10 +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_;
|
||||
// Internal hint for Delete batching: whether next Append() should prepend
|
||||
bool pending_prepend_ = false;
|
||||
};
|
||||
|
||||
#endif // KTE_UNDOSYSTEM_H
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
#ifndef KTE_UNDOTREE_H
|
||||
#define KTE_UNDOTREE_H
|
||||
|
||||
#pragma once
|
||||
#include "UndoNode.h"
|
||||
|
||||
|
||||
@@ -10,6 +8,3 @@ struct UndoTree {
|
||||
UndoNode *saved = nullptr; // points to node matching last save (for dirty flag)
|
||||
UndoNode *pending = nullptr; // in-progress batch (detached)
|
||||
};
|
||||
|
||||
|
||||
#endif // KTE_UNDOTREE_H
|
||||
|
||||
@@ -24,5 +24,8 @@
|
||||
<string>10.13</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<!-- Allow running multiple instances of the app -->
|
||||
<key>LSMultipleInstancesProhibited</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
78
cmake/fix_bundle.cmake
Normal file
78
cmake/fix_bundle.cmake
Normal file
@@ -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}")
|
||||
75
default.nix
75
default.nix
@@ -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/
|
||||
${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
|
||||
cp ../kge.png $out/share/icons/
|
||||
''
|
||||
+ ''
|
||||
|
||||
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";
|
||||
};
|
||||
}
|
||||
|
||||
28
docker-build.sh
Executable file
28
docker-build.sh
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
# Helper script to test Linux builds using Docker/Podman
|
||||
# This script mounts the current source tree into a Linux container,
|
||||
# builds kte in terminal-only mode, and runs the test suite.
|
||||
|
||||
set -e
|
||||
|
||||
# Detect whether to use docker or podman
|
||||
if command -v docker &> /dev/null; then
|
||||
CONTAINER_CMD="docker"
|
||||
elif command -v podman &> /dev/null; then
|
||||
CONTAINER_CMD="podman"
|
||||
else
|
||||
echo "Error: Neither docker nor podman found in PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
IMAGE_NAME="kte-linux"
|
||||
|
||||
# Check if image exists, if not, build it
|
||||
if ! $CONTAINER_CMD image inspect "$IMAGE_NAME" &> /dev/null; then
|
||||
echo "Building $IMAGE_NAME image..."
|
||||
$CONTAINER_CMD build -t "$IMAGE_NAME" .
|
||||
fi
|
||||
|
||||
# Run the container with the current directory mounted
|
||||
echo "Running Linux build and tests..."
|
||||
$CONTAINER_CMD run --rm -v "$(pwd):/kte" "$IMAGE_NAME"
|
||||
245
docs/BENCHMARKS.md
Normal file
245
docs/BENCHMARKS.md
Normal file
@@ -0,0 +1,245 @@
|
||||
# kte Benchmarking and Testing Guide
|
||||
|
||||
This document describes the benchmarking infrastructure and testing
|
||||
improvements added to ensure high performance and correctness of core
|
||||
operations.
|
||||
|
||||
## Overview
|
||||
|
||||
The kte test suite now includes comprehensive benchmarks and migration
|
||||
coverage tests to:
|
||||
|
||||
- Measure performance of core operations (PieceTable, Buffer, syntax
|
||||
highlighting)
|
||||
- Ensure no performance regressions from refactorings
|
||||
- Validate correctness of API migrations (Buffer::Rows() →
|
||||
GetLineString/GetLineView)
|
||||
- Provide performance baselines for future optimizations
|
||||
|
||||
## Running Tests
|
||||
|
||||
### All Tests (including benchmarks)
|
||||
|
||||
```bash
|
||||
cmake --build cmake-build-debug --target kte_tests && ./cmake-build-debug/kte_tests
|
||||
```
|
||||
|
||||
### Test Organization
|
||||
|
||||
- **58 existing tests**: Core functionality, undo/redo, swap recovery,
|
||||
search, etc.
|
||||
- **15 benchmark tests**: Performance measurements for critical
|
||||
operations
|
||||
- **30 migration coverage tests**: Edge cases and correctness validation
|
||||
|
||||
Total: **98 tests**
|
||||
|
||||
## Benchmark Results
|
||||
|
||||
### Buffer Iteration Patterns (5,000 lines)
|
||||
|
||||
| Pattern | Time | Speedup vs Rows() |
|
||||
|-----------------------------------------|---------|-------------------|
|
||||
| `Rows()` + iteration | 3.1 ms | 1.0x (baseline) |
|
||||
| `Nrows()` + `GetLineString()` | 1.9 ms | **1.7x faster** |
|
||||
| `Nrows()` + `GetLineView()` (zero-copy) | 0.28 ms | **11x faster** |
|
||||
|
||||
**Key Insight**: `GetLineView()` provides zero-copy access and is
|
||||
dramatically faster than materializing the entire rows cache.
|
||||
|
||||
### PieceTable Operations (10,000 lines)
|
||||
|
||||
| Operation | Time |
|
||||
|-----------------------------|---------|
|
||||
| Sequential inserts (10K) | 2.1 ms |
|
||||
| Random inserts (5K) | 32.9 ms |
|
||||
| `GetLine()` sequential | 4.7 ms |
|
||||
| `GetLineRange()` sequential | 1.3 ms |
|
||||
|
||||
### Buffer Operations
|
||||
|
||||
| Operation | Time |
|
||||
|--------------------------------------|---------|
|
||||
| `Nrows()` (1M calls) | 13.0 ms |
|
||||
| `GetLineString()` (10K lines) | 4.8 ms |
|
||||
| `GetLineView()` (10K lines) | 1.6 ms |
|
||||
| `Rows()` materialization (10K lines) | 6.2 ms |
|
||||
|
||||
### Syntax Highlighting
|
||||
|
||||
| Operation | Time | Notes |
|
||||
|------------------------------------|---------|----------------|
|
||||
| C++ highlighting (~1000 lines) | 2.0 ms | First pass |
|
||||
| HighlighterEngine cache population | 19.9 ms | |
|
||||
| HighlighterEngine cache hits | 0.52 ms | **38x faster** |
|
||||
|
||||
### Large File Performance
|
||||
|
||||
| Operation | Time |
|
||||
|---------------------------------|---------|
|
||||
| Insert 50K lines | 0.53 ms |
|
||||
| Iterate 50K lines (GetLineView) | 2.7 ms |
|
||||
| Random access (10K accesses) | 1.8 ms |
|
||||
|
||||
## API Differences: GetLineString vs GetLineView
|
||||
|
||||
Understanding the difference between these APIs is critical:
|
||||
|
||||
### `GetLineString(row)`
|
||||
|
||||
- Returns: `std::string` (copy)
|
||||
- Content: Line text **without** trailing newline
|
||||
- Use case: When you need to modify the string or store it
|
||||
- Example: `"hello"` for line `"hello\n"`
|
||||
|
||||
### `GetLineView(row)`
|
||||
|
||||
- Returns: `std::string_view` (zero-copy)
|
||||
- Content: Raw line range **including** trailing newline
|
||||
- Use case: Read-only access, maximum performance
|
||||
- Example: `"hello\n"` for line `"hello\n"`
|
||||
- **Warning**: View becomes invalid after buffer modifications
|
||||
|
||||
### `Rows()`
|
||||
|
||||
- Returns: `std::vector<Buffer::Line>&` (materialized cache)
|
||||
- Content: Lines **without** trailing newlines
|
||||
- Use case: Legacy code, being phased out
|
||||
- Performance: Slower due to materialization overhead
|
||||
|
||||
## Migration Coverage Tests
|
||||
|
||||
The `test_migration_coverage.cc` file provides 30 tests covering:
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Empty buffers
|
||||
- Single lines (with/without newlines)
|
||||
- Very long lines (10,000 characters)
|
||||
- Many empty lines (1,000 newlines)
|
||||
|
||||
### Consistency
|
||||
|
||||
- `GetLineString()` vs `GetLineView()` vs `Rows()`
|
||||
- Consistency after edits (insert, delete, split, join)
|
||||
|
||||
### Boundary Conditions
|
||||
|
||||
- First line access
|
||||
- Last line access
|
||||
- Line range boundaries
|
||||
|
||||
### Special Characters
|
||||
|
||||
- Tabs, carriage returns, null bytes
|
||||
- Unicode (UTF-8 multibyte characters)
|
||||
|
||||
### Stress Tests
|
||||
|
||||
- Large files (10,000 lines)
|
||||
- Many small operations (100+ inserts)
|
||||
- Alternating insert/delete patterns
|
||||
|
||||
### Regression Tests
|
||||
|
||||
- Shebang detection pattern (Editor.cc)
|
||||
- Empty buffer check pattern (Editor.cc)
|
||||
- Syntax highlighter pattern (all highlighters)
|
||||
- Swap snapshot pattern (Swap.cc)
|
||||
|
||||
## Performance Recommendations
|
||||
|
||||
Based on benchmark results:
|
||||
|
||||
1. **Prefer `GetLineView()` for read-only access**
|
||||
- 11x faster than `Rows()` for iteration
|
||||
- Zero-copy, minimal overhead
|
||||
- Use immediately (view invalidates on edit)
|
||||
|
||||
2. **Use `GetLineString()` when you need a copy**
|
||||
- Still 1.7x faster than `Rows()`
|
||||
- Safe to store and modify
|
||||
- Strips trailing newlines automatically
|
||||
|
||||
3. **Avoid `Rows()` in hot paths**
|
||||
- Materializes entire line cache
|
||||
- Slower for large files
|
||||
- Being phased out (legacy API)
|
||||
|
||||
4. **Cache `Nrows()` in tight loops**
|
||||
- Very fast (13ms for 1M calls)
|
||||
- But still worth caching in inner loops
|
||||
|
||||
5. **Leverage HighlighterEngine caching**
|
||||
- 38x speedup on cache hits
|
||||
- Automatically invalidates on edits
|
||||
- Prefetch viewport for smooth scrolling
|
||||
|
||||
## Adding New Benchmarks
|
||||
|
||||
To add a new benchmark:
|
||||
|
||||
1. Add a `TEST(Benchmark_YourName)` in `tests/test_benchmarks.cc`
|
||||
2. Use `BenchmarkTimer` to measure critical sections:
|
||||
```cpp
|
||||
{
|
||||
BenchmarkTimer timer("Operation description");
|
||||
// ... code to benchmark ...
|
||||
}
|
||||
```
|
||||
3. Print section headers with `std::cout` for clarity
|
||||
4. Use `ASSERT_EQ` or `EXPECT_TRUE` to validate results
|
||||
|
||||
Example:
|
||||
|
||||
```cpp
|
||||
TEST(Benchmark_MyOperation) {
|
||||
std::cout << "\n=== My Operation Benchmark ===\n";
|
||||
|
||||
// Setup
|
||||
Buffer buf;
|
||||
std::string data = generate_test_data();
|
||||
buf.insert_text(0, 0, data);
|
||||
|
||||
std::size_t result = 0;
|
||||
{
|
||||
BenchmarkTimer timer("My operation on 10K lines");
|
||||
for (std::size_t i = 0; i < buf.Nrows(); ++i) {
|
||||
result += my_operation(buf, i);
|
||||
}
|
||||
}
|
||||
|
||||
EXPECT_TRUE(result > 0);
|
||||
}
|
||||
```
|
||||
|
||||
## Continuous Performance Monitoring
|
||||
|
||||
Run benchmarks regularly to detect regressions:
|
||||
|
||||
```bash
|
||||
# Run tests and save output
|
||||
./cmake-build-debug/kte_tests > benchmark_results.txt
|
||||
|
||||
# Compare with baseline
|
||||
diff benchmark_baseline.txt benchmark_results.txt
|
||||
```
|
||||
|
||||
Look for:
|
||||
|
||||
- Significant time increases (>20%) in any benchmark
|
||||
- New operations that are slower than expected
|
||||
- Cache effectiveness degradation
|
||||
|
||||
## Conclusion
|
||||
|
||||
The benchmark suite provides:
|
||||
|
||||
- **Performance validation**: Ensures migrations don't regress
|
||||
performance
|
||||
- **Optimization guidance**: Identifies fastest APIs for each use case
|
||||
- **Regression detection**: Catches performance issues early
|
||||
- **Documentation**: Demonstrates correct API usage patterns
|
||||
|
||||
All 98 tests pass with 0 failures, confirming both correctness and
|
||||
performance of the migrated codebase.
|
||||
1138
docs/DEVELOPER_GUIDE.md
Normal file
1138
docs/DEVELOPER_GUIDE.md
Normal file
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