Compare commits

..

16 Commits

Author SHA1 Message Date
8712ea673d Add leuchtturm theme, font zoom, syntax palette fixes
Themes:
- Add leuchtturm theme (fountain pen ink on cream paper, brass/leather dark)
- Add per-theme syntax palettes for leuchtturm, tufte, and everforest
- Fix static inline globals giving each TU its own copy of gCurrentTheme
  and gBackgroundMode (changed to inline for proper C++17 linkage)
- :background with no args now shows current mode

Font zoom:
- CMD-=/CMD--/CMD-0 to increase/decrease/reset font size

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 21:17:55 -07:00
3148e16cf8 Fix multi-window architecture and swap file cleanup
Multi-window:
- Per-window ImGui contexts (fixes input, scroll, and rendering isolation)
- Per-instance scroll and mouse state in ImGuiRenderer (no more statics)
- Proper GL context activation during window destruction
- ValidateBufferIndex guards against stale curbuf_ across shared buffers
- Editor methods (CurrentBuffer, SwitchTo, CloseBuffer, etc.) use Buffers()
  accessor to respect shared buffer lists
- New windows open with an untitled buffer
- Scratch buffer reuse works in secondary windows
- CMD-w on macOS closes only the focused window
- Deferred new-window creation to avoid mid-frame ImGui context corruption

Swap file cleanup:
- SaveAs prompt handler now calls ResetJournal
- cmd_save_and_quit now calls ResetJournal
- Editor::Reset detaches all buffers before clearing
- Tests for save-and-quit and editor-reset swap cleanup

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 19:48:34 -07:00
34eaa72033 Bump patch version to 1.8.3
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:36:21 -07:00
f49f1698f4 Add Tufte theme with light and dark variants
Warm cream paper, near-black ink, zero rounding, minimal chrome,
restrained dark red and navy accents following Tufte's design principles.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:34:23 -07:00
f4b3188069 Forgot to bump patch version. 2026-03-17 17:28:57 -07:00
2571ab79c1 build now works on nix
1. Static linking - Added KTE_STATIC_LINK CMake option and
   disabled it in default.nix to avoid the "attempted
   static link of dynamic object" error

2. Missing include - Added <cstring> to
   test_swap_edge_cases.cc for std::memset/std::memcpy (GCC
   14 is stricter about transitive includes)
2026-03-17 17:15:16 -07:00
d768e56727 Add multi-window support to GUI with shared buffer list and improved input handling
- Introduced support for multiple windows, sharing the primary editor's buffer list.
- Added `GUIFrontend::OpenNewWindow_` for creating secondary windows with independent dimensions and input handlers.
- Redesigned `WindowState` to encapsulate per-window attributes (dimensions, renderer, input, etc.).
- Updated input processing and command execution to route events based on active window, preserving window-level states.
- Enhanced SDL2 and ImGui integration for proper context management across multiple windows.
- Increased robustness by handling window closing, resizing, and cleanup of secondary windows without affecting the primary editor.
- Updated documentation and key bindings for multi-window operations (e.g., Cmd+N / Ctrl+Shift+N).
- Version updated to 1.8.0 to reflect the major GUI enhancement.
2026-03-15 13:19:04 -07:00
11c523ad52 Bump patch version. 2026-02-26 13:27:13 -08:00
c261261e26 Initialize ErrorHandler early and ensure immediate log file creation
- Added early initialization of `ErrorHandler` in `main.cc` for robust error handling.
- Modified `ErrorHandler` to create the log file immediately, ensuring its presence in the state directory.
- Simplified conditional checks for log file operations and updated timestamp handling to use `system_clock`.
2026-02-26 13:25:57 -08:00
27dcb41857 Add ReflowUndo tests and integrate InsertRow undo support
- Added `test_reflow_undo.cc` to validate undo/redo workflows for reflow operations.
- Introduced `UndoType::InsertRow` in `UndoSystem` for tracking row insertion changes in undo history.
- Updated `UndoNode.h` and `UndoSystem.cc` to support row insertion as a standalone undo step.
- Enhanced reflow paragraph functionality to properly record undo/redo actions for both row deletion and insertion.
- Enabled legacy/extended undo tests in `test_undo.cc` for comprehensive validation.
- Updated `CMakeLists.txt` to include new test file in the build target.
2026-02-26 13:21:07 -08:00
bc3433e988 Add SmartNewline command with tests and editor integration
- Introduced `CommandId::SmartNewline` for auto-indented newlines, enhancing text editing workflows.
- Added `cmd_smart_newline` to implement indentation-aware newline logic.
- Integrated SmartNewline with keymaps, mouse/keyboard input handlers, and terminal/editor commands.
- Wrote comprehensive tests in `test_smart_newline.cc` to validate behavior for spaces, tabs, and no-indentation cases.
- Updated `Command.h` and `CMakeLists.txt` to register and build the new command.
2026-02-26 13:08:56 -08:00
690c51b0f3 MacOS: remove static linking. Bump minor version. 2026-02-19 21:00:29 -08:00
0d87bc0b25 Introduce error recovery mechanisms with retry logic and circuit breaker integration.
- Added `ErrorRecovery.cc` and `ErrorRecovery.h` for retry and circuit breaker implementations.
- Enhanced swap file handling with transient error retries and exponential backoff (e.g., ENOSPC, EDQUOT).
- Integrated circuit breaker into SwapManager to gracefully handle repeated failures, prevent system overload, and enable automatic recovery.
- Updated `DEVELOPER_GUIDE.md` with comprehensive documentation on error recovery patterns and graceful degradation strategies.
- Refined fsync, temp file creation, and swap file logic with retry-on-failure mechanisms for improved resilience.
2026-02-17 21:38:40 -08:00
daeeecb342 Standardize error handling patterns and improve ErrorHandler integration.
- Added a comprehensive error propagation standardization report detailing dominant patterns, inconsistencies, and recommended remediations (`docs/audits/error-propagation-standardization.md`).
- Integrated `ErrorHandler` into key components, including `main.cc` for robust exception reporting, and added centralized logging to a user state path.
- Introduced EINTR-safe syscall wrappers (`SyscallWrappers.h`, `.cc`) to improve resilience of file and metadata operations.
- Enhanced `DEVELOPER_GUIDE.md` with an error handling conventions section, covering pattern guidelines and best practices.
- Identified gaps in `PieceTable` and internal helpers; deferred fixes with detailed recommendations for improved memory allocation error reporting.
2026-02-17 21:25:19 -08:00
a428b204a0 Improve exception robustness.
- Introduced `test_swap_edge_cases.cc` with extensive tests for minimum payload sizes, truncated payloads, data overflows, unsupported encoding versions, CRC mismatches, and mixed valid/invalid records to ensure reliability under complex scenarios.
- Enhanced `main.cc` with a top-level exception handler to prevent data loss and ensure cleanup during unexpected failures.
2026-02-17 20:12:09 -08:00
a21409e689 Remove PID from unnamed buffer swap names. 2026-02-17 17:17:55 -08:00
36 changed files with 5150 additions and 553 deletions

View File

@@ -18,6 +18,9 @@
#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"
@@ -122,11 +125,11 @@ best_effort_fsync_dir(const std::string &path)
std::filesystem::path dir = p.parent_path();
if (dir.empty())
return;
int dfd = ::open(dir.c_str(), O_RDONLY);
int dfd = kte::syscall::Open(dir.c_str(), O_RDONLY);
if (dfd < 0)
return;
(void) ::fsync(dfd);
(void) ::close(dfd);
(void) kte::syscall::Fsync(dfd);
(void) kte::syscall::Close(dfd);
} catch (...) {
// best-effort
}
@@ -146,9 +149,21 @@ atomic_write_file(const std::string &path, const char *data, std::size_t len, st
// mkstemp requires a mutable buffer.
std::vector<char> buf(tmpl_s.begin(), tmpl_s.end());
buf.push_back('\0');
int fd = ::mkstemp(buf.data());
// 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 = std::string("Failed to create temp file for save: ") + std::strerror(errno) + err;
}
return false;
}
std::string tmp_path(buf.data());
@@ -156,17 +171,23 @@ atomic_write_file(const std::string &path, const char *data, std::size_t len, st
// If the destination exists, carry over its permissions.
struct stat dst_st{};
if (::stat(path.c_str(), &dst_st) == 0) {
(void) ::fchmod(fd, dst_st.st_mode);
(void) kte::syscall::Fchmod(fd, dst_st.st_mode);
}
bool ok = write_all_fd(fd, data, len, err);
if (ok) {
if (::fsync(fd) != 0) {
err = std::string("fsync failed: ") + std::strerror(errno);
// 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) ::close(fd);
(void) kte::syscall::Close(fd);
if (ok) {
if (::rename(tmp_path.c_str(), path.c_str()) != 0) {
@@ -411,17 +432,46 @@ Buffer::OpenFromFile(const std::string &path, std::string &err)
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;
}
// Read entire file into PieceTable as-is
std::string data;
in.seekg(0, std::ios::end);
if (!in) {
err = "Failed to seek to end of file: " + norm;
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
return false;
}
auto sz = in.tellg();
if (sz < 0) {
err = "Failed to get file size: " + norm;
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
return false;
}
if (sz > 0) {
data.resize(static_cast<std::size_t>(sz));
in.seekg(0, std::ios::beg);
if (!in) {
err = "Failed to seek to beginning of file: " + norm;
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
return false;
}
in.read(data.data(), static_cast<std::streamsize>(data.size()));
if (!in && !in.eof()) {
err = "Failed to read file: " + norm;
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
return false;
}
// Validate we read the expected number of bytes
const std::streamsize bytes_read = in.gcount();
if (bytes_read != static_cast<std::streamsize>(data.size())) {
err = "Partial read of file (expected " + std::to_string(data.size()) +
" bytes, got " + std::to_string(bytes_read) + "): " + norm;
kte::ErrorHandler::Instance().Error("Buffer", err, norm);
return false;
}
}
content_.Clear();
if (!data.empty())
@@ -464,8 +514,10 @@ Buffer::Save(std::string &err) const
err = "Internal error: buffer materialization failed";
return false;
}
if (!atomic_write_file(filename_, data ? data : "", sz, err))
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
@@ -502,8 +554,10 @@ Buffer::SaveAs(const std::string &path, std::string &err)
err = "Internal error: buffer materialization failed";
return false;
}
if (!atomic_write_file(out_path, data ? data : "", sz, err))
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;

View File

@@ -4,7 +4,7 @@ project(kte)
include(GNUInstallDirs)
set(CMAKE_CXX_STANDARD 20)
set(KTE_VERSION "1.6.6")
set(KTE_VERSION "1.9.1")
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
@@ -14,6 +14,7 @@ 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" ON)
# Optionally enable AddressSanitizer (ASan)
option(ENABLE_ASAN "Enable AddressSanitizer for builds" OFF)
@@ -39,7 +40,6 @@ if (MSVC)
add_compile_options("/W4" "$<$<CONFIG:RELEASE>:/O2>")
else ()
add_compile_options(
"-static"
"-Wall"
"-Wextra"
"-Werror"
@@ -142,6 +142,9 @@ set(COMMON_SOURCES
HelpText.cc
KKeymap.cc
Swap.cc
ErrorHandler.cc
SyscallWrappers.cc
ErrorRecovery.cc
TerminalInputHandler.cc
TerminalRenderer.cc
TerminalFrontend.cc
@@ -282,6 +285,11 @@ 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")
@@ -316,8 +324,10 @@ if (BUILD_TESTS)
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
@@ -328,6 +338,8 @@ if (BUILD_TESTS)
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
@@ -336,6 +348,9 @@ if (BUILD_TESTS)
Command.cc
HelpText.cc
Swap.cc
ErrorHandler.cc
SyscallWrappers.cc
ErrorRecovery.cc
KKeymap.cc
SwapRecorder.h
OptimizedSearch.cc
@@ -360,6 +375,11 @@ if (BUILD_TESTS)
target_link_libraries(kte_tests ${TREESITTER_LIBRARY})
endif ()
endif ()
# Static linking on Linux only (macOS does not support static linking of system libraries)
if (NOT APPLE AND KTE_STATIC_LINK)
target_link_options(kte_tests PRIVATE -static)
endif ()
endif ()
if (BUILD_GUI)
@@ -399,6 +419,11 @@ if (BUILD_GUI)
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)
# Define the icon file

View File

@@ -115,6 +115,14 @@ ensure_cursor_visible(const Editor &ed, Buffer &buf)
}
static bool
cmd_new_window(CommandContext &ctx)
{
ctx.editor.SetNewWindowRequested(true);
return true;
}
static bool
cmd_center_on_cursor(CommandContext &ctx)
{
@@ -744,6 +752,8 @@ cmd_save_and_quit(CommandContext &ctx)
if (buf->IsFileBacked()) {
if (buf->Save(err)) {
buf->SetDirty(false);
if (auto *sm = ctx.editor.Swap())
sm->ResetJournal(*buf);
} else {
ctx.editor.SetStatus(err);
return false;
@@ -751,6 +761,8 @@ cmd_save_and_quit(CommandContext &ctx)
} else if (!buf->Filename().empty()) {
if (buf->SaveAs(buf->Filename(), err)) {
buf->SetDirty(false);
if (auto *sm = ctx.editor.Swap())
sm->ResetJournal(*buf);
} else {
ctx.editor.SetStatus(err);
return false;
@@ -1109,7 +1121,6 @@ cmd_theme_set_by_name(const CommandContext &ctx)
static bool
cmd_theme_set_by_name(CommandContext &ctx)
{
# if defined(KTE_BUILD_GUI) && defined(KTE_USE_QT)
// Qt GUI build: schedule theme change for frontend
std::string name = ctx.arg;
@@ -1346,6 +1357,10 @@ cmd_background_set(const CommandContext &ctx)
std::transform(mode.begin(), mode.end(), mode.begin(), [](unsigned char c) {
return (char) std::tolower(c);
});
if (mode.empty()) {
ctx.editor.SetStatus(std::string("Background: ") + kte::BackgroundModeName());
return true;
}
if (mode != "light" && mode != "dark") {
ctx.editor.SetStatus("background: expected 'light' or 'dark'");
return true;
@@ -2255,10 +2270,8 @@ cmd_show_help(CommandContext &ctx)
};
auto populate_from_text = [](Buffer &b, const std::string &text) {
// Clear existing rows
while (b.Nrows() > 0) {
b.delete_row(0);
}
// Clear existing content
b.replace_all_bytes("");
// Parse text and insert rows
std::string line;
line.reserve(128);
@@ -2563,6 +2576,10 @@ cmd_newline(CommandContext &ctx)
ctx.editor.SetStatus(err);
} else {
buf->SetDirty(false);
if (auto *sm = ctx.editor.Swap()) {
sm->NotifyFilenameChanged(*buf);
sm->ResetJournal(*buf);
}
ctx.editor.SetStatus("Saved as " + value);
if (auto *u = buf->Undo())
u->mark_saved();
@@ -2949,6 +2966,58 @@ cmd_newline(CommandContext &ctx)
}
static bool
cmd_smart_newline(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf) {
ctx.editor.SetStatus("No buffer to edit");
return false;
}
if (buf->IsReadOnly()) {
ctx.editor.SetStatus("Read-only buffer");
return true;
}
// Smart newline behavior: add a newline with the same indentation as the current line.
// Find indentation of current line
std::size_t y = buf->Cury();
std::string line = buf->GetLineString(y);
std::string indent;
for (char c: line) {
if (c == ' ' || c == '\t') {
indent += c;
} else {
break;
}
}
// Perform standard newline first
if (!cmd_newline(ctx)) {
return false;
}
// Now insert the indentation at the new cursor position
if (!indent.empty()) {
std::size_t new_y = buf->Cury();
std::size_t new_x = buf->Curx();
buf->insert_text(static_cast<int>(new_y), static_cast<int>(new_x), indent);
buf->SetCursor(new_x + indent.size(), new_y);
buf->SetDirty(true);
if (auto *u = buf->Undo()) {
u->Begin(UndoType::Insert);
u->Append(indent);
u->commit();
}
}
ensure_cursor_visible(ctx.editor, *buf);
return true;
}
static bool
cmd_backspace(CommandContext &ctx)
{
@@ -4624,7 +4693,14 @@ cmd_reflow_paragraph(CommandContext &ctx)
new_lines.push_back("");
// Replace paragraph lines via PieceTable-backed operations
UndoSystem *u = buf->Undo();
for (std::size_t i = para_end; i + 1 > para_start; --i) {
if (u) {
buf->SetCursor(0, i);
u->Begin(UndoType::DeleteRow);
u->Append(static_cast<std::string>(buf->Rows()[i]));
u->commit();
}
buf->delete_row(static_cast<int>(i));
if (i == 0)
break; // prevent wrap on size_t
@@ -4633,6 +4709,12 @@ cmd_reflow_paragraph(CommandContext &ctx)
std::size_t insert_y = para_start;
for (const auto &ln: new_lines) {
buf->insert_row(static_cast<int>(insert_y), std::string_view(ln));
if (u) {
buf->SetCursor(0, insert_y);
u->Begin(UndoType::InsertRow);
u->Append(std::string_view(ln));
u->commit();
}
insert_y += 1;
}
@@ -4806,6 +4888,9 @@ InstallDefaultCommands()
CommandId::InsertText, "insert", "Insert text at cursor (no newlines)", cmd_insert_text, false, true
});
CommandRegistry::Register({CommandId::Newline, "newline", "Insert newline at cursor", cmd_newline});
CommandRegistry::Register({
CommandId::SmartNewline, "smart-newline", "Insert newline with auto-indent", cmd_smart_newline
});
CommandRegistry::Register({CommandId::Backspace, "backspace", "Delete char before cursor", cmd_backspace});
CommandRegistry::Register({CommandId::DeleteChar, "delete-char", "Delete char at cursor", cmd_delete_char});
CommandRegistry::Register({CommandId::KillToEOL, "kill-to-eol", "Delete to end of line", cmd_kill_to_eol});
@@ -4935,6 +5020,11 @@ InstallDefaultCommands()
CommandId::CenterOnCursor, "center-on-cursor", "Center viewport on current line", cmd_center_on_cursor,
false, false
});
// GUI: new window
CommandRegistry::Register({
CommandId::NewWindow, "new-window", "Open a new editor window (GUI only)", cmd_new_window,
false, false
});
}

View File

@@ -38,6 +38,7 @@ enum class CommandId {
// 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
@@ -110,6 +111,12 @@ enum class CommandId {
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,
};

View File

@@ -69,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_];
}
@@ -117,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())
@@ -161,41 +164,44 @@ Editor::DisplayNameFor(const Buffer &buf) const
std::size_t
Editor::AddBuffer(const Buffer &buf)
{
buffers_.push_back(buf);
auto &bufs = Buffers();
bufs.push_back(buf);
// Attach swap recorder
if (swap_) {
swap_->Attach(&buffers_.back());
buffers_.back().SetSwapRecorder(swap_->RecorderFor(&buffers_.back()));
swap_->Attach(&bufs.back());
bufs.back().SetSwapRecorder(swap_->RecorderFor(&bufs.back()));
}
if (buffers_.size() == 1) {
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));
auto &bufs = Buffers();
bufs.push_back(std::move(buf));
if (swap_) {
swap_->Attach(&buffers_.back());
buffers_.back().SetSwapRecorder(swap_->RecorderFor(&buffers_.back()));
swap_->Attach(&bufs.back());
bufs.back().SetSwapRecorder(swap_->RecorderFor(&bufs.back()));
}
if (buffers_.size() == 1) {
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 std::size_t nrows = cur.Nrows();
@@ -268,7 +274,7 @@ 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]);
swap_->NotifyFilenameChanged(Buffers()[idx]);
}
SwitchTo(idx);
// Defensive: ensure any active prompt is closed after a successful open
@@ -446,12 +452,13 @@ Editor::ProcessPendingOpens()
bool
Editor::SwitchTo(std::size_t index)
{
if (index >= buffers_.size()) {
auto &bufs = Buffers();
if (index >= bufs.size()) {
return false;
}
curbuf_ = index;
// Robustness: ensure a valid highlighter is installed when switching buffers
Buffer &b = buffers_[curbuf_];
Buffer &b = bufs[curbuf_];
if (b.SyntaxEnabled()) {
b.EnsureHighlighter();
if (auto *eng = b.Highlighter()) {
@@ -478,21 +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;
}
if (swap_) {
// Always remove swap file when closing a buffer on normal exit.
// Swap files are for crash recovery; on clean close, we don't need them.
// This prevents stale swap files from accumulating (e.g., when used as git editor).
swap_->Detach(&buffers_[index], true);
buffers_[index].SetSwapRecorder(nullptr);
swap_->Detach(&bufs[index], true);
bufs[index].SetSwapRecorder(nullptr);
}
buffers_.erase(buffers_.begin() + static_cast<std::ptrdiff_t>(index));
if (buffers_.empty()) {
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;
}
@@ -516,7 +524,12 @@ Editor::Reset()
// Reset close-confirm/save state
close_confirm_pending_ = false;
close_after_save_ = false;
buffers_.clear();
auto &bufs = Buffers();
if (swap_) {
for (auto &buf : bufs)
swap_->Detach(&buf, true);
}
bufs.clear();
curbuf_ = 0;
}

View File

@@ -246,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;
@@ -509,7 +521,7 @@ public:
// Buffers
[[nodiscard]] std::size_t BufferCount() const
{
return buffers_.size();
return Buffers().size();
}
@@ -519,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;
@@ -570,13 +595,22 @@ 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;
}
@@ -628,6 +662,7 @@ private:
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)
@@ -639,6 +674,7 @@ private:
// 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

313
ErrorHandler.cc Normal file
View 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
View 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
View 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
View 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

View File

@@ -312,7 +312,7 @@ namespace kte {
enum class BackgroundMode { Light, Dark };
// Global background mode; default to Dark to match prior defaults
static inline auto gBackgroundMode = BackgroundMode::Dark;
inline auto gBackgroundMode = BackgroundMode::Dark;
// Basic theme identifier (kept minimal; some ids are aliases)
enum class ThemeId {
@@ -330,11 +330,13 @@ enum class ThemeId {
Amber = 10,
WeylandYutani = 11,
Orbital = 12,
Tufte = 13,
Leuchtturm = 14,
};
// Current theme tracking
static inline auto gCurrentTheme = ThemeId::Nord;
static inline std::size_t gCurrentThemeIndex = 6; // Nord index
inline auto gCurrentTheme = ThemeId::Nord;
inline std::size_t gCurrentThemeIndex = 7; // Nord index
// Forward declarations for helpers used below
static size_t ThemeIndexFromId(ThemeId id);
@@ -372,11 +374,13 @@ BackgroundModeName()
#include "themes/Everforest.h"
#include "themes/KanagawaPaper.h"
#include "themes/LCARS.h"
#include "themes/Leuchtturm.h"
#include "themes/OldBook.h"
#include "themes/Amber.h"
#include "themes/WeylandYutani.h"
#include "themes/Zenburn.h"
#include "themes/Orbital.h"
#include "themes/Tufte.h"
// Theme abstraction and registry (generalized theme system)
@@ -409,6 +413,28 @@ struct LCARSTheme final : Theme {
}
};
struct LeuchtturmTheme final : Theme {
[[nodiscard]] const char *Name() const override
{
return "leuchtturm";
}
void Apply() const override
{
if (gBackgroundMode == BackgroundMode::Dark)
ApplyLeuchtturmDarkTheme();
else
ApplyLeuchtturmLightTheme();
}
ThemeId Id() override
{
return ThemeId::Leuchtturm;
}
};
struct EverforestTheme final : Theme {
[[nodiscard]] const char *Name() const override
{
@@ -488,6 +514,28 @@ struct OrbitalTheme final : Theme {
}
};
struct TufteTheme final : Theme {
[[nodiscard]] const char *Name() const override
{
return "tufte";
}
void Apply() const override
{
if (gBackgroundMode == BackgroundMode::Dark)
ApplyTufteDarkTheme();
else
ApplyTufteLightTheme();
}
ThemeId Id() override
{
return ThemeId::Tufte;
}
};
struct ZenburnTheme final : Theme {
[[nodiscard]] const char *Name() const override
{
@@ -657,18 +705,20 @@ ThemeRegistry()
static std::vector<std::unique_ptr<Theme> > reg;
if (reg.empty()) {
// Alphabetical by canonical name:
// amber, eink, everforest, gruvbox, kanagawa-paper, lcars, nord, old-book, orbital, plan9, solarized, weyland-yutani, zenburn
// amber, eink, everforest, gruvbox, kanagawa-paper, lcars, leuchtturm, nord, old-book, orbital, plan9, solarized, tufte, weyland-yutani, zenburn
reg.emplace_back(std::make_unique<detail::AmberTheme>());
reg.emplace_back(std::make_unique<detail::EInkTheme>());
reg.emplace_back(std::make_unique<detail::EverforestTheme>());
reg.emplace_back(std::make_unique<detail::GruvboxTheme>());
reg.emplace_back(std::make_unique<detail::KanagawaPaperTheme>());
reg.emplace_back(std::make_unique<detail::LCARSTheme>());
reg.emplace_back(std::make_unique<detail::LeuchtturmTheme>());
reg.emplace_back(std::make_unique<detail::NordTheme>());
reg.emplace_back(std::make_unique<detail::OldBookTheme>());
reg.emplace_back(std::make_unique<detail::OrbitalTheme>());
reg.emplace_back(std::make_unique<detail::Plan9Theme>());
reg.emplace_back(std::make_unique<detail::SolarizedTheme>());
reg.emplace_back(std::make_unique<detail::TufteTheme>());
reg.emplace_back(std::make_unique<detail::WeylandYutaniTheme>());
reg.emplace_back(std::make_unique<detail::ZenburnTheme>());
}
@@ -845,20 +895,24 @@ ThemeIndexFromId(const ThemeId id)
return 4;
case ThemeId::LCARS:
return 5;
case ThemeId::Nord:
case ThemeId::Leuchtturm:
return 6;
case ThemeId::OldBook:
case ThemeId::Nord:
return 7;
case ThemeId::Orbital:
case ThemeId::OldBook:
return 8;
case ThemeId::Plan9:
case ThemeId::Orbital:
return 9;
case ThemeId::Solarized:
case ThemeId::Plan9:
return 10;
case ThemeId::WeylandYutani:
case ThemeId::Solarized:
return 11;
case ThemeId::Zenburn:
case ThemeId::Tufte:
return 12;
case ThemeId::WeylandYutani:
return 13;
case ThemeId::Zenburn:
return 14;
}
return 0;
}
@@ -882,30 +936,144 @@ ThemeIdFromIndex(const size_t idx)
case 5:
return ThemeId::LCARS;
case 6:
return ThemeId::Nord;
return ThemeId::Leuchtturm;
case 7:
return ThemeId::OldBook;
return ThemeId::Nord;
case 8:
return ThemeId::Orbital;
return ThemeId::OldBook;
case 9:
return ThemeId::Plan9;
return ThemeId::Orbital;
case 10:
return ThemeId::Solarized;
return ThemeId::Plan9;
case 11:
return ThemeId::WeylandYutani;
return ThemeId::Solarized;
case 12:
return ThemeId::Tufte;
case 13:
return ThemeId::WeylandYutani;
case 14:
return ThemeId::Zenburn;
}
}
// --- Syntax palette (v1): map TokenKind to ink color per current theme/background ---
// Tufte palette: high-contrast, restrained color. Body text is true black on
// cream; only keywords and links get subtle color to avoid a "christmas tree."
static ImVec4
SyntaxInkTufte(const TokenKind k, const bool dark)
{
const ImVec4 ink = dark ? RGBA(0xEAE6DE) : RGBA(0x111111); // body text
const ImVec4 dim = dark ? RGBA(0x8A8680) : RGBA(0x555555); // comments
const ImVec4 red = dark ? RGBA(0xD06060) : RGBA(0x8B0000); // keywords/preproc
const ImVec4 navy = dark ? RGBA(0x7098C0) : RGBA(0x1A3A5C); // functions/links
const ImVec4 grn = dark ? RGBA(0x8AAA6E) : RGBA(0x2E5E2E); // strings
switch (k) {
case TokenKind::Keyword:
case TokenKind::Preproc:
return red;
case TokenKind::String:
case TokenKind::Char:
return grn;
case TokenKind::Comment:
return dim;
case TokenKind::Function:
return navy;
case TokenKind::Number:
case TokenKind::Constant:
return dark ? RGBA(0xC8A85A) : RGBA(0x6B4C00);
case TokenKind::Type:
return dark ? RGBA(0xBBAA90) : RGBA(0x333333);
case TokenKind::Error:
return dark ? RGBA(0xD06060) : RGBA(0xCC0000);
default:
return ink;
}
}
// Leuchtturm palette: blue-black fountain pen ink with brass and bronze accents.
// Body text is ink-colored; accents drawn from the pen metals.
static ImVec4
SyntaxInkLeuchtturm(const TokenKind k, const bool dark)
{
const ImVec4 ink = dark ? RGBA(0xE5DDD0) : RGBA(0x040720); // fountain pen ink
const ImVec4 dim = dark ? RGBA(0x7A7060) : RGBA(0x6A6558); // comments
const ImVec4 brass = dark ? RGBA(0xB8A060) : RGBA(0x504518); // patinated brass
const ImVec4 bronze= dark ? RGBA(0xC08050) : RGBA(0x5C3010); // dark bronze
const ImVec4 navy = dark ? RGBA(0x8898B0) : RGBA(0x1C2E4A); // deep navy
switch (k) {
case TokenKind::Keyword:
case TokenKind::Preproc:
return brass;
case TokenKind::String:
case TokenKind::Char:
return bronze;
case TokenKind::Comment:
return dim;
case TokenKind::Function:
return navy;
case TokenKind::Number:
case TokenKind::Constant:
return dark ? RGBA(0xA89060) : RGBA(0x483C10);
case TokenKind::Type:
return dark ? RGBA(0xC0B898) : RGBA(0x222238);
case TokenKind::Error:
return dark ? RGBA(0xD06060) : RGBA(0xA02020);
default:
return ink;
}
}
// Everforest: warm forest palette on dark green-gray (bg 0x2B3339).
// Default comment color (0x616E88) is too dim; boost it and tune others.
static ImVec4
SyntaxInkEverforest(const TokenKind k)
{
switch (k) {
case TokenKind::Keyword:
return RGBA(0xE67E80); // everforest red
case TokenKind::Type:
return RGBA(0xD699B6); // everforest purple
case TokenKind::String:
case TokenKind::Char:
return RGBA(0xA7C080); // everforest green
case TokenKind::Comment:
return RGBA(0x859289); // boosted from 0x616E88 for contrast
case TokenKind::Number:
case TokenKind::Constant:
return RGBA(0xD8A657); // everforest yellow/orange
case TokenKind::Preproc:
return RGBA(0xE69875); // everforest orange
case TokenKind::Function:
return RGBA(0x83C092); // everforest aqua
case TokenKind::Operator:
case TokenKind::Punctuation:
return RGBA(0xD3C6AA); // everforest fg
case TokenKind::Error:
return RGBA(0xE67E80);
default:
return RGBA(0xD3C6AA); // everforest fg
}
}
[[maybe_unused]] static ImVec4
SyntaxInk(const TokenKind k)
{
// Basic palettes for dark/light backgrounds; tuned for Nord-ish defaults
const bool dark = (GetBackgroundMode() == BackgroundMode::Dark);
// Base text
// Per-theme syntax palettes
if (gCurrentTheme == ThemeId::Tufte)
return SyntaxInkTufte(k, dark);
if (gCurrentTheme == ThemeId::Leuchtturm)
return SyntaxInkLeuchtturm(k, dark);
if (gCurrentTheme == ThemeId::Everforest)
return SyntaxInkEverforest(k);
// Default palettes tuned for Nord-ish themes
const ImVec4 def = dark ? RGBA(0xD8DEE9) : RGBA(0x2E3440);
switch (k) {
case TokenKind::Keyword:

View File

@@ -27,6 +27,7 @@ HelpText::Text()
" 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"
@@ -63,6 +64,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"
@@ -74,12 +79,20 @@ HelpText::Text()
" C-t Regex search & replace\n"
" C-h Search & replace\n"
" C-l / C-g Refresh / Cancel\n"
" C-u [digits] Universal argument (repeat count)\n"
"\n"
"Buffers:\n +HELP+ is read-only. Press C-k ' to toggle; C-k h restores it.\n"
"\n"
"GUI appearance (command prompt):\n"
" : theme NAME Set GUI theme (amber, eink, everforest, gruvbox, kanagawa-paper, lcars, nord, old-book, plan9, solarized, weyland-yutani, zenburn)\n"
" : background MODE Set background: light | dark (affects eink, gruvbox, old-book, solarized)\n"
"\n"
"GUI config file options:\n"
" font_size=NUM Set font size in pixels (default: 16; e.g., font_size=18)\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"
);
}

View File

@@ -29,21 +29,143 @@
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;
if (cfg.syntax) {
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();
float ch_w = ImGui::CalcTextSize("M").x;
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;
// Attach editor to input handler for editor-owned features (e.g., universal argument)
input_.Attach(&ed);
// editor dimensions will be initialized during the first Step() frame
// 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;
}
// 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);
@@ -56,159 +178,114 @@ GUIFrontend::Init(int &argc, char **argv, Editor &ed)
// 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.
int init_w = 1280, init_h = 800;
if (config_.fullscreen) {
SDL_Rect usable{};
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
width_ = usable.w;
height_ = usable.h;
init_w = usable.w;
init_h = 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
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);
}
width_ = std::max(320, w);
height_ = std::max(200, h);
init_w = std::max(320, w);
init_h = std::max(200, h);
}
SDL_SetHint(SDL_HINT_VIDEO_ALLOW_SCREENSAVER, "1");
window_ = SDL_CreateWindow(
SDL_Window *win = SDL_CreateWindow(
"kge - kyle's graphical editor " KTE_VERSION_STR,
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
width_, height_,
init_w, init_h,
win_flags);
if (!window_) {
if (!win) {
return false;
}
SDL_EnableScreenSaver();
#if defined(__APPLE__)
// macOS: when "fullscreen" is requested, position the window at the
// top-left of the usable display area to mimic fullscreen while keeping
// the system menu bar visible.
if (cfg.fullscreen) {
if (config_.fullscreen) {
SDL_Rect usable{};
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
SDL_SetWindowPosition(window_, usable.x, usable.y);
SDL_SetWindowPosition(win, usable.x, usable.y);
}
}
#endif
gl_ctx_ = SDL_GL_CreateContext(window_);
if (!gl_ctx_)
SDL_GLContext gl_ctx = SDL_GL_CreateContext(win);
if (!gl_ctx) {
SDL_DestroyWindow(win);
return false;
SDL_GL_MakeCurrent(window_, gl_ctx_);
}
SDL_GL_MakeCurrent(win, gl_ctx);
SDL_GL_SetSwapInterval(1); // vsync
// Create primary ImGui context
IMGUI_CHECKVERSION();
ImGui::CreateContext();
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; // Enable Keyboard Controls
io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad; // Enable Gamepad Controls
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad;
ImGui::StyleColorsDark();
// Apply background mode and selected theme (default: Nord). Can be changed at runtime via commands.
if (cfg.background == "light")
if (config_.background == "light")
kte::SetBackgroundMode(kte::BackgroundMode::Light);
else
kte::SetBackgroundMode(kte::BackgroundMode::Dark);
kte::ApplyThemeByName(cfg.theme);
kte::ApplyThemeByName(config_.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);
}
}
apply_syntax_to_buffer(ed.CurrentBuffer(), config_);
if (!ImGui_ImplSDL2_InitForOpenGL(window_, gl_ctx_))
if (!ImGui_ImplSDL2_InitForOpenGL(win, 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
// Cache initial window size
int w, h;
SDL_GetWindowSize(window_, &w, &h);
width_ = w;
height_ = h;
SDL_GetWindowSize(win, &w, &h);
init_w = w;
init_h = 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;
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 into registry and load configured font
// Install embedded fonts
kte::Fonts::InstallDefaultFonts();
// Initialize font atlas using configured font name and size; fallback to embedded default helper
if (!kte::Fonts::FontRegistry::Instance().LoadFont(cfg.font, (float) cfg.font_size)) {
LoadGuiFont_(nullptr, (float) cfg.font_size);
// Record defaults in registry so subsequent size changes have a base
kte::Fonts::FontRegistry::Instance().RequestLoadFont("default", (float) cfg.font_size);
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)) {
@@ -216,6 +293,90 @@ GUIFrontend::Init(int &argc, char **argv, Editor &ed)
}
}
// 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;
}
@@ -223,137 +384,229 @@ GUIFrontend::Init(int &argc, char **argv, Editor &ed)
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)) {
ImGui_ImplSDL2_ProcessEvent(&e);
// Determine which window this event belongs to
Uint32 event_win_id = 0;
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;
}
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;
}
// Map input to commands
input_.ProcessSDLEvent(e);
if (e.type == SDL_QUIT) {
running = false;
break;
}
// Apply pending font change before starting a new frame
// 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);
// Recreate backend font texture
ImGui_ImplOpenGL3_DestroyFontsTexture();
ImGui_ImplOpenGL3_CreateFontsTexture();
}
}
}
}
// Start a new ImGui frame BEFORE processing commands so dimensions are correct
// --- 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(window_);
ImGui_ImplSDL2_NewFrame(ws.window);
ImGui::NewFrame();
// Update editor logical rows/cols using current ImGui metrics and display size
// Update editor dimensions
{
ImGuiIO &io = ImGui::GetIO();
float row_h = ImGui::GetTextLineHeightWithSpacing();
float ch_w = ImGui::CalcTextSize("M").x;
if (row_h <= 0.0f)
row_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 ImGuiRenderer.
const float pad_x = 6.0f;
const float pad_y = 6.0f;
// Use the same logic as ImGuiRenderer for available height and status bar reservation.
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;
// Visible content rows inside the scroll child
auto content_rows = static_cast<std::size_t>(std::max(0.0f, std::floor(actual_avail_h / row_h)));
// Editor::Rows includes the status line; add 1 back for it.
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)));
// Only update if changed to avoid churn
if (rows != ed.Rows() || cols != ed.Cols()) {
ed.SetDimensions(rows, cols);
}
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 (including swap recovery prompts) to run.
ed.ProcessPendingOpens();
// Allow deferred opens
wed.ProcessPendingOpens();
// Execute pending mapped inputs (drain queue) AFTER dimensions are updated
// Drain input queue
for (;;) {
MappedInput mi;
if (!input_.Poll(mi))
if (!ws.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 (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()) {
// Update the system clipboard to mirror the kill ring head in GUI
SDL_SetClipboardText(after.c_str());
}
}
}
}
if (ed.QuitRequested()) {
if (wi == 0 && wed.QuitRequested()) {
running = false;
}
// No runtime font UI; always use embedded font.
// Draw editor UI
renderer_.Draw(ed);
// Draw
ws.renderer.Draw(wed);
// Render
ImGui::Render();
int display_w, display_h;
SDL_GL_GetDrawableSize(window_, &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(window_);
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()
{
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;
// Destroy all windows (secondary first, then primary)
for (auto it = windows_.rbegin(); it != windows_.rend(); ++it) {
DestroyWindowResources_(**it);
}
windows_.clear();
SDL_Quit();
}
@@ -367,7 +620,6 @@ GUIFrontend::LoadGuiFont_(const char * /*path*/, const float size_px)
ImFontConfig config;
config.MergeMode = false;
// Load Basic Latin + Latin Supplement
io.Fonts->AddFontFromMemoryCompressedTTF(
kte::Fonts::DefaultFontData,
kte::Fonts::DefaultFontSize,
@@ -375,7 +627,6 @@ GUIFrontend::LoadGuiFont_(const char * /*path*/, const float size_px)
&config,
io.Fonts->GetGlyphRangesDefault());
// Merge Greek and Mathematical symbols from IosevkaExtended
config.MergeMode = true;
static const ImWchar extended_ranges[] = {
0x0370, 0x03FF, // Greek and Coptic

View File

@@ -2,13 +2,18 @@
* 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 {
@@ -24,13 +29,31 @@ public:
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_{};
ImGuiInputHandler input_{};
ImGuiRenderer renderer_{};
SDL_Window *window_ = nullptr;
SDL_GLContext gl_ctx_ = nullptr;
int width_ = 1280;
int height_ = 800;
// Primary window (index 0 in windows_); created during Init.
std::vector<std::unique_ptr<WindowState> > windows_;
};

View File

@@ -125,7 +125,11 @@ map_key(const SDL_Keycode key,
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;
@@ -333,6 +337,38 @@ ImGuiInputHandler::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)) {
@@ -442,9 +478,11 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
if (ed_ &&ed_
->
UArg() != 0
) {
)
{
const char *txt = e.text.text;
if (txt && *txt) {
unsigned char c0 = static_cast<unsigned char>(txt[0]);

View File

@@ -76,19 +76,16 @@ ImGuiRenderer::Draw(Editor &ed)
// Two-way sync between Buffer::Rowoffs and ImGui scroll position:
// - If command layer changed Buffer::Rowoffs since last frame, drive ImGui scroll from it.
// - Otherwise, propagate ImGui scroll to Buffer::Rowoffs so command layer has an up-to-date view.
static long prev_buf_rowoffs = -1; // previous frame's Buffer::Rowoffs
static long prev_buf_coloffs = -1; // previous frame's Buffer::Coloffs
const long buf_rowoffs = static_cast<long>(buf->Rowoffs());
const long buf_coloffs = static_cast<long>(buf->Coloffs());
// Detect programmatic change (e.g., page_down command changed rowoffs)
// Use SetNextWindowScroll BEFORE BeginChild to set initial scroll position
if (prev_buf_rowoffs >= 0 && buf_rowoffs != prev_buf_rowoffs) {
if (prev_buf_rowoffs_ >= 0 && buf_rowoffs != prev_buf_rowoffs_) {
float target_y = static_cast<float>(buf_rowoffs) * row_h;
ImGui::SetNextWindowScroll(ImVec2(-1.0f, target_y));
}
if (prev_buf_coloffs >= 0 && buf_coloffs != prev_buf_coloffs) {
if (prev_buf_coloffs_ >= 0 && buf_coloffs != prev_buf_coloffs_) {
float target_x = static_cast<float>(buf_coloffs) * space_w;
float target_y = static_cast<float>(buf_rowoffs) * row_h;
ImGui::SetNextWindowScroll(ImVec2(target_x, target_y));
@@ -116,25 +113,22 @@ ImGuiRenderer::Draw(Editor &ed)
// Synchronize buffer offsets from ImGui scroll if user scrolled manually
bool forced_scroll = false;
{
static float prev_scroll_y = -1.0f; // previous frame's ImGui scroll Y in pixels
static float prev_scroll_x = -1.0f; // previous frame's ImGui scroll X in pixels
const long scroll_top = static_cast<long>(scroll_y / row_h);
const long scroll_left = static_cast<long>(scroll_x / space_w);
// Check if rowoffs was programmatically changed this frame
if (prev_buf_rowoffs >= 0 && buf_rowoffs != prev_buf_rowoffs) {
if (prev_buf_rowoffs_ >= 0 && buf_rowoffs != prev_buf_rowoffs_) {
forced_scroll = true;
}
// If user scrolled (not programmatic), update buffer offsets accordingly
if (prev_scroll_y >= 0.0f && scroll_y != prev_scroll_y && !forced_scroll) {
if (prev_scroll_y_ >= 0.0f && scroll_y != prev_scroll_y_ && !forced_scroll) {
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
mbuf->SetOffsets(static_cast<std::size_t>(std::max(0L, scroll_top)),
mbuf->Coloffs());
}
}
if (prev_scroll_x >= 0.0f && scroll_x != prev_scroll_x && !forced_scroll) {
if (prev_scroll_x_ >= 0.0f && scroll_x != prev_scroll_x_ && !forced_scroll) {
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
mbuf->SetOffsets(mbuf->Rowoffs(),
static_cast<std::size_t>(std::max(0L, scroll_left)));
@@ -142,11 +136,11 @@ ImGuiRenderer::Draw(Editor &ed)
}
// Update trackers for next frame
prev_scroll_y = scroll_y;
prev_scroll_x = scroll_x;
prev_scroll_y_ = scroll_y;
prev_scroll_x_ = scroll_x;
}
prev_buf_rowoffs = buf_rowoffs;
prev_buf_coloffs = buf_coloffs;
prev_buf_rowoffs_ = buf_rowoffs;
prev_buf_coloffs_ = buf_coloffs;
// Cache current horizontal offset in rendered columns for click handling
const std::size_t coloffs_now = buf->Coloffs();
@@ -169,7 +163,7 @@ ImGuiRenderer::Draw(Editor &ed)
const std::size_t vsel_sy = vsel_active ? buf->VisualLineStartY() : 0;
const std::size_t vsel_ey = vsel_active ? buf->VisualLineEndY() : 0;
static bool mouse_selecting = false;
// (mouse_selecting__ is a member variable)
auto mouse_pos_to_buf = [&]() -> std::pair<std::size_t, std::size_t> {
ImVec2 mp = ImGui::GetIO().MousePos;
// Convert mouse pos to buffer row
@@ -209,25 +203,41 @@ ImGuiRenderer::Draw(Editor &ed)
return {by, best_col};
};
// Mouse-driven selection: set mark on press, update cursor on drag
// Mouse-driven selection: set mark on double-click or drag, update cursor on any press/drag
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
mouse_selecting = true;
mouse_selecting_ = true;
auto [by, bx] = mouse_pos_to_buf();
char tmp[64];
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx);
Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
// Only set mark on double click.
// Dragging will also set the mark if not already set (handled below).
if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) {
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
mbuf->SetMark(bx, by);
}
}
if (mouse_selecting && ImGui::IsWindowHovered() && ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
}
if (mouse_selecting_ && ImGui::IsWindowHovered() && ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
auto [by, bx] = mouse_pos_to_buf();
// If we are dragging (mouse moved while down), ensure mark is set to start selection
if (ImGui::IsMouseDragging(ImGuiMouseButton_Left, 1.0f)) {
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
if (!mbuf->MarkSet()) {
// We'd need to convert click_pos to buf coords, but it's complex here.
// Setting it to where the cursor was *before* we started moving it
// in this frame is a good approximation, or just using current.
mbuf->SetMark(mbuf->Curx(), mbuf->Cury());
}
}
}
char tmp[64];
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx);
Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
}
if (mouse_selecting && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
mouse_selecting = false;
if (mouse_selecting_ && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
mouse_selecting_ = false;
}
for (std::size_t i = rowoffs; i < lines.size(); ++i) {
// Capture the screen position before drawing the line

View File

@@ -11,4 +11,13 @@ public:
~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;
};

View File

@@ -226,6 +226,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;
}

325
Swap.cc
View File

@@ -1,5 +1,8 @@
#include "Swap.h"
#include "Buffer.h"
#include "ErrorHandler.h"
#include "SyscallWrappers.h"
#include "ErrorRecovery.h"
#include <algorithm>
#include <chrono>
@@ -530,8 +533,7 @@ SwapManager::ComputeSidecarPath(const Buffer &buf)
// Unnamed buffers: unique within the process.
static std::atomic<std::uint64_t> ctr{0};
const std::uint64_t n = ++ctr;
const int pid = (int) ::getpid();
const std::string name = "unnamed-" + std::to_string(pid) + "-" + std::to_string(n) + ".swp";
const std::string name = "unnamed-" + std::to_string(n) + ".swp";
return (root / name).string();
}
@@ -599,38 +601,68 @@ SwapManager::write_header(int fd)
bool
SwapManager::open_ctx(JournalCtx &ctx, const std::string &path)
SwapManager::open_ctx(JournalCtx &ctx, const std::string &path, std::string &err)
{
err.clear();
if (ctx.fd >= 0)
return true;
if (!ensure_parent_dir(path))
if (!ensure_parent_dir(path)) {
err = "Failed to create parent directory for swap file: " + path;
return false;
}
int flags = O_CREAT | O_WRONLY | O_APPEND;
#ifdef O_CLOEXEC
flags |= O_CLOEXEC;
#endif
int fd = ::open(path.c_str(), flags, 0600);
if (fd < 0)
// Retry on transient errors (ENOSPC, EDQUOT, EBUSY, etc.)
int fd = -1;
auto open_fn = [&]() -> bool {
fd = kte::syscall::Open(path.c_str(), flags, 0600);
return fd >= 0;
};
if (!RetryOnTransientError(open_fn, RetryPolicy::Aggressive(), err)) {
if (fd < 0) {
int saved_errno = errno;
err = "Failed to open swap file '" + path + "': " + std::strerror(saved_errno) + err;
}
return false;
}
// Ensure permissions even if file already existed.
(void) ::fchmod(fd, 0600);
(void) kte::syscall::Fchmod(fd, 0600);
struct stat st{};
if (fstat(fd, &st) != 0) {
::close(fd);
if (kte::syscall::Fstat(fd, &st) != 0) {
int saved_errno = errno;
kte::syscall::Close(fd);
err = "Failed to fstat swap file '" + path + "': " + std::strerror(saved_errno);
return false;
}
// If an existing file is too small to contain the fixed header, truncate
// and restart.
if (st.st_size > 0 && st.st_size < 64) {
::close(fd);
kte::syscall::Close(fd);
int tflags = O_CREAT | O_WRONLY | O_TRUNC | O_APPEND;
#ifdef O_CLOEXEC
tflags |= O_CLOEXEC;
#endif
fd = ::open(path.c_str(), tflags, 0600);
if (fd < 0)
// Retry on transient errors for truncation open
fd = -1;
auto reopen_fn = [&]() -> bool {
fd = kte::syscall::Open(path.c_str(), tflags, 0600);
return fd >= 0;
};
if (!RetryOnTransientError(reopen_fn, RetryPolicy::Aggressive(), err)) {
if (fd < 0) {
int saved_errno = errno;
err = "Failed to reopen swap file for truncation '" + path + "': " + std::strerror(
saved_errno) + err;
}
return false;
(void) ::fchmod(fd, 0600);
}
(void) kte::syscall::Fchmod(fd, 0600);
st.st_size = 0;
}
ctx.fd = fd;
@@ -638,6 +670,9 @@ SwapManager::open_ctx(JournalCtx &ctx, const std::string &path)
if (st.st_size == 0) {
ctx.header_ok = write_header(fd);
ctx.approx_size_bytes = ctx.header_ok ? 64 : 0;
if (!ctx.header_ok) {
err = "Failed to write swap file header: " + path;
}
} else {
ctx.header_ok = true; // stage 1: trust existing header
ctx.approx_size_bytes = static_cast<std::uint64_t>(st.st_size);
@@ -650,8 +685,8 @@ void
SwapManager::close_ctx(JournalCtx &ctx)
{
if (ctx.fd >= 0) {
(void) ::fsync(ctx.fd);
::close(ctx.fd);
(void) kte::syscall::Fsync(ctx.fd);
kte::syscall::Close(ctx.fd);
ctx.fd = -1;
}
ctx.header_ok = false;
@@ -659,47 +694,77 @@ SwapManager::close_ctx(JournalCtx &ctx)
bool
SwapManager::compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8_t> &chkpt_record)
SwapManager::compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8_t> &chkpt_record, std::string &err)
{
if (ctx.path.empty())
err.clear();
if (ctx.path.empty()) {
err = "Compact failed: empty path";
return false;
if (chkpt_record.empty())
}
if (chkpt_record.empty()) {
err = "Compact failed: empty checkpoint record";
return false;
}
// Close existing file before rename.
if (ctx.fd >= 0) {
(void) ::fsync(ctx.fd);
::close(ctx.fd);
(void) kte::syscall::Fsync(ctx.fd);
kte::syscall::Close(ctx.fd);
ctx.fd = -1;
}
ctx.header_ok = false;
const std::string tmp_path = ctx.path + ".tmp";
// Create the compacted file: header + checkpoint record.
if (!ensure_parent_dir(tmp_path))
if (!ensure_parent_dir(tmp_path)) {
err = "Failed to create parent directory for temp swap file: " + tmp_path;
return false;
}
int flags = O_CREAT | O_WRONLY | O_TRUNC;
#ifdef O_CLOEXEC
flags |= O_CLOEXEC;
#endif
int tfd = ::open(tmp_path.c_str(), flags, 0600);
if (tfd < 0)
// Retry on transient errors for temp file creation
int tfd = -1;
auto open_tmp_fn = [&]() -> bool {
tfd = kte::syscall::Open(tmp_path.c_str(), flags, 0600);
return tfd >= 0;
};
if (!RetryOnTransientError(open_tmp_fn, RetryPolicy::Aggressive(), err)) {
if (tfd < 0) {
int saved_errno = errno;
err = "Failed to open temp swap file '" + tmp_path + "': " + std::strerror(saved_errno) + err;
}
return false;
(void) ::fchmod(tfd, 0600);
}
(void) kte::syscall::Fchmod(tfd, 0600);
bool ok = write_header(tfd);
if (ok)
ok = write_full(tfd, chkpt_record.data(), chkpt_record.size());
if (ok)
ok = (::fsync(tfd) == 0);
::close(tfd);
if (ok) {
if (kte::syscall::Fsync(tfd) != 0) {
int saved_errno = errno;
err = "Failed to fsync temp swap file '" + tmp_path + "': " + std::strerror(saved_errno);
ok = false;
}
}
kte::syscall::Close(tfd);
if (!ok) {
if (err.empty()) {
err = "Failed to write temp swap file: " + tmp_path;
}
std::remove(tmp_path.c_str());
return false;
}
// Atomic replace.
if (::rename(tmp_path.c_str(), ctx.path.c_str()) != 0) {
int saved_errno = errno;
err = "Failed to rename temp swap file '" + tmp_path + "' to '" + ctx.path + "': " + std::strerror(
saved_errno);
std::remove(tmp_path.c_str());
return false;
}
@@ -713,10 +778,10 @@ SwapManager::compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8
#ifdef O_DIRECTORY
dflags |= O_DIRECTORY;
#endif
int dfd = ::open(dir.string().c_str(), dflags);
int dfd = kte::syscall::Open(dir.string().c_str(), dflags);
if (dfd >= 0) {
(void) ::fsync(dfd);
::close(dfd);
(void) kte::syscall::Fsync(dfd);
kte::syscall::Close(dfd);
}
}
} catch (...) {
@@ -724,8 +789,10 @@ SwapManager::compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8
}
// Re-open for further appends.
if (!open_ctx(ctx, ctx.path))
if (!open_ctx(ctx, ctx.path, err)) {
// err already set by open_ctx
return false;
}
ctx.approx_size_bytes = 64 + static_cast<std::uint64_t>(chkpt_record.size());
return true;
}
@@ -970,7 +1037,13 @@ SwapManager::writer_loop()
continue;
for (const Pending &p: batch) {
try {
process_one(p);
} catch (const std::exception &e) {
report_error(std::string("Exception in process_one: ") + e.what(), p.buf);
} catch (...) {
report_error("Unknown exception in process_one", p.buf);
}
{
std::lock_guard<std::mutex> lg(mtx_);
if (p.seq > last_processed_)
@@ -982,6 +1055,7 @@ SwapManager::writer_loop()
}
// Throttled fsync: best-effort (grouped)
try {
std::vector<int> to_sync;
std::uint64_t now = now_ns();
{
@@ -998,7 +1072,12 @@ SwapManager::writer_loop()
}
}
for (int fd: to_sync) {
(void) ::fsync(fd);
(void) kte::syscall::Fsync(fd);
}
} catch (const std::exception &e) {
report_error(std::string("Exception in fsync operations: ") + e.what());
} catch (...) {
report_error("Unknown exception in fsync operations");
}
}
// Wake any waiters.
@@ -1011,6 +1090,36 @@ SwapManager::process_one(const Pending &p)
{
if (!p.buf)
return;
// Check circuit breaker before processing
bool circuit_open = false;
{
std::lock_guard<std::mutex> lg(mtx_);
if (!circuit_breaker_.AllowRequest()) {
circuit_open = true;
}
}
if (circuit_open) {
// Circuit is open - graceful degradation: skip swap write
// This prevents repeated failures from overwhelming the system
// Swap recording will resume when circuit closes
static std::atomic<std::uint64_t> last_warning_ns{0};
const std::uint64_t now = now_ns();
const std::uint64_t last = last_warning_ns.load();
// Log warning at most once per 60 seconds to avoid spam
if (now - last > 60000000000ULL) {
last_warning_ns.store(now);
ErrorHandler::Instance().Warning("SwapManager",
"Swap operations temporarily disabled due to repeated failures (circuit breaker open)",
p.buf && !p.buf->Filename().empty()
? p.buf->Filename()
: "<unnamed>");
}
return;
}
try {
Buffer &buf = *p.buf;
JournalCtx *ctxp = nullptr;
@@ -1029,10 +1138,23 @@ SwapManager::process_one(const Pending &p)
}
if (!ctxp)
return;
if (!open_ctx(*ctxp, path))
std::string open_err;
if (!open_ctx(*ctxp, path, open_err)) {
report_error(open_err, p.buf);
{
std::lock_guard<std::mutex> lg(mtx_);
circuit_breaker_.RecordFailure();
}
return;
if (p.payload.size() > 0xFFFFFFu)
}
if (p.payload.size() > 0xFFFFFFu) {
report_error("Payload too large: " + std::to_string(p.payload.size()) + " bytes", p.buf);
{
std::lock_guard<std::mutex> lg(mtx_);
circuit_breaker_.RecordFailure();
}
return;
}
// Build record: [type u8][len u24][payload][crc32 u32]
std::uint8_t len3[3];
@@ -1063,18 +1185,50 @@ SwapManager::process_one(const Pending &p)
// Write (handle partial writes and check results)
bool ok = write_full(ctxp->fd, rec.data(), rec.size());
if (ok) {
if (!ok) {
int err = errno;
report_error("Failed to write swap record to '" + path + "': " + std::strerror(err), p.buf);
{
std::lock_guard<std::mutex> lg(mtx_);
circuit_breaker_.RecordFailure();
}
return;
}
ctxp->approx_size_bytes += static_cast<std::uint64_t>(rec.size());
if (p.urgent_flush) {
(void) ::fsync(ctxp->fd);
if (kte::syscall::Fsync(ctxp->fd) != 0) {
int err = errno;
report_error("Failed to fsync swap file '" + path + "': " + std::strerror(err), p.buf);
}
ctxp->last_fsync_ns = now_ns();
}
if (p.type == SwapRecType::CHKPT && compact_bytes > 0 &&
ctxp->approx_size_bytes >= static_cast<std::uint64_t>(compact_bytes)) {
(void) compact_to_checkpoint(*ctxp, rec);
std::string compact_err;
if (!compact_to_checkpoint(*ctxp, rec, compact_err)) {
report_error(compact_err, p.buf);
// Note: compaction failure is not fatal, don't record circuit breaker failure
}
}
// Record success for circuit breaker
{
std::lock_guard<std::mutex> lg(mtx_);
circuit_breaker_.RecordSuccess();
}
} catch (const std::exception &e) {
report_error(std::string("Exception in process_one: ") + e.what(), p.buf);
{
std::lock_guard<std::mutex> lg(mtx_);
circuit_breaker_.RecordFailure();
}
} catch (...) {
report_error("Unknown exception in process_one", p.buf);
{
std::lock_guard<std::mutex> lg(mtx_);
circuit_breaker_.RecordFailure();
}
}
(void) ok; // best-effort; future work could mark ctx error state
}
@@ -1185,8 +1339,10 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
switch (type) {
case SwapRecType::INS: {
std::size_t off = 0;
if (payload.empty()) {
err = "Swap record missing INS payload";
// INS payload: encver(1) + row(4) + col(4) + nbytes(4) + data(nbytes)
// Minimum: 1 + 4 + 4 + 4 = 13 bytes
if (payload.size() < 13) {
err = "INS payload too short (need at least 13 bytes)";
return false;
}
const std::uint8_t encver = payload[off++];
@@ -1197,7 +1353,7 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
std::uint32_t row = 0, col = 0, nbytes = 0;
if (!parse_u32_le(payload, off, row) || !parse_u32_le(payload, off, col) || !parse_u32_le(
payload, off, nbytes)) {
err = "Malformed INS payload";
err = "Malformed INS payload (failed to parse row/col/nbytes)";
return false;
}
if (off + nbytes > payload.size()) {
@@ -1210,8 +1366,10 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
}
case SwapRecType::DEL: {
std::size_t off = 0;
if (payload.empty()) {
err = "Swap record missing DEL payload";
// DEL payload: encver(1) + row(4) + col(4) + dlen(4)
// Minimum: 1 + 4 + 4 + 4 = 13 bytes
if (payload.size() < 13) {
err = "DEL payload too short (need at least 13 bytes)";
return false;
}
const std::uint8_t encver = payload[off++];
@@ -1222,7 +1380,7 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
std::uint32_t row = 0, col = 0, dlen = 0;
if (!parse_u32_le(payload, off, row) || !parse_u32_le(payload, off, col) || !parse_u32_le(
payload, off, dlen)) {
err = "Malformed DEL payload";
err = "Malformed DEL payload (failed to parse row/col/dlen)";
return false;
}
buf.delete_text((int) row, (int) col, (std::size_t) dlen);
@@ -1230,8 +1388,10 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
}
case SwapRecType::SPLIT: {
std::size_t off = 0;
if (payload.empty()) {
err = "Swap record missing SPLIT payload";
// SPLIT payload: encver(1) + row(4) + col(4)
// Minimum: 1 + 4 + 4 = 9 bytes
if (payload.size() < 9) {
err = "SPLIT payload too short (need at least 9 bytes)";
return false;
}
const std::uint8_t encver = payload[off++];
@@ -1241,7 +1401,7 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
}
std::uint32_t row = 0, col = 0;
if (!parse_u32_le(payload, off, row) || !parse_u32_le(payload, off, col)) {
err = "Malformed SPLIT payload";
err = "Malformed SPLIT payload (failed to parse row/col)";
return false;
}
buf.split_line((int) row, (int) col);
@@ -1249,8 +1409,10 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
}
case SwapRecType::JOIN: {
std::size_t off = 0;
if (payload.empty()) {
err = "Swap record missing JOIN payload";
// JOIN payload: encver(1) + row(4)
// Minimum: 1 + 4 = 5 bytes
if (payload.size() < 5) {
err = "JOIN payload too short (need at least 5 bytes)";
return false;
}
const std::uint8_t encver = payload[off++];
@@ -1260,7 +1422,7 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
}
std::uint32_t row = 0;
if (!parse_u32_le(payload, off, row)) {
err = "Malformed JOIN payload";
err = "Malformed JOIN payload (failed to parse row)";
return false;
}
buf.join_lines((int) row);
@@ -1268,8 +1430,10 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
}
case SwapRecType::CHKPT: {
std::size_t off = 0;
// CHKPT payload: encver(1) + nbytes(4) + data(nbytes)
// Minimum: 1 + 4 = 5 bytes
if (payload.size() < 5) {
err = "Malformed CHKPT payload";
err = "CHKPT payload too short (need at least 5 bytes)";
return false;
}
const std::uint8_t encver = payload[off++];
@@ -1279,7 +1443,7 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
}
std::uint32_t nbytes = 0;
if (!parse_u32_le(payload, off, nbytes)) {
err = "Malformed CHKPT payload";
err = "Malformed CHKPT payload (failed to parse nbytes)";
return false;
}
if (off + nbytes > payload.size()) {
@@ -1296,4 +1460,61 @@ SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &
}
}
}
void
SwapManager::report_error(const std::string &message, Buffer *buf)
{
std::string context;
if (buf && !buf->Filename().empty()) {
context = buf->Filename();
} else if (buf) {
context = "<unnamed>";
} else {
context = "<unknown>";
}
// Report to centralized error handler
ErrorHandler::Instance().Error("SwapManager", message, context);
// Maintain local error tracking for backward compatibility
std::lock_guard<std::mutex> lg(mtx_);
SwapError err;
err.timestamp_ns = now_ns();
err.message = message;
err.buffer_name = context;
errors_.push_back(err);
// Bound the error queue to 100 entries
while (errors_.size() > 100) {
errors_.pop_front();
}
++total_error_count_;
}
bool
SwapManager::HasErrors() const
{
std::lock_guard<std::mutex> lg(mtx_);
return !errors_.empty();
}
std::string
SwapManager::GetLastError() const
{
std::lock_guard<std::mutex> lg(mtx_);
if (errors_.empty())
return "";
const SwapError &e = errors_.back();
return "[" + e.buffer_name + "] " + e.message;
}
std::size_t
SwapManager::GetErrorCount() const
{
std::lock_guard<std::mutex> lg(mtx_);
return total_error_count_;
}
} // namespace kte

33
Swap.h
View File

@@ -10,10 +10,12 @@
#include <memory>
#include <mutex>
#include <condition_variable>
#include <deque>
#include <thread>
#include <atomic>
#include "SwapRecorder.h"
#include "ErrorRecovery.h"
class Buffer;
@@ -131,6 +133,20 @@ public:
// 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:
@@ -190,11 +206,12 @@ private:
static bool write_header(int fd);
static bool open_ctx(JournalCtx &ctx, const std::string &path);
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);
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);
@@ -210,11 +227,14 @@ private:
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_;
std::mutex mtx_;
mutable std::mutex mtx_;
std::condition_variable cv_;
std::vector<Pending> queue_;
std::uint64_t next_seq_{0};
@@ -222,5 +242,12 @@ private:
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

76
SyscallWrappers.cc Normal file
View 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
View 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

View File

@@ -67,13 +67,20 @@ map_key_to_command(const int ch,
if (pressed) {
mouse_selecting = true;
Execute(*ed, CommandId::MoveCursorTo, std::string(buf));
if (Buffer *b = ed->CurrentBuffer()) {
b->SetMark(b->Curx(), b->Cury());
}
// 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;

View File

@@ -9,6 +9,7 @@ enum class UndoType : std::uint8_t {
Paste,
Newline,
DeleteRow,
InsertRow,
};
struct UndoNode {

View File

@@ -36,7 +36,8 @@ UndoSystem::Begin(UndoType type)
const int col = static_cast<int>(buf_->Curx());
// Some operations should always be standalone undo steps.
const bool always_standalone = (type == UndoType::Newline || type == UndoType::DeleteRow);
const bool always_standalone = (type == UndoType::Newline || type == UndoType::DeleteRow || type ==
UndoType::InsertRow);
if (always_standalone) {
commit();
}
@@ -75,6 +76,7 @@ UndoSystem::Begin(UndoType type)
}
case UndoType::Newline:
case UndoType::DeleteRow:
case UndoType::InsertRow:
break;
}
}
@@ -314,6 +316,15 @@ UndoSystem::apply(const UndoNode *node, int direction)
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;
}
}
@@ -411,6 +422,8 @@ UndoSystem::type_str(UndoType t)
return "Newline";
case UndoType::DeleteRow:
return "DeleteRow";
case UndoType::InsertRow:
return "InsertRow";
}
return "?";
}

View File

@@ -48,6 +48,7 @@ stdenv.mkDerivation {
"-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"
];
installPhase = ''

View File

@@ -11,7 +11,8 @@ codebase, make changes, and contribute effectively.
4. [Building and Testing](#building-and-testing)
5. [Making Changes](#making-changes)
6. [Code Style](#code-style)
7. [Common Tasks](#common-tasks)
7. [Error Handling Conventions](#error-handling-conventions)
8. [Common Tasks](#common-tasks)
## Architecture Overview
@@ -537,6 +538,491 @@ void maybeConsolidate() {
}
```
## Error Handling Conventions
kte uses standardized error handling patterns to ensure consistency and
reliability across the codebase. This section documents when to use each
pattern and how to integrate with the centralized error handling system.
### Error Propagation Patterns
kte uses three standard patterns for error handling:
#### 1. `bool` + `std::string &err` (I/O and Fallible Operations)
**When to use**: Operations that can fail and need detailed error
messages
(file I/O, network operations, parsing, resource allocation).
**Pattern**:
```cpp
bool OperationName(args..., std::string &err) {
err.clear();
// Attempt operation
if (/* operation failed */) {
err = "Detailed error message with context";
ErrorHandler::Instance().Error("ComponentName", err, "optional_context");
return false;
}
return true;
}
```
**Examples**:
- `Buffer::OpenFromFile(const std::string &path, std::string &err)`
- `Buffer::Save(std::string &err)`
-
`SwapManager::ReplayFile(Buffer &buf, const std::string &path, std::string &err)`
**Guidelines**:
- Always clear `err` at the start of the function
- Provide actionable error messages with context (file paths, operation
details)
- Call `ErrorHandler::Instance().Error()` for centralized logging
- Return `false` on failure, `true` on success
- Capture `errno` immediately after syscall failures: `int saved_errno =
errno;`
- Use `std::strerror(saved_errno)` for syscall error messages
#### 2. `void` (Infallible State Changes)
**When to use**: Operations that modify internal state and cannot fail
(setters, cursor movement, flag toggles).
**Pattern**:
```cpp
void SetProperty(Type value) {
property_ = value;
// Update related state if needed
}
```
**Examples**:
- `Buffer::SetCursor(std::size_t x, std::size_t y)`
- `Buffer::SetDirty(bool d)`
- `Editor::SetStatus(const std::string &msg)`
**Guidelines**:
- Use for simple state changes that cannot fail
- No error reporting needed
- Keep operations atomic and side-effect free when possible
#### 3. `bool` without error parameter (Control Flow)
**When to use**: Operations where success/failure is sufficient
information
and detailed error messages aren't needed (validation checks, control
flow
decisions).
**Pattern**:
```cpp
bool CheckCondition() const {
return condition_is_met;
}
```
**Examples**:
- `Editor::SwitchTo(std::size_t index)` - returns false if index invalid
- `Editor::CloseBuffer(std::size_t index)` - returns false if can't
close
**Guidelines**:
- Use when the caller only needs to know success/failure
- Typically for validation or control flow decisions
- Don't use for operations that need error diagnostics
### ErrorHandler Integration
All error-prone operations should report errors to the centralized
`ErrorHandler` for logging and UI integration.
**Severity Levels**:
```cpp
ErrorHandler::Instance().Info("Component", "message", "context"); // Informational
ErrorHandler::Instance().Warning("Component", "message", "context"); // Warning
ErrorHandler::Instance().Error("Component", "message", "context"); // Error
ErrorHandler::Instance().Critical("Component", "message", "context"); // Critical
```
**When to use each severity**:
- **Info**: Non-error events (file saved, operation completed)
- **Warning**: Recoverable issues (external file modification detected)
- **Error**: Operation failures (file I/O errors, allocation failures)
- **Critical**: Fatal errors (unhandled exceptions, data corruption)
**Component names**: Use the class name ("Buffer", "SwapManager",
"Editor", "main")
**Context**: Optional string providing additional context (filename,
buffer
name, operation details)
### Error Handling in Different Contexts
#### File I/O Operations
```cpp
bool Buffer::Save(std::string &err) const {
if (!is_file_backed_ || filename_.empty()) {
err = "Buffer is not file-backed; use SaveAs()";
return false;
}
const std::size_t sz = content_.Size();
const char *data = sz ? content_.Data() : nullptr;
if (!atomic_write_file(filename_, data ? data : "", sz, err)) {
ErrorHandler::Instance().Error("Buffer", err, filename_);
return false;
}
return true;
}
```
#### Syscall Error Handling with EINTR-Safe Wrappers
kte provides EINTR-safe syscall wrappers in `SyscallWrappers.h` that
automatically retry on `EINTR`. **Always use these wrappers instead of
direct syscalls.**
```cpp
#include "SyscallWrappers.h"
bool open_file(const std::string &path, std::string &err) {
int fd = kte::syscall::Open(path.c_str(), O_RDONLY);
if (fd < 0) {
int saved_errno = errno; // Capture immediately!
err = "Failed to open file '" + path + "': " + std::strerror(saved_errno);
ErrorHandler::Instance().Error("Component", err, path);
return false;
}
// ... use fd
kte::syscall::Close(fd);
return true;
}
```
**Available EINTR-safe wrappers**:
- `kte::syscall::Open(path, flags, mode)` - wraps `open(2)`
- `kte::syscall::Close(fd)` - wraps `close(2)`
- `kte::syscall::Fsync(fd)` - wraps `fsync(2)`
- `kte::syscall::Fstat(fd, buf)` - wraps `fstat(2)`
- `kte::syscall::Fchmod(fd, mode)` - wraps `fchmod(2)`
- `kte::syscall::Mkstemp(template)` - wraps `mkstemp(3)`
**Note**: `rename(2)` and `unlink(2)` are NOT wrapped because they
operate on filesystem metadata atomically and don't need EINTR retry.
#### Background Thread Errors
```cpp
void background_worker() {
try {
// ... work
} catch (const std::exception &e) {
std::string msg = std::string("Exception in worker: ") + e.what();
ErrorHandler::Instance().Error("WorkerThread", msg);
} catch (...) {
ErrorHandler::Instance().Error("WorkerThread", "Unknown exception");
}
}
```
#### Top-Level Exception Handling
```cpp
int main(int argc, char *argv[]) {
try {
// ... main logic
return 0;
} catch (const std::exception &e) {
std::string msg = std::string("Unhandled exception: ") + e.what();
ErrorHandler::Instance().Critical("main", msg);
std::cerr << "FATAL ERROR: " << e.what() << "\n";
return 1;
} catch (...) {
ErrorHandler::Instance().Critical("main", "Unknown exception");
std::cerr << "FATAL ERROR: Unknown exception\n";
return 1;
}
}
```
### Error Handling Anti-Patterns
**❌ Don't**: Silently ignore errors
```cpp
// BAD
void process() {
std::string err;
if (!operation(err)) {
// Error ignored!
}
}
```
**✅ Do**: Always handle or propagate errors
```cpp
// GOOD
bool process(std::string &err) {
if (!operation(err)) {
// err already set by operation()
return false;
}
return true;
}
```
**❌ Don't**: Use generic error messages
```cpp
// BAD
err = "Operation failed";
```
**✅ Do**: Provide specific, actionable error messages
```cpp
// GOOD
err = "Failed to open file '" + path + "': " + std::strerror(errno);
```
**❌ Don't**: Forget to capture errno
```cpp
// BAD
if (::write(fd, data, len) < 0) {
// errno might be overwritten by other calls!
err = std::strerror(errno);
}
```
**✅ Do**: Capture errno immediately
```cpp
// GOOD
if (::write(fd, data, len) < 0) {
int saved_errno = errno;
err = std::strerror(saved_errno);
}
```
### Error Log Location
All errors are automatically logged to:
```
~/.local/state/kte/error.log
```
Log format:
```
[2026-02-17 20:12:34.567] [ERROR] SwapManager (buffer.txt): Failed to write swap record
[2026-02-17 20:12:35.123] [CRITICAL] main: Unhandled exception: out of memory
```
### Migration Guide
When updating existing code to follow these conventions:
1. **Identify error-prone operations** - File I/O, syscalls, allocations
2. **Add `std::string &err` parameter** if not present
3. **Add ErrorHandler calls** at all error sites
4. **Capture errno** for syscall failures
5. **Update callers** to handle the error parameter
6. **Write tests** that verify error handling
### Error Recovery Mechanisms
kte implements automatic error recovery for transient failures using
retry logic and circuit breaker patterns.
#### Transient Error Classification
Transient errors are temporary failures that may succeed on retry:
```cpp
#include "ErrorRecovery.h"
bool IsTransientError(int err); // Returns true for EAGAIN, EWOULDBLOCK, EBUSY, EIO, ETIMEDOUT, ENOSPC, EDQUOT
```
**Transient errors**:
- `EAGAIN` / `EWOULDBLOCK` - Resource temporarily unavailable
- `EBUSY` - Device or resource busy
- `EIO` - I/O error (may be transient on network filesystems)
- `ETIMEDOUT` - Operation timed out
- `ENOSPC` - No space left on device (may become available)
- `EDQUOT` - Disk quota exceeded (may become available)
**Permanent errors** (don't retry):
- `ENOENT` - File not found
- `EACCES` - Permission denied
- `EINVAL` - Invalid argument
- `ENOTDIR` - Not a directory
#### Retry Policies
Three predefined retry policies are available:
```cpp
// Default: 3 attempts, 100ms initial delay, 2x backoff, 5s max delay
RetryPolicy::Default()
// Aggressive: 5 attempts, 50ms initial delay, 1.5x backoff, 2s max delay
// Use for critical operations (swap files, file saves)
RetryPolicy::Aggressive()
// Conservative: 2 attempts, 200ms initial delay, 2.5x backoff, 10s max delay
// Use for non-critical operations
RetryPolicy::Conservative()
```
#### Using RetryOnTransientError
Wrap syscalls with automatic retry on transient errors:
```cpp
#include "ErrorRecovery.h"
#include "SyscallWrappers.h"
bool save_file(const std::string &path, std::string &err) {
int fd = -1;
auto open_fn = [&]() -> bool {
fd = kte::syscall::Open(path.c_str(), O_CREAT | O_WRONLY, 0644);
return fd >= 0;
};
if (!kte::RetryOnTransientError(open_fn, kte::RetryPolicy::Aggressive(), err)) {
if (fd < 0) {
int saved_errno = errno;
err = "Failed to open file '" + path + "': " + std::strerror(saved_errno) + err;
}
return false;
}
// ... use fd
kte::syscall::Close(fd);
return true;
}
```
**Key points**:
- Lambda must return `bool` (true = success, false = failure)
- Lambda must set `errno` on failure for transient error detection
- Use EINTR-safe syscall wrappers (`kte::syscall::*`) inside lambdas
- Capture errno immediately after failure
- Append retry info to error message (automatically added by
RetryOnTransientError)
#### Circuit Breaker Pattern
The circuit breaker prevents repeated attempts to failing operations,
enabling graceful degradation.
**States**:
- **Closed** (normal): All requests allowed
- **Open** (failing): Requests rejected immediately, operation disabled
- **HalfOpen** (testing): Limited requests allowed to test recovery
**Configuration** (SwapManager example):
```cpp
CircuitBreaker::Config cfg;
cfg.failure_threshold = 5; // Open after 5 failures
cfg.timeout = std::chrono::seconds(30); // Try recovery after 30s
cfg.success_threshold = 2; // Close after 2 successes in HalfOpen
cfg.window = std::chrono::seconds(60); // Count failures in 60s window
CircuitBreaker breaker(cfg);
```
**Usage**:
```cpp
// Check before operation
if (!breaker.AllowRequest()) {
// Circuit is open - graceful degradation
log_warning("Operation disabled due to repeated failures");
return; // Skip operation
}
// Perform operation
if (operation_succeeds()) {
breaker.RecordSuccess();
} else {
breaker.RecordFailure();
}
```
**SwapManager Integration**:
The SwapManager uses a circuit breaker to handle repeated swap file
failures:
1. After 5 swap write failures in 60 seconds, circuit opens
2. Swap recording is disabled (graceful degradation)
3. Warning logged once per 60 seconds to avoid spam
4. After 30 seconds, circuit enters HalfOpen state
5. If 2 consecutive operations succeed, circuit closes and swap
recording resumes
This ensures the editor remains functional even when swap files are
unavailable (disk full, quota exceeded, filesystem errors).
#### Graceful Degradation Strategies
When operations fail repeatedly:
1. **Disable non-critical features** - Swap recording can be disabled
without affecting editing
2. **Log warnings** - Inform user of degraded operation via ErrorHandler
3. **Rate-limit warnings** - Avoid log spam (e.g., once per 60 seconds)
4. **Automatic recovery** - Circuit breaker automatically tests recovery
5. **Preserve core functionality** - Editor remains usable without swap
files
**Example** (from SwapManager):
```cpp
if (circuit_open) {
// Graceful degradation: skip swap write
static std::atomic<std::uint64_t> last_warning_ns{0};
const std::uint64_t now = now_ns();
if (now - last_warning_ns.load() > 60000000000ULL) {
last_warning_ns.store(now);
ErrorHandler::Instance().Warning("SwapManager",
"Swap operations temporarily disabled due to repeated failures",
buffer_name);
}
return; // Skip operation, editor continues normally
}
```
## Common Tasks
### Adding a New Command

View File

@@ -0,0 +1,549 @@
# Error Propagation Standardization Report
**Project:** kte (Kyle's Text Editor)
**Date:** 2026-02-17
**Auditor:** Error Propagation Standardization Review
**Language:** C++20
---
## Executive Summary
This report documents the standardization of error propagation patterns
across the kte codebase. Following the implementation of centralized
error handling (ErrorHandler), this audit identifies inconsistencies in
error propagation and provides concrete remediation recommendations.
**Key Findings:**
- **Dominant Pattern**: `bool + std::string &err` is used consistently
in Buffer and SwapManager for I/O operations
- **Inconsistencies**: PieceTable has no error reporting mechanism; some
internal helpers lack error propagation
- **Standard Chosen**: `bool + std::string &err` pattern (C++20 project,
std::expected not available)
- **Documentation**: Comprehensive error handling conventions added to
DEVELOPER_GUIDE.md
**Overall Assessment**: The codebase has a **solid foundation** with the
`bool + err` pattern used consistently in critical I/O paths. Primary
gaps are in PieceTable memory allocation error handling and some
internal helper functions.
---
## 1. CURRENT STATE ANALYSIS
### 1.1 Error Propagation Patterns Found
#### Pattern 1: `bool + std::string &err` (Dominant)
**Usage**: File I/O, swap operations, resource allocation
**Examples**:
- `Buffer::OpenFromFile(const std::string &path, std::string &err)` (
Buffer.h:72)
- `Buffer::Save(std::string &err)` (Buffer.h:74)
- `Buffer::SaveAs(const std::string &path, std::string &err)` (Buffer.h:
75)
- `Editor::OpenFile(const std::string &path, std::string &err)` (
Editor.h:536)
-
`SwapManager::ReplayFile(Buffer &buf, const std::string &swap_path, std::string &err)` (
Swap.h:104)
-
`SwapManager::open_ctx(JournalCtx &ctx, const std::string &path, std::string &err)` (
Swap.h:208)
-
`SwapManager::compact_to_checkpoint(JournalCtx &ctx, const std::vector<std::uint8_t> &chkpt_record, std::string &err)` (
Swap.h:212-213)
**Assessment**: ✅ **Excellent** - Consistent, well-implemented,
integrated with ErrorHandler
#### Pattern 2: `void` (State Changes)
**Usage**: Setters, cursor movement, flag toggles, internal state
modifications
**Examples**:
- `Buffer::SetCursor(std::size_t x, std::size_t y)` (Buffer.h:348)
- `Buffer::SetDirty(bool d)` (Buffer.h:368)
- `Buffer::SetMark(std::size_t x, std::size_t y)` (Buffer.h:387)
- `Buffer::insert_text(int row, int col, std::string_view text)` (
Buffer.h:545)
- `Buffer::delete_text(int row, int col, std::size_t len)` (Buffer.h:
547)
- `Editor::SetStatus(const std::string &msg)` (Editor.h:various)
**Assessment**: ✅ **Appropriate** - These operations are infallible
state changes
#### Pattern 3: `bool` without error parameter (Control Flow)
**Usage**: Validation checks, control flow decisions
**Examples**:
- `Editor::ProcessPendingOpens()` (Editor.h:544)
- `Editor::ResolveRecoveryPrompt(bool yes)` (Editor.h:558)
- `Editor::SwitchTo(std::size_t index)` (Editor.h:563)
- `Editor::CloseBuffer(std::size_t index)` (Editor.h:565)
**Assessment**: ✅ **Appropriate** - Success/failure is sufficient for
control flow
#### Pattern 4: No Error Reporting (PieceTable)
**Usage**: Memory allocation, text manipulation
**Examples**:
- `void PieceTable::Reserve(std::size_t newCapacity)` (PieceTable.h:71)
- `void PieceTable::Append(const char *s, std::size_t len)` (
PieceTable.h:75)
-
`void PieceTable::Insert(std::size_t byte_offset, const char *text, std::size_t len)` (
PieceTable.h:118)
- `char *PieceTable::Data()` (PieceTable.h:89-93) - returns nullptr on
allocation failure
**Assessment**: ⚠️ **Gap** - Memory allocation failures are not reported
---
## 2. STANDARDIZATION DECISION
### 2.1 Chosen Pattern: `bool + std::string &err`
**Rationale**:
1. **C++20 Project**: `std::expected` (C++23) is not available
2. **Existing Adoption**: Already used consistently in Buffer,
SwapManager, Editor for I/O operations
3. **Clear Semantics**: `bool` return indicates success/failure, `err`
provides details
4. **ErrorHandler Integration**: Works seamlessly with centralized error
logging
5. **Zero Overhead**: No exceptions, no dynamic allocation for error
paths
6. **Testability**: Easy to verify error messages in unit tests
**Alternative Considered**: `std::expected<T, std::string>` (C++23)
- **Rejected**: Requires C++23, would require major refactoring, not
available in current toolchain
### 2.2 Pattern Selection Guidelines
| Operation Type | Pattern | Example |
|---------------------|---------------------------|-----------------------------------------------------------------------------------|
| File I/O | `bool + std::string &err` | `Buffer::Save(std::string &err)` |
| Syscalls | `bool + std::string &err` | `open_ctx(JournalCtx &ctx, const std::string &path, std::string &err)` |
| Resource Allocation | `bool + std::string &err` | Future: `PieceTable::Reserve(std::size_t cap, std::string &err)` |
| Parsing/Validation | `bool + std::string &err` | `SwapManager::ReplayFile(Buffer &buf, const std::string &path, std::string &err)` |
| State Changes | `void` | `Buffer::SetCursor(std::size_t x, std::size_t y)` |
| Control Flow | `bool` (no err) | `Editor::SwitchTo(std::size_t index)` |
---
## 3. INCONSISTENCIES AND GAPS
### 3.1 PieceTable Memory Allocation (Severity: 6/10)
**Finding**: PieceTable methods that allocate memory (`Reserve`,
`Append`, `Insert`, `Data`) do not report allocation failures.
**Impact**:
- Memory allocation failures are silent
- `Data()` returns `nullptr` on failure, but callers may not check
- Large file operations could fail without user notification
**Evidence**:
```cpp
// PieceTable.h:71
void Reserve(std::size_t newCapacity); // No error reporting
// PieceTable.h:89-93
char *Data(); // Returns nullptr on allocation failure
```
**Remediation Priority**: **Medium** - Memory allocation failures are
rare on modern systems, but should be handled for robustness
**Recommended Fix**:
**Option 1: Add error parameter to fallible operations** (Preferred)
```cpp
// PieceTable.h
bool Reserve(std::size_t newCapacity, std::string &err);
bool Append(const char *s, std::size_t len, std::string &err);
bool Insert(std::size_t byte_offset, const char *text, std::size_t len, std::string &err);
// Returns nullptr on failure; check with HasMaterializationError()
char *Data();
bool HasMaterializationError() const;
std::string GetMaterializationError() const;
```
**Option 2: Use exceptions for allocation failures** (Not recommended)
PieceTable could throw `std::bad_alloc` on allocation failures, but this
conflicts with the project's error handling philosophy and would require
exception handling throughout the codebase.
**Option 3: Status quo with improved documentation** (Minimal change)
Document that `Data()` can return `nullptr` and callers must check. Add
assertions in debug builds.
```cpp
// PieceTable.h
// Returns pointer to materialized buffer, or nullptr if materialization fails.
// Callers MUST check for nullptr before dereferencing.
char *Data();
```
**Recommendation**: **Option 3** for now (document + assertions), *
*Option 1** if memory allocation errors become a concern in production.
### 3.2 Internal Helper Functions (Severity: 4/10)
**Finding**: Some internal helper functions in Swap.cc and Buffer.cc use
`bool` returns without error parameters.
**Examples**:
```cpp
// Swap.cc:562
static bool ensure_parent_dir(const std::string &path); // No error details
// Swap.cc:579
static bool write_header(int fd); // No error details
// Buffer.cc:101
static bool write_all_fd(int fd, const char *data, std::size_t len, std::string &err); // ✅ Good
```
**Impact**: Limited - These are internal helpers called by functions
that do report errors
**Remediation Priority**: **Low** - Callers already provide error
context
**Recommended Fix**: Add error parameters to internal helpers for
consistency
```cpp
// Swap.cc
static bool ensure_parent_dir(const std::string &path, std::string &err);
static bool write_header(int fd, std::string &err);
```
**Status**: **Deferred** - Low priority, callers already provide
adequate error context
### 3.3 Editor Control Flow Methods (Severity: 2/10)
**Finding**: Editor methods like `SwitchTo()`, `CloseBuffer()` return
`bool` without error details.
**Assessment**: ✅ **Appropriate** - These are control flow decisions
where success/failure is sufficient
**Remediation**: **None needed** - Current pattern is correct for this
use case
---
## 4. ERRORHANDLER INTEGRATION STATUS
### 4.1 Components with ErrorHandler Integration
**Buffer** (Buffer.cc)
- `OpenFromFile()` - Reports file open, seek, read errors
- `Save()` - Reports write errors
- `SaveAs()` - Reports write errors
**SwapManager** (Swap.cc)
- `report_error()` - All swap file errors reported
- Background thread errors captured and logged
- Errno captured for all syscalls
**main** (main.cc)
- Top-level exception handler reports Critical errors
- Both `std::exception` and unknown exceptions captured
### 4.2 Components Without ErrorHandler Integration
⚠️ **PieceTable** (PieceTable.cc)
- No error reporting mechanism
- Memory allocation failures are silent
⚠️ **Editor** (Editor.cc)
- File operations delegate to Buffer (✅ covered)
- Control flow methods don't need error reporting (✅ appropriate)
⚠️ **Command** (Command.cc)
- Commands use `Editor::SetStatus()` for user-facing messages
- No ErrorHandler integration for command failures
- **Assessment**: Commands are user-initiated actions; status messages
are appropriate
---
## 5. DOCUMENTATION STATUS
### 5.1 Error Handling Conventions (DEVELOPER_GUIDE.md)
**Added comprehensive section** covering:
- Three standard error propagation patterns
- Pattern selection guidelines with decision tree
- ErrorHandler integration requirements
- Code examples for file I/O, syscalls, background threads, top-level
handlers
- Anti-patterns and best practices
- Error log location and format
- Migration guide for updating existing code
**Location**: `docs/DEVELOPER_GUIDE.md` section 7
### 5.2 API Documentation
⚠️ **Gap**: Individual function documentation in headers could be
improved
**Recommendation**: Add brief comments to public APIs documenting error
behavior
```cpp
// Buffer.h
// Opens a file and loads its content into the buffer.
// Returns false on failure; err contains detailed error message.
// Errors are logged to ErrorHandler.
bool OpenFromFile(const std::string &path, std::string &err);
```
---
## 6. REMEDIATION RECOMMENDATIONS
### 6.1 High Priority (Severity 7-10)
**None identified** - Critical error handling gaps were addressed in
previous sessions:
- ✅ Top-level exception handler added (Severity 9/10)
- ✅ Background thread error reporting added (Severity 9/10)
- ✅ File I/O error checking added (Severity 8/10)
- ✅ Errno capture added to swap operations (Severity 7/10)
- ✅ Centralized error handling implemented (Severity 7/10)
### 6.2 Medium Priority (Severity 4-6)
#### 6.2.1 PieceTable Memory Allocation Error Handling (Severity: 6/10)
**Action**: Document that `Data()` can return `nullptr` and add debug
assertions
**Implementation**:
```cpp
// PieceTable.h
// Returns pointer to materialized buffer, or nullptr if materialization fails
// due to memory allocation error. Callers MUST check for nullptr.
char *Data();
// PieceTable.cc
char *PieceTable::Data() {
materialize();
assert(materialized_ != nullptr && "PieceTable materialization failed");
return materialized_;
}
```
**Effort**: Low (documentation + assertions)
**Risk**: Low (no API changes)
**Timeline**: Next maintenance cycle
#### 6.2.2 Add Error Parameters to Internal Helpers (Severity: 4/10)
**Action**: Add `std::string &err` parameters to `ensure_parent_dir()`
and `write_header()`
**Implementation**:
```cpp
// Swap.cc
static bool ensure_parent_dir(const std::string &path, std::string &err) {
try {
fs::path p(path);
fs::path dir = p.parent_path();
if (dir.empty())
return true;
if (!fs::exists(dir))
fs::create_directories(dir);
return true;
} catch (const std::exception &e) {
err = std::string("Failed to create directory: ") + e.what();
return false;
} catch (...) {
err = "Failed to create directory: unknown error";
return false;
}
}
```
**Effort**: Low (update 2 functions + call sites)
**Risk**: Low (internal helpers only)
**Timeline**: Next maintenance cycle
### 6.3 Low Priority (Severity 1-3)
#### 6.3.1 Add Function-Level Error Documentation (Severity: 3/10)
**Action**: Add brief comments to public APIs documenting error behavior
**Effort**: Medium (many functions to document)
**Risk**: None (documentation only)
**Timeline**: Ongoing as code is touched
#### 6.3.2 Add ErrorHandler Integration to Commands (Severity: 2/10)
**Action**: Consider logging command failures to ErrorHandler for
diagnostics
**Assessment**: **Not recommended** - Commands are user-initiated
actions; status messages are more appropriate than error logs
---
## 7. TESTING RECOMMENDATIONS
### 7.1 Error Handling Test Coverage
**Current State**:
- ✅ Swap file error handling tested (test_swap_edge_cases.cc)
- ✅ Buffer I/O error handling tested (test_buffer_io.cc)
- ⚠️ PieceTable allocation failure testing missing
**Recommendations**:
1. **Add PieceTable allocation failure tests** (if Option 1 from 3.1 is
implemented)
2. **Add ErrorHandler query tests** - Verify error logging and retrieval
3. **Add errno capture tests** - Verify errno is captured correctly in
syscall failures
### 7.2 Test Examples
```cpp
// test_error_handler.cc
TEST(ErrorHandler, LogsErrorsWithContext) {
ErrorHandler::Instance().Error("TestComponent", "Test error", "test.txt");
EXPECT_TRUE(ErrorHandler::Instance().HasErrors());
EXPECT_EQ(ErrorHandler::Instance().GetErrorCount(), 1);
std::string last = ErrorHandler::Instance().GetLastError();
EXPECT_TRUE(last.find("Test error") != std::string::npos);
EXPECT_TRUE(last.find("test.txt") != std::string::npos);
}
// test_piece_table.cc (if Option 1 implemented)
TEST(PieceTable, ReportsAllocationFailure) {
PieceTable pt;
std::string err;
// Attempt to allocate huge buffer
bool ok = pt.Reserve(SIZE_MAX, err);
EXPECT_FALSE(ok);
EXPECT_FALSE(err.empty());
}
```
---
## 8. MIGRATION CHECKLIST
For developers updating existing code to follow error handling
conventions:
- [ ] Identify all error-prone operations (file I/O, syscalls,
allocations)
- [ ] Add `std::string &err` parameter if not present
- [ ] Clear `err` at function start: `err.clear();`
- [ ] Capture `errno` immediately after syscall failures:
`int saved_errno = errno;`
- [ ] Build detailed error messages with context (paths, operation
details)
- [ ] Call `ErrorHandler::Instance().Error()` at all error sites
- [ ] Return `false` on failure, `true` on success
- [ ] Update all call sites to handle the error parameter
- [ ] Write unit tests that verify error handling
- [ ] Update function documentation to describe error behavior
---
## 9. SUMMARY AND NEXT STEPS
### 9.1 Achievements
**Standardized on `bool + std::string &err` pattern** for error-prone
operations
**Documented comprehensive error handling conventions** in
DEVELOPER_GUIDE.md
**Identified and prioritized remaining gaps** (PieceTable, internal
helpers)
**Integrated ErrorHandler** into Buffer, SwapManager, and main
**Established clear pattern selection guidelines** for future
development
### 9.2 Remaining Work
**Medium Priority**:
1. Document PieceTable `Data()` nullptr behavior and add assertions
2. Add error parameters to internal helper functions
**Low Priority**:
3. Add function-level error documentation to public APIs
4. Add ErrorHandler query tests
### 9.3 Conclusion
The kte codebase has achieved **strong error handling consistency** with
the `bool + std::string &err` pattern used uniformly across critical I/O
paths. The centralized ErrorHandler provides comprehensive logging and
UI integration. Remaining gaps are minor and primarily affect edge
cases (memory allocation failures) that are rare in practice.
**Overall Grade**: **B+ (8.5/10)**
**Strengths**:
- Consistent error propagation in Buffer and SwapManager
- Comprehensive ErrorHandler integration
- Excellent documentation in DEVELOPER_GUIDE.md
- Errno capture for all syscalls
- Top-level exception handling
**Areas for Improvement**:
- PieceTable memory allocation error handling
- Internal helper function error propagation
- Function-level API documentation
The error handling infrastructure is **production-ready** and provides a
solid foundation for reliable operation and debugging.

26
main.cc
View File

@@ -20,6 +20,7 @@
#include "Editor.h"
#include "Frontend.h"
#include "TerminalFrontend.h"
#include "ErrorHandler.h"
#if defined(KTE_BUILD_GUI)
#if defined(KTE_USE_QT)
@@ -116,6 +117,9 @@ main(int argc, char *argv[])
{
std::setlocale(LC_ALL, "");
// Ensure the error handler (and its log file) is initialised early.
kte::ErrorHandler::Instance();
Editor editor;
// CLI parsing using getopt_long
@@ -181,10 +185,13 @@ main(int argc, char *argv[])
return RunStressHighlighter(stress_seconds);
}
// Top-level exception handler to prevent data loss and ensure cleanup
try {
// Determine frontend
#if !defined(KTE_BUILD_GUI)
if (req_gui) {
std::cerr << "kte: GUI not built. Reconfigure with -DBUILD_GUI=ON and required deps installed." <<
std::cerr << "kte: GUI not built. Reconfigure with -DBUILD_GUI=ON and required deps installed."
<<
std::endl;
return 2;
}
@@ -195,6 +202,9 @@ main(int argc, char *argv[])
} else if (req_term) {
use_gui = false;
} else {
// Default depends on build target: kge defaults to GUI, kte to terminal
#if defined(KTE_DEFAULT_GUI)
use_gui = true;
@@ -301,4 +311,18 @@ main(int argc, char *argv[])
fe->Shutdown();
return 0;
} catch (const std::exception &e) {
std::string msg = std::string("Unhandled exception: ") + e.what();
kte::ErrorHandler::Instance().Critical("main", msg);
std::cerr << "\n*** FATAL ERROR ***\n"
<< "kte encountered an unhandled exception: " << e.what() << "\n"
<< "The editor will now exit. Any unsaved changes may be recovered from swap files.\n";
return 1;
} catch (...) {
kte::ErrorHandler::Instance().Critical("main", "Unknown exception");
std::cerr << "\n*** FATAL ERROR ***\n"
<< "kte encountered an unknown exception.\n"
<< "The editor will now exit. Any unsaved changes may be recovered from swap files.\n";
return 1;
}
}

69
tests/test_reflow_undo.cc Normal file
View File

@@ -0,0 +1,69 @@
#include "Test.h"
#include "Buffer.h"
#include "Command.h"
#include "Editor.h"
#include "UndoSystem.h"
#include <string>
static std::string
to_string_rows(const Buffer &buf)
{
std::string out;
for (const auto &r: buf.Rows()) {
out += static_cast<std::string>(r);
out.push_back('\n');
}
return out;
}
TEST (ReflowUndo)
{
InstallDefaultCommands();
Editor ed;
ed.SetDimensions(24, 80);
Buffer b;
const std::string initial =
"This is a very long line that should be reflowed into multiple lines to see if undo works correctly.\n";
b.insert_text(0, 0, initial);
b.SetCursor(0, 0);
// Commit initial insertion so it's its own undo step
if (auto *u = b.Undo())
u->commit();
ed.AddBuffer(std::move(b));
Buffer *buf = ed.CurrentBuffer();
ASSERT_TRUE(buf != nullptr);
const std::string original_dump = to_string_rows(*buf);
// Reflow with small width
const int width = 20;
ASSERT_TRUE(Execute(ed, "reflow-paragraph", "", width));
const std::string reflowed_dump = to_string_rows(*buf);
ASSERT_TRUE(reflowed_dump != original_dump);
ASSERT_TRUE(buf->Rows().size() > 1);
// Undo reflow
ASSERT_TRUE(Execute(ed, "undo", "", 1));
const std::string after_undo_dump = to_string_rows(*buf);
if (after_undo_dump != original_dump) {
fprintf(stderr, "Undo failed.\nExpected:\n%s\nGot:\n%s\n", original_dump.c_str(),
after_undo_dump.c_str());
}
EXPECT_TRUE(after_undo_dump == original_dump);
// Redo reflow
ASSERT_TRUE(Execute(ed, "redo", "", 1));
const std::string after_redo_dump = to_string_rows(*buf);
EXPECT_TRUE(after_redo_dump == reflowed_dump);
}

View File

@@ -0,0 +1,79 @@
#include "Test.h"
#include "Buffer.h"
#include "Editor.h"
#include "Command.h"
#include <string>
TEST (SmartNewline_AutoIndent)
{
Editor editor;
InstallDefaultCommands();
Buffer &buf = editor.Buffers().emplace_back();
// Set up initial state: " line1"
buf.insert_text(0, 0, " line1");
buf.SetCursor(7, 0); // At end of line
// Execute SmartNewline
bool ok = Execute(editor, CommandId::SmartNewline);
ASSERT_TRUE(ok);
// Should have two lines now
ASSERT_EQ(buf.Nrows(), 2);
// Line 0 remains " line1"
ASSERT_EQ(buf.GetLineString(0), " line1");
// Line 1 should have " " (two spaces)
ASSERT_EQ(buf.GetLineString(1), " ");
// Cursor should be at (2, 1)
ASSERT_EQ(buf.Curx(), 2);
ASSERT_EQ(buf.Cury(), 1);
}
TEST (SmartNewline_TabIndent)
{
Editor editor;
InstallDefaultCommands();
Buffer &buf = editor.Buffers().emplace_back();
// Set up initial state: "\tline1"
buf.insert_text(0, 0, "\tline1");
buf.SetCursor(6, 0); // At end of line
// Execute SmartNewline
bool ok = Execute(editor, CommandId::SmartNewline);
ASSERT_TRUE(ok);
// Should have two lines now
ASSERT_EQ(buf.Nrows(), 2);
// Line 1 should have "\t"
ASSERT_EQ(buf.GetLineString(1), "\t");
// Cursor should be at (1, 1)
ASSERT_EQ(buf.Curx(), 1);
ASSERT_EQ(buf.Cury(), 1);
}
TEST (SmartNewline_NoIndent)
{
Editor editor;
InstallDefaultCommands();
Buffer &buf = editor.Buffers().emplace_back();
// Set up initial state: "line1"
buf.insert_text(0, 0, "line1");
buf.SetCursor(5, 0); // At end of line
// Execute SmartNewline
bool ok = Execute(editor, CommandId::SmartNewline);
ASSERT_TRUE(ok);
// Should have two lines now
ASSERT_EQ(buf.Nrows(), 2);
// Line 1 should be empty
ASSERT_EQ(buf.GetLineString(1), "");
// Cursor should be at (0, 1)
ASSERT_EQ(buf.Curx(), 0);
ASSERT_EQ(buf.Cury(), 1);
}

125
tests/test_swap_cleanup2.cc Normal file
View File

@@ -0,0 +1,125 @@
#include "Test.h"
#include "Command.h"
#include "Editor.h"
#include "tests/TestHarness.h"
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <string>
#include <unistd.h>
namespace fs = std::filesystem;
static void
write_file_bytes(const std::string &path, const std::string &bytes)
{
std::ofstream out(path, std::ios::binary | std::ios::trunc);
out.write(bytes.data(), (std::streamsize) bytes.size());
}
// RAII helper to set XDG_STATE_HOME for the duration of a test and clean up.
struct XdgStateGuard {
fs::path root;
std::string old_xdg;
bool had_old;
explicit XdgStateGuard(const std::string &suffix)
{
root = fs::temp_directory_path() /
(std::string("kte_ut_xdg_") + suffix + "_" + std::to_string((int) ::getpid()));
fs::remove_all(root);
fs::create_directories(root);
const char *p = std::getenv("XDG_STATE_HOME");
had_old = (p != nullptr);
if (p)
old_xdg = p;
setenv("XDG_STATE_HOME", root.string().c_str(), 1);
}
~XdgStateGuard()
{
if (had_old)
setenv("XDG_STATE_HOME", old_xdg.c_str(), 1);
else
unsetenv("XDG_STATE_HOME");
fs::remove_all(root);
}
};
TEST(SwapCleanup_SaveAndQuit)
{
ktet::InstallDefaultCommandsOnce();
XdgStateGuard xdg("save_quit");
const std::string path = (xdg.root / "work" / "file.txt").string();
fs::create_directories(xdg.root / "work");
write_file_bytes(path, "hello\n");
Editor ed;
ed.SetDimensions(24, 80);
ed.AddBuffer(Buffer());
std::string err;
ASSERT_TRUE(ed.OpenFile(path, err));
Buffer *b = ed.CurrentBuffer();
ASSERT_TRUE(b != nullptr);
// Edit to create swap file
ASSERT_TRUE(Execute(ed, CommandId::MoveFileStart));
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "Z"));
ASSERT_TRUE(b->Dirty());
ed.Swap()->Flush(b);
const std::string swp = kte::SwapManager::ComputeSwapPathForTests(*b);
ASSERT_TRUE(fs::exists(swp));
// Save-and-quit should clean up the swap file
ASSERT_TRUE(Execute(ed, CommandId::SaveAndQuit));
ed.Swap()->Flush(b);
ASSERT_TRUE(!fs::exists(swp));
// Cleanup
std::remove(path.c_str());
}
TEST(SwapCleanup_EditorReset)
{
ktet::InstallDefaultCommandsOnce();
XdgStateGuard xdg("editor_reset");
const std::string path = (xdg.root / "work" / "file.txt").string();
fs::create_directories(xdg.root / "work");
write_file_bytes(path, "hello\n");
Editor ed;
ed.SetDimensions(24, 80);
ed.AddBuffer(Buffer());
std::string err;
ASSERT_TRUE(ed.OpenFile(path, err));
Buffer *b = ed.CurrentBuffer();
ASSERT_TRUE(b != nullptr);
// Edit to create swap file
ASSERT_TRUE(Execute(ed, CommandId::MoveFileStart));
ASSERT_TRUE(Execute(ed, CommandId::InsertText, "W"));
ASSERT_TRUE(b->Dirty());
ed.Swap()->Flush(b);
const std::string swp = kte::SwapManager::ComputeSwapPathForTests(*b);
ASSERT_TRUE(fs::exists(swp));
// Reset (simulates clean editor exit) should remove swap files
ed.Reset();
ASSERT_TRUE(!fs::exists(swp));
// Cleanup
std::remove(path.c_str());
}

View File

@@ -0,0 +1,814 @@
#include "Test.h"
#include "Buffer.h"
#include "Swap.h"
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <fstream>
#include <string>
#include <vector>
// CRC32 helper (same algorithm as SwapManager::crc32)
static std::uint32_t
crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed = 0)
{
static std::uint32_t table[256];
static bool inited = false;
if (!inited) {
for (std::uint32_t i = 0; i < 256; ++i) {
std::uint32_t c = i;
for (int j = 0; j < 8; ++j)
c = (c & 1) ? (0xEDB88320u ^ (c >> 1)) : (c >> 1);
table[i] = c;
}
inited = true;
}
std::uint32_t c = ~seed;
for (std::size_t i = 0; i < len; ++i)
c = table[(c ^ data[i]) & 0xFFu] ^ (c >> 8);
return ~c;
}
// Build a valid 64-byte swap file header
static std::string
build_swap_header()
{
std::uint8_t hdr[64];
std::memset(hdr, 0, sizeof(hdr));
// Magic
const std::uint8_t magic[8] = {'K', 'T', 'E', '_', 'S', 'W', 'P', '\0'};
std::memcpy(hdr, magic, 8);
// Version = 1 (little-endian)
hdr[8] = 1;
hdr[9] = 0;
hdr[10] = 0;
hdr[11] = 0;
// Flags = 0
// Created time (just use 0 for tests)
return std::string(reinterpret_cast<char *>(hdr), sizeof(hdr));
}
// Build a swap record: [type u8][len u24][payload][crc32 u32]
static std::string
build_swap_record(std::uint8_t type, const std::vector<std::uint8_t> &payload)
{
std::vector<std::uint8_t> record;
// Record header: type(1) + length(3)
record.push_back(type);
std::uint32_t len = static_cast<std::uint32_t>(payload.size());
record.push_back(static_cast<std::uint8_t>(len & 0xFFu));
record.push_back(static_cast<std::uint8_t>((len >> 8) & 0xFFu));
record.push_back(static_cast<std::uint8_t>((len >> 16) & 0xFFu));
// Payload
record.insert(record.end(), payload.begin(), payload.end());
// CRC32 (compute over header + payload)
std::uint32_t crc = crc32(record.data(), record.size());
record.push_back(static_cast<std::uint8_t>(crc & 0xFFu));
record.push_back(static_cast<std::uint8_t>((crc >> 8) & 0xFFu));
record.push_back(static_cast<std::uint8_t>((crc >> 16) & 0xFFu));
record.push_back(static_cast<std::uint8_t>((crc >> 24) & 0xFFu));
return std::string(reinterpret_cast<char *>(record.data()), record.size());
}
// Build complete swap file with header and records
static std::string
build_swap_file(const std::vector<std::string> &records)
{
std::string file = build_swap_header();
for (const auto &rec: records) {
file += rec;
}
return file;
}
// Write bytes to file
static void
write_file_bytes(const std::string &path, const std::string &bytes)
{
std::ofstream out(path, std::ios::binary | std::ios::trunc);
out.write(bytes.data(), static_cast<std::streamsize>(bytes.size()));
}
// Helper to encode u32 little-endian
static void
put_u32_le(std::vector<std::uint8_t> &out, std::uint32_t v)
{
out.push_back(static_cast<std::uint8_t>(v & 0xFFu));
out.push_back(static_cast<std::uint8_t>((v >> 8) & 0xFFu));
out.push_back(static_cast<std::uint8_t>((v >> 16) & 0xFFu));
out.push_back(static_cast<std::uint8_t>((v >> 24) & 0xFFu));
}
//=============================================================================
// 1. MINIMUM VALID PAYLOAD SIZE TESTS
//=============================================================================
TEST (SwapEdge_INS_MinimumValidPayload)
{
const std::string path = "./.kte_ut_edge_ins_min.txt";
const std::string swap_path = "./.kte_ut_edge_ins_min.swp";
std::remove(path.c_str());
std::remove(swap_path.c_str());
write_file_bytes(path, "hello\n");
// INS record: encver(1) + row(4) + col(4) + nbytes(4) = 13 bytes minimum
// nbytes=0 means zero-length insertion
std::vector<std::uint8_t> payload;
payload.push_back(1); // encver
put_u32_le(payload, 0); // row
put_u32_le(payload, 0); // col
put_u32_le(payload, 0); // nbytes=0
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload);
std::string file = build_swap_file({rec});
write_file_bytes(swap_path, file);
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
ASSERT_TRUE(kte::SwapManager::ReplayFile(b, swap_path, err));
std::remove(path.c_str());
std::remove(swap_path.c_str());
}
TEST (SwapEdge_DEL_MinimumValidPayload)
{
const std::string path = "./.kte_ut_edge_del_min.txt";
const std::string swap_path = "./.kte_ut_edge_del_min.swp";
std::remove(path.c_str());
std::remove(swap_path.c_str());
write_file_bytes(path, "hello\n");
// DEL record: encver(1) + row(4) + col(4) + dlen(4) = 13 bytes minimum
std::vector<std::uint8_t> payload;
payload.push_back(1); // encver
put_u32_le(payload, 0); // row
put_u32_le(payload, 0); // col
put_u32_le(payload, 0); // dlen=0
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::DEL), payload);
std::string file = build_swap_file({rec});
write_file_bytes(swap_path, file);
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
ASSERT_TRUE(kte::SwapManager::ReplayFile(b, swap_path, err));
std::remove(path.c_str());
std::remove(swap_path.c_str());
}
TEST (SwapEdge_SPLIT_MinimumValidPayload)
{
const std::string path = "./.kte_ut_edge_split_min.txt";
const std::string swap_path = "./.kte_ut_edge_split_min.swp";
std::remove(path.c_str());
std::remove(swap_path.c_str());
write_file_bytes(path, "hello\n");
// SPLIT record: encver(1) + row(4) + col(4) = 9 bytes minimum
std::vector<std::uint8_t> payload;
payload.push_back(1); // encver
put_u32_le(payload, 0); // row
put_u32_le(payload, 0); // col
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::SPLIT), payload);
std::string file = build_swap_file({rec});
write_file_bytes(swap_path, file);
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
ASSERT_TRUE(kte::SwapManager::ReplayFile(b, swap_path, err));
std::remove(path.c_str());
std::remove(swap_path.c_str());
}
TEST (SwapEdge_JOIN_MinimumValidPayload)
{
const std::string path = "./.kte_ut_edge_join_min.txt";
const std::string swap_path = "./.kte_ut_edge_join_min.swp";
std::remove(path.c_str());
std::remove(swap_path.c_str());
write_file_bytes(path, "hello\nworld\n");
// JOIN record: encver(1) + row(4) = 5 bytes minimum
std::vector<std::uint8_t> payload;
payload.push_back(1); // encver
put_u32_le(payload, 0); // row
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::JOIN), payload);
std::string file = build_swap_file({rec});
write_file_bytes(swap_path, file);
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
ASSERT_TRUE(kte::SwapManager::ReplayFile(b, swap_path, err));
std::remove(path.c_str());
std::remove(swap_path.c_str());
}
TEST (SwapEdge_CHKPT_MinimumValidPayload)
{
const std::string path = "./.kte_ut_edge_chkpt_min.txt";
const std::string swap_path = "./.kte_ut_edge_chkpt_min.swp";
std::remove(path.c_str());
std::remove(swap_path.c_str());
write_file_bytes(path, "hello\n");
// CHKPT record: encver(1) + nbytes(4) = 5 bytes minimum
std::vector<std::uint8_t> payload;
payload.push_back(1); // encver
put_u32_le(payload, 0); // nbytes=0
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::CHKPT), payload);
std::string file = build_swap_file({rec});
write_file_bytes(swap_path, file);
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
ASSERT_TRUE(kte::SwapManager::ReplayFile(b, swap_path, err));
std::remove(path.c_str());
std::remove(swap_path.c_str());
}
//=============================================================================
// 2. TRUNCATED PAYLOAD TESTS (BELOW MINIMUM)
//=============================================================================
TEST (SwapEdge_INS_TruncatedPayload_1Byte)
{
const std::string path = "./.kte_ut_edge_ins_trunc1.txt";
const std::string swap_path = "./.kte_ut_edge_ins_trunc1.swp";
std::remove(path.c_str());
std::remove(swap_path.c_str());
write_file_bytes(path, "hello\n");
// INS record with only 1 byte (just encver)
std::vector<std::uint8_t> payload;
payload.push_back(1); // encver only
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload);
std::string file = build_swap_file({rec});
write_file_bytes(swap_path, file);
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
ASSERT_TRUE(err.find("INS payload too short") != std::string::npos);
std::remove(path.c_str());
std::remove(swap_path.c_str());
}
TEST (SwapEdge_INS_TruncatedPayload_5Bytes)
{
const std::string path = "./.kte_ut_edge_ins_trunc5.txt";
const std::string swap_path = "./.kte_ut_edge_ins_trunc5.swp";
std::remove(path.c_str());
std::remove(swap_path.c_str());
write_file_bytes(path, "hello\n");
// INS record with 5 bytes (encver + row only)
std::vector<std::uint8_t> payload;
payload.push_back(1); // encver
put_u32_le(payload, 0); // row
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload);
std::string file = build_swap_file({rec});
write_file_bytes(swap_path, file);
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
ASSERT_TRUE(err.find("INS payload too short") != std::string::npos);
std::remove(path.c_str());
std::remove(swap_path.c_str());
}
TEST (SwapEdge_DEL_TruncatedPayload_9Bytes)
{
const std::string path = "./.kte_ut_edge_del_trunc9.txt";
const std::string swap_path = "./.kte_ut_edge_del_trunc9.swp";
std::remove(path.c_str());
std::remove(swap_path.c_str());
write_file_bytes(path, "hello\n");
// DEL record with 9 bytes (encver + row + col, missing dlen)
std::vector<std::uint8_t> payload;
payload.push_back(1); // encver
put_u32_le(payload, 0); // row
put_u32_le(payload, 0); // col
// missing dlen
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::DEL), payload);
std::string file = build_swap_file({rec});
write_file_bytes(swap_path, file);
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
ASSERT_TRUE(err.find("DEL payload too short") != std::string::npos);
std::remove(path.c_str());
std::remove(swap_path.c_str());
}
TEST (SwapEdge_SPLIT_TruncatedPayload_5Bytes)
{
const std::string path = "./.kte_ut_edge_split_trunc5.txt";
const std::string swap_path = "./.kte_ut_edge_split_trunc5.swp";
std::remove(path.c_str());
std::remove(swap_path.c_str());
write_file_bytes(path, "hello\n");
// SPLIT record with 5 bytes (encver + row, missing col)
std::vector<std::uint8_t> payload;
payload.push_back(1); // encver
put_u32_le(payload, 0); // row
// missing col
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::SPLIT), payload);
std::string file = build_swap_file({rec});
write_file_bytes(swap_path, file);
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
ASSERT_TRUE(err.find("SPLIT payload too short") != std::string::npos);
std::remove(path.c_str());
std::remove(swap_path.c_str());
}
TEST (SwapEdge_JOIN_TruncatedPayload_1Byte)
{
const std::string path = "./.kte_ut_edge_join_trunc1.txt";
const std::string swap_path = "./.kte_ut_edge_join_trunc1.swp";
std::remove(path.c_str());
std::remove(swap_path.c_str());
write_file_bytes(path, "hello\nworld\n");
// JOIN record with 1 byte (just encver)
std::vector<std::uint8_t> payload;
payload.push_back(1); // encver only
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::JOIN), payload);
std::string file = build_swap_file({rec});
write_file_bytes(swap_path, file);
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
ASSERT_TRUE(err.find("JOIN payload too short") != std::string::npos);
std::remove(path.c_str());
std::remove(swap_path.c_str());
}
TEST (SwapEdge_CHKPT_TruncatedPayload_3Bytes)
{
const std::string path = "./.kte_ut_edge_chkpt_trunc3.txt";
const std::string swap_path = "./.kte_ut_edge_chkpt_trunc3.swp";
std::remove(path.c_str());
std::remove(swap_path.c_str());
write_file_bytes(path, "hello\n");
// CHKPT record with 3 bytes (encver + partial nbytes)
std::vector<std::uint8_t> payload;
payload.push_back(1); // encver
payload.push_back(0); // partial nbytes (only 2 bytes instead of 4)
payload.push_back(0);
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::CHKPT), payload);
std::string file = build_swap_file({rec});
write_file_bytes(swap_path, file);
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
ASSERT_TRUE(err.find("CHKPT payload too short") != std::string::npos);
std::remove(path.c_str());
std::remove(swap_path.c_str());
}
//=============================================================================
// 3. DATA OVERFLOW TESTS
//=============================================================================
TEST (SwapEdge_INS_TruncatedData_NbytesExceedsPayload)
{
const std::string path = "./.kte_ut_edge_ins_overflow.txt";
const std::string swap_path = "./.kte_ut_edge_ins_overflow.swp";
std::remove(path.c_str());
std::remove(swap_path.c_str());
write_file_bytes(path, "hello\n");
// INS record where nbytes=100 but payload only contains 13 bytes total
std::vector<std::uint8_t> payload;
payload.push_back(1); // encver
put_u32_le(payload, 0); // row
put_u32_le(payload, 0); // col
put_u32_le(payload, 100); // nbytes=100 (but no data follows)
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload);
std::string file = build_swap_file({rec});
write_file_bytes(swap_path, file);
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
ASSERT_TRUE(err.find("Truncated INS payload bytes") != std::string::npos);
std::remove(path.c_str());
std::remove(swap_path.c_str());
}
TEST (SwapEdge_CHKPT_TruncatedData_NbytesExceedsPayload)
{
const std::string path = "./.kte_ut_edge_chkpt_overflow.txt";
const std::string swap_path = "./.kte_ut_edge_chkpt_overflow.swp";
std::remove(path.c_str());
std::remove(swap_path.c_str());
write_file_bytes(path, "hello\n");
// CHKPT record where nbytes=1000 but payload only contains 5 bytes total
std::vector<std::uint8_t> payload;
payload.push_back(1); // encver
put_u32_le(payload, 1000); // nbytes=1000 (but no data follows)
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::CHKPT), payload);
std::string file = build_swap_file({rec});
write_file_bytes(swap_path, file);
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
ASSERT_TRUE(err.find("Truncated CHKPT payload bytes") != std::string::npos);
std::remove(path.c_str());
std::remove(swap_path.c_str());
}
//=============================================================================
// 4. UNSUPPORTED ENCODING VERSION TESTS
//=============================================================================
TEST (SwapEdge_INS_UnsupportedEncodingVersion)
{
const std::string path = "./.kte_ut_edge_ins_badenc.txt";
const std::string swap_path = "./.kte_ut_edge_ins_badenc.swp";
std::remove(path.c_str());
std::remove(swap_path.c_str());
write_file_bytes(path, "hello\n");
// INS record with encver=2 (unsupported)
std::vector<std::uint8_t> payload;
payload.push_back(2); // encver=2 (unsupported)
put_u32_le(payload, 0); // row
put_u32_le(payload, 0); // col
put_u32_le(payload, 0); // nbytes
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload);
std::string file = build_swap_file({rec});
write_file_bytes(swap_path, file);
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
ASSERT_TRUE(err.find("Unsupported swap payload encoding") != std::string::npos);
std::remove(path.c_str());
std::remove(swap_path.c_str());
}
TEST (SwapEdge_CHKPT_UnsupportedEncodingVersion)
{
const std::string path = "./.kte_ut_edge_chkpt_badenc.txt";
const std::string swap_path = "./.kte_ut_edge_chkpt_badenc.swp";
std::remove(path.c_str());
std::remove(swap_path.c_str());
write_file_bytes(path, "hello\n");
// CHKPT record with encver=99 (unsupported)
std::vector<std::uint8_t> payload;
payload.push_back(99); // encver=99 (unsupported)
put_u32_le(payload, 0); // nbytes
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::CHKPT), payload);
std::string file = build_swap_file({rec});
write_file_bytes(swap_path, file);
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
ASSERT_TRUE(err.find("Unsupported swap checkpoint encoding") != std::string::npos);
std::remove(path.c_str());
std::remove(swap_path.c_str());
}
//=============================================================================
// 5. BOUNDARY CONDITION TESTS
//=============================================================================
TEST (SwapEdge_INS_ExactlyEnoughBytes)
{
const std::string path = "./.kte_ut_edge_ins_exact.txt";
const std::string swap_path = "./.kte_ut_edge_ins_exact.swp";
std::remove(path.c_str());
std::remove(swap_path.c_str());
write_file_bytes(path, "hello\n");
// INS record with nbytes=10 and exactly 23 bytes total (13 header + 10 data)
std::vector<std::uint8_t> payload;
payload.push_back(1); // encver
put_u32_le(payload, 0); // row
put_u32_le(payload, 0); // col
put_u32_le(payload, 10); // nbytes=10
// Add exactly 10 bytes of data
for (int i = 0; i < 10; i++) {
payload.push_back('X');
}
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload);
std::string file = build_swap_file({rec});
write_file_bytes(swap_path, file);
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
ASSERT_TRUE(kte::SwapManager::ReplayFile(b, swap_path, err));
std::remove(path.c_str());
std::remove(swap_path.c_str());
}
TEST (SwapEdge_INS_OneByteTooFew)
{
const std::string path = "./.kte_ut_edge_ins_toofew.txt";
const std::string swap_path = "./.kte_ut_edge_ins_toofew.swp";
std::remove(path.c_str());
std::remove(swap_path.c_str());
write_file_bytes(path, "hello\n");
// INS record with nbytes=10 but only 22 bytes total (13 header + 9 data)
std::vector<std::uint8_t> payload;
payload.push_back(1); // encver
put_u32_le(payload, 0); // row
put_u32_le(payload, 0); // col
put_u32_le(payload, 10); // nbytes=10
// Add only 9 bytes of data (one too few)
for (int i = 0; i < 9; i++) {
payload.push_back('X');
}
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload);
std::string file = build_swap_file({rec});
write_file_bytes(swap_path, file);
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
ASSERT_TRUE(err.find("Truncated INS payload bytes") != std::string::npos);
std::remove(path.c_str());
std::remove(swap_path.c_str());
}
//=============================================================================
// 6. MIXED VALID AND INVALID RECORDS
//=============================================================================
TEST (SwapEdge_MixedRecords_ValidThenInvalid)
{
const std::string path = "./.kte_ut_edge_mixed1.txt";
const std::string swap_path = "./.kte_ut_edge_mixed1.swp";
std::remove(path.c_str());
std::remove(swap_path.c_str());
write_file_bytes(path, "hello\n");
// First record: valid INS
std::vector<std::uint8_t> payload1;
payload1.push_back(1); // encver
put_u32_le(payload1, 0); // row
put_u32_le(payload1, 0); // col
put_u32_le(payload1, 1); // nbytes=1
payload1.push_back('X'); // data
std::string rec1 = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload1);
// Second record: truncated DEL
std::vector<std::uint8_t> payload2;
payload2.push_back(1); // encver only
std::string rec2 = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::DEL), payload2);
std::string file = build_swap_file({rec1, rec2});
write_file_bytes(swap_path, file);
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
ASSERT_TRUE(err.find("DEL payload too short") != std::string::npos);
// Verify first INS was applied before failure
auto view = b.GetLineView(0);
std::string line(view.data(), view.size());
ASSERT_TRUE(line.find('X') != std::string::npos);
std::remove(path.c_str());
std::remove(swap_path.c_str());
}
TEST (SwapEdge_MixedRecords_MultipleValidOneInvalid)
{
const std::string path = "./.kte_ut_edge_mixed2.txt";
const std::string swap_path = "./.kte_ut_edge_mixed2.swp";
std::remove(path.c_str());
std::remove(swap_path.c_str());
write_file_bytes(path, "ab\n");
// First record: valid INS at (0,0)
std::vector<std::uint8_t> payload1;
payload1.push_back(1);
put_u32_le(payload1, 0);
put_u32_le(payload1, 0);
put_u32_le(payload1, 1);
payload1.push_back('X');
std::string rec1 = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload1);
// Second record: valid INS at (0,1)
std::vector<std::uint8_t> payload2;
payload2.push_back(1);
put_u32_le(payload2, 0);
put_u32_le(payload2, 1);
put_u32_le(payload2, 1);
payload2.push_back('Y');
std::string rec2 = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload2);
// Third record: truncated SPLIT
std::vector<std::uint8_t> payload3;
payload3.push_back(1); // encver only
std::string rec3 = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::SPLIT), payload3);
std::string file = build_swap_file({rec1, rec2, rec3});
write_file_bytes(swap_path, file);
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
ASSERT_TRUE(err.find("SPLIT payload too short") != std::string::npos);
// Verify first two INS were applied
auto view = b.GetLineView(0);
std::string line(view.data(), view.size());
ASSERT_TRUE(line.find('X') != std::string::npos);
ASSERT_TRUE(line.find('Y') != std::string::npos);
std::remove(path.c_str());
std::remove(swap_path.c_str());
}
//=============================================================================
// 7. EMPTY PAYLOAD TEST
//=============================================================================
TEST (SwapEdge_EmptyPayload_INS)
{
const std::string path = "./.kte_ut_edge_empty.txt";
const std::string swap_path = "./.kte_ut_edge_empty.swp";
std::remove(path.c_str());
std::remove(swap_path.c_str());
write_file_bytes(path, "hello\n");
// INS record with zero-length payload
std::vector<std::uint8_t> payload; // empty
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload);
std::string file = build_swap_file({rec});
write_file_bytes(swap_path, file);
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
ASSERT_TRUE(err.find("INS payload too short") != std::string::npos);
std::remove(path.c_str());
std::remove(swap_path.c_str());
}
//=============================================================================
// 8. CRC MISMATCH TEST
//=============================================================================
TEST (SwapEdge_ValidStructure_BadCRC)
{
const std::string path = "./.kte_ut_edge_badcrc.txt";
const std::string swap_path = "./.kte_ut_edge_badcrc.swp";
std::remove(path.c_str());
std::remove(swap_path.c_str());
write_file_bytes(path, "hello\n");
// Build a valid INS record
std::vector<std::uint8_t> payload;
payload.push_back(1);
put_u32_le(payload, 0);
put_u32_le(payload, 0);
put_u32_le(payload, 1);
payload.push_back('X');
std::string rec = build_swap_record(static_cast<std::uint8_t>(kte::SwapRecType::INS), payload);
// Corrupt the CRC (last 4 bytes)
rec[rec.size() - 1] ^= 0xFF;
std::string file = build_swap_file({rec});
write_file_bytes(swap_path, file);
Buffer b;
std::string err;
ASSERT_TRUE(b.OpenFromFile(path, err));
ASSERT_EQ(kte::SwapManager::ReplayFile(b, swap_path, err), false);
ASSERT_TRUE(err.find("CRC mismatch") != std::string::npos);
std::remove(path.c_str());
std::remove(swap_path.c_str());
}

View File

@@ -368,7 +368,7 @@ TEST(Undo_RoundTrip_Lossless_RandomEdits)
// Legacy/extended undo tests follow. Keep them available for debugging,
// but disable them by default to keep the suite focused (~10 tests).
#if 0
#if 1
TEST (Undo_Branching_RedoPreservedAfterNewEdit)
@@ -713,6 +713,7 @@ TEST (Undo_StructuralInvariants_BranchingAndRoots)
validate_undo_tree(*u);
}
TEST (Undo_BranchSelection_ThreeSiblingsAndHeadPersists)
{
Buffer b;
@@ -796,7 +797,7 @@ TEST (Undo_BranchSelection_ThreeSiblingsAndHeadPersists)
// Additional legacy tests below are useful, but kept disabled by default.
#if 0
#if 1
TEST (Undo_Branching_SwitchBetweenTwoRedoBranches_TextAndCursor)
{
@@ -1196,4 +1197,167 @@ TEST (Undo_Command_RedoCountSelectsBranch)
validate_undo_tree(*u);
}
TEST (Undo_InsertRow_UndoDeletesRow)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Seed two lines so insert_row has proper newline context.
b.insert_text(0, 0, std::string_view("first\nlast"));
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
// Insert a row at position 1 (between first and last), then record it.
b.insert_row(1, std::string_view("second"));
ASSERT_EQ(b.Rows().size(), (std::size_t) 3);
ASSERT_EQ(std::string(b.Rows()[1]), std::string("second"));
b.SetCursor(0, 1);
u->Begin(UndoType::InsertRow);
u->Append(std::string_view("second"));
u->commit();
// Undo should remove the inserted row.
u->undo();
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("first"));
ASSERT_EQ(std::string(b.Rows()[1]), std::string("last"));
// Redo should re-insert it.
u->redo();
ASSERT_EQ(b.Rows().size(), (std::size_t) 3);
ASSERT_EQ(std::string(b.Rows()[1]), std::string("second"));
validate_undo_tree(*u);
}
TEST (Undo_DeleteRow_UndoRestoresRow)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
b.insert_text(0, 0, std::string_view("alpha\nbeta\ngamma"));
ASSERT_EQ(b.Rows().size(), (std::size_t) 3);
// Record a DeleteRow for row 1 ("beta").
b.SetCursor(0, 1);
u->Begin(UndoType::DeleteRow);
u->Append(static_cast<std::string>(b.Rows()[1]));
u->commit();
b.delete_row(1);
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("alpha"));
ASSERT_EQ(std::string(b.Rows()[1]), std::string("gamma"));
// Undo should restore "beta" at row 1.
u->undo();
ASSERT_EQ(b.Rows().size(), (std::size_t) 3);
ASSERT_EQ(std::string(b.Rows()[1]), std::string("beta"));
// Redo should delete it again.
u->redo();
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
ASSERT_EQ(std::string(b.Rows()[1]), std::string("gamma"));
validate_undo_tree(*u);
}
TEST (Undo_InsertRow_IsStandalone)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Seed with two lines so InsertRow has proper newline context.
b.insert_text(0, 0, std::string_view("x\nend"));
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
// Start a pending insert on row 0.
b.SetCursor(1, 0);
u->Begin(UndoType::Insert);
b.insert_text(0, 1, std::string_view("y"));
u->Append('y');
b.SetCursor(2, 0);
// InsertRow should seal the pending "y" and become its own step.
b.insert_row(1, std::string_view("row2"));
b.SetCursor(0, 1);
u->Begin(UndoType::InsertRow);
u->Append(std::string_view("row2"));
u->commit();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("xy"));
ASSERT_EQ(std::string(b.Rows()[1]), std::string("row2"));
ASSERT_EQ(b.Rows().size(), (std::size_t) 3);
// Undo InsertRow only.
u->undo();
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
ASSERT_EQ(std::string(b.Rows()[0]), std::string("xy"));
// Undo the insert "y".
u->undo();
ASSERT_EQ(std::string(b.Rows()[0]), std::string("x"));
validate_undo_tree(*u);
}
TEST (Undo_GroupedDeleteAndInsertRows_UndoesAsUnit)
{
Buffer b;
UndoSystem *u = b.Undo();
ASSERT_TRUE(u != nullptr);
// Seed three lines (with trailing newline so delete_row/insert_row work cleanly).
b.insert_text(0, 0, std::string_view("aaa\nbbb\nccc\n"));
ASSERT_EQ(b.Rows().size(), (std::size_t) 4); // 3 content + 1 empty trailing
const std::string original = b.AsString();
// Group: delete content rows then insert replacements (simulates reflow).
(void) u->BeginGroup();
// Delete rows 2,1,0 in reverse order (like reflow does).
for (int i = 2; i >= 0; --i) {
b.SetCursor(0, static_cast<std::size_t>(i));
u->Begin(UndoType::DeleteRow);
u->Append(static_cast<std::string>(b.Rows()[static_cast<std::size_t>(i)]));
u->commit();
b.delete_row(i);
}
// Insert replacement rows.
b.insert_row(0, std::string_view("aaa bbb"));
b.SetCursor(0, 0);
u->Begin(UndoType::InsertRow);
u->Append(std::string_view("aaa bbb"));
u->commit();
b.insert_row(1, std::string_view("ccc"));
b.SetCursor(0, 1);
u->Begin(UndoType::InsertRow);
u->Append(std::string_view("ccc"));
u->commit();
u->EndGroup();
const std::string reflowed = b.AsString();
// Single undo should restore original content.
u->undo();
ASSERT_EQ(b.AsString(), original);
// Redo should restore the reflowed state.
u->redo();
ASSERT_EQ(b.AsString(), reflowed);
validate_undo_tree(*u);
}
#endif // legacy tests

204
themes/Leuchtturm.h Normal file
View File

@@ -0,0 +1,204 @@
// themes/Leuchtturm.h — Fountain pen on cream paper, brass and leather (header-only)
// Inspired by Kaweco Brass/Bronze Sport pens on Leuchtturm1917 notebook paper.
// Light: warm cream paper with blue-black fountain pen ink.
// Dark: leather case and patinated metal.
#pragma once
#include "ThemeHelpers.h"
static inline void
ApplyLeuchtturmLightTheme()
{
// Notebook paper and fountain pen ink
const ImVec4 paper = RGBA(0xF2ECDF); // Leuchtturm cream paper
const ImVec4 bg1 = RGBA(0xE8E2D5); // slightly darker cream
const ImVec4 bg2 = RGBA(0xDDD7CA); // UI elements
const ImVec4 bg3 = RGBA(0xD1CBBD); // hover/active
const ImVec4 ink = RGBA(0x040720); // blue-black fountain pen ink
const ImVec4 dim = RGBA(0x7A756A); // faded text (like printed headers)
const ImVec4 border = RGBA(0xCCC6B4); // faint ruled lines
// Metal accents from the pens
const ImVec4 brass = RGBA(0x6B5E2A); // dark patinated brass
const ImVec4 brown = RGBA(0x5C3D28); // leather/bronze
ImGuiStyle &style = ImGui::GetStyle();
style.WindowPadding = ImVec2(8.0f, 8.0f);
style.FramePadding = ImVec2(6.0f, 4.0f);
style.CellPadding = ImVec2(6.0f, 4.0f);
style.ItemSpacing = ImVec2(6.0f, 6.0f);
style.ItemInnerSpacing = ImVec2(6.0f, 4.0f);
style.ScrollbarSize = 12.0f;
style.GrabMinSize = 10.0f;
style.WindowRounding = 0.0f;
style.FrameRounding = 0.0f;
style.PopupRounding = 0.0f;
style.GrabRounding = 0.0f;
style.TabRounding = 0.0f;
style.WindowBorderSize = 1.0f;
style.FrameBorderSize = 0.0f;
ImVec4 *colors = style.Colors;
colors[ImGuiCol_Text] = ink;
colors[ImGuiCol_TextDisabled] = dim;
colors[ImGuiCol_WindowBg] = paper;
colors[ImGuiCol_ChildBg] = paper;
colors[ImGuiCol_PopupBg] = ImVec4(bg1.x, bg1.y, bg1.z, 0.98f);
colors[ImGuiCol_Border] = border;
colors[ImGuiCol_BorderShadow] = RGBA(0x000000, 0.0f);
colors[ImGuiCol_FrameBg] = bg2;
colors[ImGuiCol_FrameBgHovered] = bg3;
colors[ImGuiCol_FrameBgActive] = bg1;
colors[ImGuiCol_TitleBg] = bg1;
colors[ImGuiCol_TitleBgActive] = bg2;
colors[ImGuiCol_TitleBgCollapsed] = bg1;
colors[ImGuiCol_MenuBarBg] = bg1;
colors[ImGuiCol_ScrollbarBg] = paper;
colors[ImGuiCol_ScrollbarGrab] = bg3;
colors[ImGuiCol_ScrollbarGrabHovered] = bg2;
colors[ImGuiCol_ScrollbarGrabActive] = border;
colors[ImGuiCol_CheckMark] = ink;
colors[ImGuiCol_SliderGrab] = ink;
colors[ImGuiCol_SliderGrabActive] = brass;
colors[ImGuiCol_Button] = bg2;
colors[ImGuiCol_ButtonHovered] = bg3;
colors[ImGuiCol_ButtonActive] = bg1;
colors[ImGuiCol_Header] = bg2;
colors[ImGuiCol_HeaderHovered] = bg3;
colors[ImGuiCol_HeaderActive] = bg3;
colors[ImGuiCol_Separator] = border;
colors[ImGuiCol_SeparatorHovered] = bg3;
colors[ImGuiCol_SeparatorActive] = brass;
colors[ImGuiCol_ResizeGrip] = ImVec4(ink.x, ink.y, ink.z, 0.10f);
colors[ImGuiCol_ResizeGripHovered] = ImVec4(brass.x, brass.y, brass.z, 0.50f);
colors[ImGuiCol_ResizeGripActive] = brass;
colors[ImGuiCol_Tab] = bg2;
colors[ImGuiCol_TabHovered] = bg1;
colors[ImGuiCol_TabActive] = bg3;
colors[ImGuiCol_TabUnfocused] = bg2;
colors[ImGuiCol_TabUnfocusedActive] = bg3;
colors[ImGuiCol_TableHeaderBg] = bg2;
colors[ImGuiCol_TableBorderStrong] = border;
colors[ImGuiCol_TableBorderLight] = ImVec4(border.x, border.y, border.z, 0.5f);
colors[ImGuiCol_TableRowBg] = ImVec4(bg1.x, bg1.y, bg1.z, 0.0f);
colors[ImGuiCol_TableRowBgAlt] = ImVec4(bg1.x, bg1.y, bg1.z, 0.30f);
colors[ImGuiCol_TextSelectedBg] = ImVec4(brass.x, brass.y, brass.z, 0.18f);
colors[ImGuiCol_DragDropTarget] = brass;
colors[ImGuiCol_NavHighlight] = brass;
colors[ImGuiCol_NavWindowingHighlight] = ImVec4(ink.x, ink.y, ink.z, 0.70f);
colors[ImGuiCol_NavWindowingDimBg] = ImVec4(0.0f, 0.0f, 0.0f, 0.15f);
colors[ImGuiCol_ModalWindowDimBg] = ImVec4(0.0f, 0.0f, 0.0f, 0.15f);
colors[ImGuiCol_PlotLines] = brown;
colors[ImGuiCol_PlotLinesHovered] = brass;
colors[ImGuiCol_PlotHistogram] = brown;
colors[ImGuiCol_PlotHistogramHovered] = brass;
}
// Dark variant — leather pen case with warm metal and cream accents
static inline void
ApplyLeuchtturmDarkTheme()
{
const ImVec4 bg0 = RGBA(0x1C1610); // dark leather
const ImVec4 bg1 = RGBA(0x251E16); // slightly lighter
const ImVec4 bg2 = RGBA(0x30281E); // UI elements
const ImVec4 bg3 = RGBA(0x3E3428); // hover/active
const ImVec4 ink = RGBA(0xE5DDD0); // warm cream text
const ImVec4 dim = RGBA(0x978E7C); // secondary text
const ImVec4 border = RGBA(0x4A3E30); // subtle borders
const ImVec4 brass = RGBA(0xB8A060); // polished brass
const ImVec4 brown = RGBA(0x8B6848); // bronze pen
ImGuiStyle &style = ImGui::GetStyle();
style.WindowPadding = ImVec2(8.0f, 8.0f);
style.FramePadding = ImVec2(6.0f, 4.0f);
style.CellPadding = ImVec2(6.0f, 4.0f);
style.ItemSpacing = ImVec2(6.0f, 6.0f);
style.ItemInnerSpacing = ImVec2(6.0f, 4.0f);
style.ScrollbarSize = 12.0f;
style.GrabMinSize = 10.0f;
style.WindowRounding = 0.0f;
style.FrameRounding = 0.0f;
style.PopupRounding = 0.0f;
style.GrabRounding = 0.0f;
style.TabRounding = 0.0f;
style.WindowBorderSize = 1.0f;
style.FrameBorderSize = 0.0f;
ImVec4 *colors = style.Colors;
colors[ImGuiCol_Text] = ink;
colors[ImGuiCol_TextDisabled] = dim;
colors[ImGuiCol_WindowBg] = bg0;
colors[ImGuiCol_ChildBg] = bg0;
colors[ImGuiCol_PopupBg] = ImVec4(bg1.x, bg1.y, bg1.z, 0.98f);
colors[ImGuiCol_Border] = border;
colors[ImGuiCol_BorderShadow] = RGBA(0x000000, 0.0f);
colors[ImGuiCol_FrameBg] = bg2;
colors[ImGuiCol_FrameBgHovered] = bg3;
colors[ImGuiCol_FrameBgActive] = bg1;
colors[ImGuiCol_TitleBg] = bg1;
colors[ImGuiCol_TitleBgActive] = bg2;
colors[ImGuiCol_TitleBgCollapsed] = bg1;
colors[ImGuiCol_MenuBarBg] = bg1;
colors[ImGuiCol_ScrollbarBg] = bg0;
colors[ImGuiCol_ScrollbarGrab] = bg3;
colors[ImGuiCol_ScrollbarGrabHovered] = border;
colors[ImGuiCol_ScrollbarGrabActive] = dim;
colors[ImGuiCol_CheckMark] = brass;
colors[ImGuiCol_SliderGrab] = brass;
colors[ImGuiCol_SliderGrabActive] = brown;
colors[ImGuiCol_Button] = bg2;
colors[ImGuiCol_ButtonHovered] = bg3;
colors[ImGuiCol_ButtonActive] = bg1;
colors[ImGuiCol_Header] = bg2;
colors[ImGuiCol_HeaderHovered] = bg3;
colors[ImGuiCol_HeaderActive] = bg3;
colors[ImGuiCol_Separator] = border;
colors[ImGuiCol_SeparatorHovered] = bg3;
colors[ImGuiCol_SeparatorActive] = brass;
colors[ImGuiCol_ResizeGrip] = ImVec4(ink.x, ink.y, ink.z, 0.10f);
colors[ImGuiCol_ResizeGripHovered] = ImVec4(brass.x, brass.y, brass.z, 0.50f);
colors[ImGuiCol_ResizeGripActive] = brass;
colors[ImGuiCol_Tab] = bg2;
colors[ImGuiCol_TabHovered] = bg1;
colors[ImGuiCol_TabActive] = bg3;
colors[ImGuiCol_TabUnfocused] = bg2;
colors[ImGuiCol_TabUnfocusedActive] = bg3;
colors[ImGuiCol_TableHeaderBg] = bg2;
colors[ImGuiCol_TableBorderStrong] = border;
colors[ImGuiCol_TableBorderLight] = ImVec4(border.x, border.y, border.z, 0.5f);
colors[ImGuiCol_TableRowBg] = ImVec4(bg1.x, bg1.y, bg1.z, 0.0f);
colors[ImGuiCol_TableRowBgAlt] = ImVec4(bg1.x, bg1.y, bg1.z, 0.30f);
colors[ImGuiCol_TextSelectedBg] = ImVec4(brass.x, brass.y, brass.z, 0.22f);
colors[ImGuiCol_DragDropTarget] = brass;
colors[ImGuiCol_NavHighlight] = brass;
colors[ImGuiCol_NavWindowingHighlight] = ImVec4(ink.x, ink.y, ink.z, 0.70f);
colors[ImGuiCol_NavWindowingDimBg] = ImVec4(0.0f, 0.0f, 0.0f, 0.35f);
colors[ImGuiCol_ModalWindowDimBg] = ImVec4(0.0f, 0.0f, 0.0f, 0.35f);
colors[ImGuiCol_PlotLines] = brass;
colors[ImGuiCol_PlotLinesHovered] = brown;
colors[ImGuiCol_PlotHistogram] = brass;
colors[ImGuiCol_PlotHistogramHovered] = brown;
}

203
themes/Tufte.h Normal file
View File

@@ -0,0 +1,203 @@
// themes/Tufte.h — Edward Tufte inspired ImGui theme (header-only)
// Warm cream paper, dark ink, minimal chrome, restrained accent colors.
#pragma once
#include "ThemeHelpers.h"
// Light variant (primary — Tufte's books are fundamentally light)
static inline void
ApplyTufteLightTheme()
{
// Tufte palette: warm cream paper with near-black ink
const ImVec4 paper = RGBA(0xFFFFF8); // Tufte's signature warm white
const ImVec4 bg1 = RGBA(0xF4F0E8); // slightly darker cream
const ImVec4 bg2 = RGBA(0xEAE6DE); // UI elements
const ImVec4 bg3 = RGBA(0xDDD9D1); // hover/active
const ImVec4 ink = RGBA(0x111111); // near-black text
const ImVec4 dim = RGBA(0x6B6B6B); // disabled/secondary text
const ImVec4 border = RGBA(0xD0CCC4); // subtle borders
// Tufte uses color sparingly: muted red for emphasis, navy for links
const ImVec4 red = RGBA(0xA00000); // restrained dark red
const ImVec4 blue = RGBA(0x1F3F6F); // dark navy
ImGuiStyle &style = ImGui::GetStyle();
style.WindowPadding = ImVec2(8.0f, 8.0f);
style.FramePadding = ImVec2(6.0f, 4.0f);
style.CellPadding = ImVec2(6.0f, 4.0f);
style.ItemSpacing = ImVec2(6.0f, 6.0f);
style.ItemInnerSpacing = ImVec2(6.0f, 4.0f);
style.ScrollbarSize = 12.0f;
style.GrabMinSize = 10.0f;
style.WindowRounding = 0.0f; // sharp edges — typographic, not app-like
style.FrameRounding = 0.0f;
style.PopupRounding = 0.0f;
style.GrabRounding = 0.0f;
style.TabRounding = 0.0f;
style.WindowBorderSize = 1.0f;
style.FrameBorderSize = 0.0f; // minimal frame borders
ImVec4 *colors = style.Colors;
colors[ImGuiCol_Text] = ink;
colors[ImGuiCol_TextDisabled] = dim;
colors[ImGuiCol_WindowBg] = paper;
colors[ImGuiCol_ChildBg] = paper;
colors[ImGuiCol_PopupBg] = ImVec4(bg1.x, bg1.y, bg1.z, 0.98f);
colors[ImGuiCol_Border] = border;
colors[ImGuiCol_BorderShadow] = RGBA(0x000000, 0.0f);
colors[ImGuiCol_FrameBg] = bg2;
colors[ImGuiCol_FrameBgHovered] = bg3;
colors[ImGuiCol_FrameBgActive] = bg1;
colors[ImGuiCol_TitleBg] = bg1;
colors[ImGuiCol_TitleBgActive] = bg2;
colors[ImGuiCol_TitleBgCollapsed] = bg1;
colors[ImGuiCol_MenuBarBg] = bg1;
colors[ImGuiCol_ScrollbarBg] = paper;
colors[ImGuiCol_ScrollbarGrab] = bg3;
colors[ImGuiCol_ScrollbarGrabHovered] = bg2;
colors[ImGuiCol_ScrollbarGrabActive] = border;
colors[ImGuiCol_CheckMark] = ink;
colors[ImGuiCol_SliderGrab] = ink;
colors[ImGuiCol_SliderGrabActive] = blue;
colors[ImGuiCol_Button] = bg2;
colors[ImGuiCol_ButtonHovered] = bg3;
colors[ImGuiCol_ButtonActive] = bg1;
colors[ImGuiCol_Header] = bg2;
colors[ImGuiCol_HeaderHovered] = bg3;
colors[ImGuiCol_HeaderActive] = bg3;
colors[ImGuiCol_Separator] = border;
colors[ImGuiCol_SeparatorHovered] = bg3;
colors[ImGuiCol_SeparatorActive] = red;
colors[ImGuiCol_ResizeGrip] = ImVec4(ink.x, ink.y, ink.z, 0.10f);
colors[ImGuiCol_ResizeGripHovered] = ImVec4(red.x, red.y, red.z, 0.50f);
colors[ImGuiCol_ResizeGripActive] = red;
colors[ImGuiCol_Tab] = bg2;
colors[ImGuiCol_TabHovered] = bg1;
colors[ImGuiCol_TabActive] = bg3;
colors[ImGuiCol_TabUnfocused] = bg2;
colors[ImGuiCol_TabUnfocusedActive] = bg3;
colors[ImGuiCol_TableHeaderBg] = bg2;
colors[ImGuiCol_TableBorderStrong] = border;
colors[ImGuiCol_TableBorderLight] = ImVec4(border.x, border.y, border.z, 0.5f);
colors[ImGuiCol_TableRowBg] = ImVec4(bg1.x, bg1.y, bg1.z, 0.0f);
colors[ImGuiCol_TableRowBgAlt] = ImVec4(bg1.x, bg1.y, bg1.z, 0.30f);
colors[ImGuiCol_TextSelectedBg] = ImVec4(red.x, red.y, red.z, 0.15f);
colors[ImGuiCol_DragDropTarget] = red;
colors[ImGuiCol_NavHighlight] = red;
colors[ImGuiCol_NavWindowingHighlight] = ImVec4(ink.x, ink.y, ink.z, 0.70f);
colors[ImGuiCol_NavWindowingDimBg] = ImVec4(0.0f, 0.0f, 0.0f, 0.15f);
colors[ImGuiCol_ModalWindowDimBg] = ImVec4(0.0f, 0.0f, 0.0f, 0.15f);
colors[ImGuiCol_PlotLines] = blue;
colors[ImGuiCol_PlotLinesHovered] = red;
colors[ImGuiCol_PlotHistogram] = blue;
colors[ImGuiCol_PlotHistogramHovered] = red;
}
// Dark variant — warm charcoal with cream ink, same restrained accents
static inline void
ApplyTufteDarkTheme()
{
const ImVec4 bg0 = RGBA(0x1C1B19); // warm near-black
const ImVec4 bg1 = RGBA(0x252420); // slightly lighter
const ImVec4 bg2 = RGBA(0x302F2A); // UI elements
const ImVec4 bg3 = RGBA(0x3D3C36); // hover/active
const ImVec4 ink = RGBA(0xEAE6DE); // cream text (inverted paper)
const ImVec4 dim = RGBA(0x9A9690); // disabled text
const ImVec4 border = RGBA(0x4A4840); // subtle borders
const ImVec4 red = RGBA(0xD06060); // warmer red for dark bg
const ImVec4 blue = RGBA(0x7098C0); // lighter navy for dark bg
ImGuiStyle &style = ImGui::GetStyle();
style.WindowPadding = ImVec2(8.0f, 8.0f);
style.FramePadding = ImVec2(6.0f, 4.0f);
style.CellPadding = ImVec2(6.0f, 4.0f);
style.ItemSpacing = ImVec2(6.0f, 6.0f);
style.ItemInnerSpacing = ImVec2(6.0f, 4.0f);
style.ScrollbarSize = 12.0f;
style.GrabMinSize = 10.0f;
style.WindowRounding = 0.0f;
style.FrameRounding = 0.0f;
style.PopupRounding = 0.0f;
style.GrabRounding = 0.0f;
style.TabRounding = 0.0f;
style.WindowBorderSize = 1.0f;
style.FrameBorderSize = 0.0f;
ImVec4 *colors = style.Colors;
colors[ImGuiCol_Text] = ink;
colors[ImGuiCol_TextDisabled] = dim;
colors[ImGuiCol_WindowBg] = bg0;
colors[ImGuiCol_ChildBg] = bg0;
colors[ImGuiCol_PopupBg] = ImVec4(bg1.x, bg1.y, bg1.z, 0.98f);
colors[ImGuiCol_Border] = border;
colors[ImGuiCol_BorderShadow] = RGBA(0x000000, 0.0f);
colors[ImGuiCol_FrameBg] = bg2;
colors[ImGuiCol_FrameBgHovered] = bg3;
colors[ImGuiCol_FrameBgActive] = bg1;
colors[ImGuiCol_TitleBg] = bg1;
colors[ImGuiCol_TitleBgActive] = bg2;
colors[ImGuiCol_TitleBgCollapsed] = bg1;
colors[ImGuiCol_MenuBarBg] = bg1;
colors[ImGuiCol_ScrollbarBg] = bg0;
colors[ImGuiCol_ScrollbarGrab] = bg3;
colors[ImGuiCol_ScrollbarGrabHovered] = border;
colors[ImGuiCol_ScrollbarGrabActive] = dim;
colors[ImGuiCol_CheckMark] = ink;
colors[ImGuiCol_SliderGrab] = ink;
colors[ImGuiCol_SliderGrabActive] = blue;
colors[ImGuiCol_Button] = bg2;
colors[ImGuiCol_ButtonHovered] = bg3;
colors[ImGuiCol_ButtonActive] = bg1;
colors[ImGuiCol_Header] = bg2;
colors[ImGuiCol_HeaderHovered] = bg3;
colors[ImGuiCol_HeaderActive] = bg3;
colors[ImGuiCol_Separator] = border;
colors[ImGuiCol_SeparatorHovered] = bg3;
colors[ImGuiCol_SeparatorActive] = red;
colors[ImGuiCol_ResizeGrip] = ImVec4(ink.x, ink.y, ink.z, 0.10f);
colors[ImGuiCol_ResizeGripHovered] = ImVec4(red.x, red.y, red.z, 0.50f);
colors[ImGuiCol_ResizeGripActive] = red;
colors[ImGuiCol_Tab] = bg2;
colors[ImGuiCol_TabHovered] = bg1;
colors[ImGuiCol_TabActive] = bg3;
colors[ImGuiCol_TabUnfocused] = bg2;
colors[ImGuiCol_TabUnfocusedActive] = bg3;
colors[ImGuiCol_TableHeaderBg] = bg2;
colors[ImGuiCol_TableBorderStrong] = border;
colors[ImGuiCol_TableBorderLight] = ImVec4(border.x, border.y, border.z, 0.5f);
colors[ImGuiCol_TableRowBg] = ImVec4(bg1.x, bg1.y, bg1.z, 0.0f);
colors[ImGuiCol_TableRowBgAlt] = ImVec4(bg1.x, bg1.y, bg1.z, 0.30f);
colors[ImGuiCol_TextSelectedBg] = ImVec4(red.x, red.y, red.z, 0.20f);
colors[ImGuiCol_DragDropTarget] = red;
colors[ImGuiCol_NavHighlight] = red;
colors[ImGuiCol_NavWindowingHighlight] = ImVec4(ink.x, ink.y, ink.z, 0.70f);
colors[ImGuiCol_NavWindowingDimBg] = ImVec4(0.0f, 0.0f, 0.0f, 0.35f);
colors[ImGuiCol_ModalWindowDimBg] = ImVec4(0.0f, 0.0f, 0.0f, 0.35f);
colors[ImGuiCol_PlotLines] = blue;
colors[ImGuiCol_PlotLinesHovered] = red;
colors[ImGuiCol_PlotHistogram] = blue;
colors[ImGuiCol_PlotHistogramHovered] = red;
}