Compare commits

...

6 Commits

Author SHA1 Message Date
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
16 changed files with 977 additions and 259 deletions

View File

@@ -4,7 +4,7 @@ project(kte)
include(GNUInstallDirs) include(GNUInstallDirs)
set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD 20)
set(KTE_VERSION "1.7.1") set(KTE_VERSION "1.9.0")
# Default to terminal-only build to avoid SDL/OpenGL dependency by default. # Default to terminal-only build to avoid SDL/OpenGL dependency by default.
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available. # Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
@@ -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") set(KTE_FONT_SIZE "18.0" CACHE STRING "Default font size for GUI")
option(KTE_UNDO_DEBUG "Enable undo instrumentation logs" OFF) option(KTE_UNDO_DEBUG "Enable undo instrumentation logs" OFF)
option(KTE_ENABLE_TREESITTER "Enable optional Tree-sitter highlighter adapter" OFF) option(KTE_ENABLE_TREESITTER "Enable optional Tree-sitter highlighter adapter" OFF)
option(KTE_STATIC_LINK "Enable static linking on Linux" ON)
# Optionally enable AddressSanitizer (ASan) # Optionally enable AddressSanitizer (ASan)
option(ENABLE_ASAN "Enable AddressSanitizer for builds" OFF) option(ENABLE_ASAN "Enable AddressSanitizer for builds" OFF)
@@ -285,7 +286,7 @@ endif ()
target_link_libraries(kte ${CURSES_LIBRARIES}) target_link_libraries(kte ${CURSES_LIBRARIES})
# Static linking on Linux only (macOS does not support static linking of system libraries) # Static linking on Linux only (macOS does not support static linking of system libraries)
if (NOT APPLE) if (NOT APPLE AND KTE_STATIC_LINK)
target_link_options(kte PRIVATE -static) target_link_options(kte PRIVATE -static)
endif () endif ()
@@ -326,6 +327,7 @@ if (BUILD_TESTS)
tests/test_swap_edge_cases.cc tests/test_swap_edge_cases.cc
tests/test_swap_recovery_prompt.cc tests/test_swap_recovery_prompt.cc
tests/test_swap_cleanup.cc tests/test_swap_cleanup.cc
tests/test_swap_cleanup2.cc
tests/test_swap_git_editor.cc tests/test_swap_git_editor.cc
tests/test_piece_table.cc tests/test_piece_table.cc
tests/test_search.cc tests/test_search.cc
@@ -375,7 +377,7 @@ if (BUILD_TESTS)
endif () endif ()
# Static linking on Linux only (macOS does not support static linking of system libraries) # Static linking on Linux only (macOS does not support static linking of system libraries)
if (NOT APPLE) if (NOT APPLE AND KTE_STATIC_LINK)
target_link_options(kte_tests PRIVATE -static) target_link_options(kte_tests PRIVATE -static)
endif () endif ()
endif () endif ()
@@ -418,7 +420,7 @@ if (BUILD_GUI)
endif () endif ()
# Static linking on Linux only (macOS does not support static linking of system libraries) # Static linking on Linux only (macOS does not support static linking of system libraries)
if (NOT APPLE) if (NOT APPLE AND KTE_STATIC_LINK)
target_link_options(kge PRIVATE -static) target_link_options(kge PRIVATE -static)
endif () endif ()

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 static bool
cmd_center_on_cursor(CommandContext &ctx) cmd_center_on_cursor(CommandContext &ctx)
{ {
@@ -744,6 +752,8 @@ cmd_save_and_quit(CommandContext &ctx)
if (buf->IsFileBacked()) { if (buf->IsFileBacked()) {
if (buf->Save(err)) { if (buf->Save(err)) {
buf->SetDirty(false); buf->SetDirty(false);
if (auto *sm = ctx.editor.Swap())
sm->ResetJournal(*buf);
} else { } else {
ctx.editor.SetStatus(err); ctx.editor.SetStatus(err);
return false; return false;
@@ -751,6 +761,8 @@ cmd_save_and_quit(CommandContext &ctx)
} else if (!buf->Filename().empty()) { } else if (!buf->Filename().empty()) {
if (buf->SaveAs(buf->Filename(), err)) { if (buf->SaveAs(buf->Filename(), err)) {
buf->SetDirty(false); buf->SetDirty(false);
if (auto *sm = ctx.editor.Swap())
sm->ResetJournal(*buf);
} else { } else {
ctx.editor.SetStatus(err); ctx.editor.SetStatus(err);
return false; return false;
@@ -2254,10 +2266,8 @@ cmd_show_help(CommandContext &ctx)
}; };
auto populate_from_text = [](Buffer &b, const std::string &text) { auto populate_from_text = [](Buffer &b, const std::string &text) {
// Clear existing rows // Clear existing content
while (b.Nrows() > 0) { b.replace_all_bytes("");
b.delete_row(0);
}
// Parse text and insert rows // Parse text and insert rows
std::string line; std::string line;
line.reserve(128); line.reserve(128);
@@ -2562,6 +2572,10 @@ cmd_newline(CommandContext &ctx)
ctx.editor.SetStatus(err); ctx.editor.SetStatus(err);
} else { } else {
buf->SetDirty(false); buf->SetDirty(false);
if (auto *sm = ctx.editor.Swap()) {
sm->NotifyFilenameChanged(*buf);
sm->ResetJournal(*buf);
}
ctx.editor.SetStatus("Saved as " + value); ctx.editor.SetStatus("Saved as " + value);
if (auto *u = buf->Undo()) if (auto *u = buf->Undo())
u->mark_saved(); u->mark_saved();
@@ -5002,6 +5016,11 @@ InstallDefaultCommands()
CommandId::CenterOnCursor, "center-on-cursor", "Center viewport on current line", cmd_center_on_cursor, CommandId::CenterOnCursor, "center-on-cursor", "Center viewport on current line", cmd_center_on_cursor,
false, false 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

@@ -111,6 +111,8 @@ enum class CommandId {
SetOption, // generic ":set key=value" (v1: filetype=<lang>) SetOption, // generic ":set key=value" (v1: filetype=<lang>)
// Viewport control // Viewport control
CenterOnCursor, // center the viewport on the current cursor line (C-k k) CenterOnCursor, // center the viewport on the current cursor line (C-k k)
// GUI: open a new editor window sharing the same buffer list
NewWindow,
}; };

View File

@@ -69,20 +69,22 @@ Editor::SetStatus(const std::string &message)
Buffer * Buffer *
Editor::CurrentBuffer() Editor::CurrentBuffer()
{ {
if (buffers_.empty() || curbuf_ >= buffers_.size()) { auto &bufs = Buffers();
if (bufs.empty() || curbuf_ >= bufs.size()) {
return nullptr; return nullptr;
} }
return &buffers_[curbuf_]; return &bufs[curbuf_];
} }
const Buffer * const Buffer *
Editor::CurrentBuffer() const Editor::CurrentBuffer() const
{ {
if (buffers_.empty() || curbuf_ >= buffers_.size()) { const auto &bufs = Buffers();
if (bufs.empty() || curbuf_ >= bufs.size()) {
return nullptr; return nullptr;
} }
return &buffers_[curbuf_]; return &bufs[curbuf_];
} }
@@ -117,8 +119,9 @@ Editor::DisplayNameFor(const Buffer &buf) const
// Prepare list of other buffer paths // Prepare list of other buffer paths
std::vector<std::vector<std::filesystem::path> > others; std::vector<std::vector<std::filesystem::path> > others;
others.reserve(buffers_.size()); const auto &bufs = Buffers();
for (const auto &b: buffers_) { others.reserve(bufs.size());
for (const auto &b: bufs) {
if (&b == &buf) if (&b == &buf)
continue; continue;
if (b.Filename().empty()) if (b.Filename().empty())
@@ -161,41 +164,44 @@ Editor::DisplayNameFor(const Buffer &buf) const
std::size_t std::size_t
Editor::AddBuffer(const Buffer &buf) Editor::AddBuffer(const Buffer &buf)
{ {
buffers_.push_back(buf); auto &bufs = Buffers();
bufs.push_back(buf);
// Attach swap recorder // Attach swap recorder
if (swap_) { if (swap_) {
swap_->Attach(&buffers_.back()); swap_->Attach(&bufs.back());
buffers_.back().SetSwapRecorder(swap_->RecorderFor(&buffers_.back())); bufs.back().SetSwapRecorder(swap_->RecorderFor(&bufs.back()));
} }
if (buffers_.size() == 1) { if (bufs.size() == 1) {
curbuf_ = 0; curbuf_ = 0;
} }
return buffers_.size() - 1; return bufs.size() - 1;
} }
std::size_t std::size_t
Editor::AddBuffer(Buffer &&buf) Editor::AddBuffer(Buffer &&buf)
{ {
buffers_.push_back(std::move(buf)); auto &bufs = Buffers();
bufs.push_back(std::move(buf));
if (swap_) { if (swap_) {
swap_->Attach(&buffers_.back()); swap_->Attach(&bufs.back());
buffers_.back().SetSwapRecorder(swap_->RecorderFor(&buffers_.back())); bufs.back().SetSwapRecorder(swap_->RecorderFor(&bufs.back()));
} }
if (buffers_.size() == 1) { if (bufs.size() == 1) {
curbuf_ = 0; curbuf_ = 0;
} }
return buffers_.size() - 1; return bufs.size() - 1;
} }
bool bool
Editor::OpenFile(const std::string &path, std::string &err) Editor::OpenFile(const std::string &path, std::string &err)
{ {
// If there is exactly one unnamed, empty, clean buffer, reuse it instead // If the current buffer is an unnamed, empty, clean scratch buffer, reuse
// of creating a new one. // it instead of creating a new one.
if (buffers_.size() == 1) { auto &bufs_ref = Buffers();
Buffer &cur = buffers_[curbuf_]; if (!bufs_ref.empty() && curbuf_ < bufs_ref.size()) {
Buffer &cur = bufs_ref[curbuf_];
const bool unnamed = cur.Filename().empty() && !cur.IsFileBacked(); const bool unnamed = cur.Filename().empty() && !cur.IsFileBacked();
const bool clean = !cur.Dirty(); const bool clean = !cur.Dirty();
const std::size_t nrows = cur.Nrows(); 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 // Add as a new buffer and switch to it
std::size_t idx = AddBuffer(std::move(b)); std::size_t idx = AddBuffer(std::move(b));
if (swap_) { if (swap_) {
swap_->NotifyFilenameChanged(buffers_[idx]); swap_->NotifyFilenameChanged(Buffers()[idx]);
} }
SwitchTo(idx); SwitchTo(idx);
// Defensive: ensure any active prompt is closed after a successful open // Defensive: ensure any active prompt is closed after a successful open
@@ -446,12 +452,13 @@ Editor::ProcessPendingOpens()
bool bool
Editor::SwitchTo(std::size_t index) Editor::SwitchTo(std::size_t index)
{ {
if (index >= buffers_.size()) { auto &bufs = Buffers();
if (index >= bufs.size()) {
return false; return false;
} }
curbuf_ = index; curbuf_ = index;
// Robustness: ensure a valid highlighter is installed when switching buffers // Robustness: ensure a valid highlighter is installed when switching buffers
Buffer &b = buffers_[curbuf_]; Buffer &b = bufs[curbuf_];
if (b.SyntaxEnabled()) { if (b.SyntaxEnabled()) {
b.EnsureHighlighter(); b.EnsureHighlighter();
if (auto *eng = b.Highlighter()) { if (auto *eng = b.Highlighter()) {
@@ -478,21 +485,22 @@ Editor::SwitchTo(std::size_t index)
bool bool
Editor::CloseBuffer(std::size_t index) Editor::CloseBuffer(std::size_t index)
{ {
if (index >= buffers_.size()) { auto &bufs = Buffers();
if (index >= bufs.size()) {
return false; return false;
} }
if (swap_) { if (swap_) {
// Always remove swap file when closing a buffer on normal exit. // 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. // 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). // This prevents stale swap files from accumulating (e.g., when used as git editor).
swap_->Detach(&buffers_[index], true); swap_->Detach(&bufs[index], true);
buffers_[index].SetSwapRecorder(nullptr); bufs[index].SetSwapRecorder(nullptr);
} }
buffers_.erase(buffers_.begin() + static_cast<std::ptrdiff_t>(index)); bufs.erase(bufs.begin() + static_cast<std::ptrdiff_t>(index));
if (buffers_.empty()) { if (bufs.empty()) {
curbuf_ = 0; curbuf_ = 0;
} else if (curbuf_ >= buffers_.size()) { } else if (curbuf_ >= bufs.size()) {
curbuf_ = buffers_.size() - 1; curbuf_ = bufs.size() - 1;
} }
return true; return true;
} }
@@ -516,7 +524,12 @@ Editor::Reset()
// Reset close-confirm/save state // Reset close-confirm/save state
close_confirm_pending_ = false; close_confirm_pending_ = false;
close_after_save_ = false; close_after_save_ = false;
buffers_.clear(); auto &bufs = Buffers();
if (swap_) {
for (auto &buf : bufs)
swap_->Detach(&buf, true);
}
bufs.clear();
curbuf_ = 0; 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) void SetQuitConfirmPending(bool on)
{ {
quit_confirm_pending_ = on; quit_confirm_pending_ = on;
@@ -509,7 +521,7 @@ public:
// Buffers // Buffers
[[nodiscard]] std::size_t BufferCount() const [[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(); Buffer *CurrentBuffer();
const Buffer *CurrentBuffer() const; const Buffer *CurrentBuffer() const;
@@ -570,13 +595,22 @@ public:
// Direct access when needed (try to prefer methods above) // Direct access when needed (try to prefer methods above)
[[nodiscard]] const std::vector<Buffer> &Buffers() const [[nodiscard]] const std::vector<Buffer> &Buffers() const
{ {
return buffers_; return shared_buffers_ ? *shared_buffers_ : buffers_;
} }
std::vector<Buffer> &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,7 +662,8 @@ private:
bool repeatable_ = false; // whether the next command is repeatable bool repeatable_ = false; // whether the next command is repeatable
std::vector<Buffer> buffers_; std::vector<Buffer> buffers_;
std::size_t curbuf_ = 0; // index into buffers_ std::vector<Buffer> *shared_buffers_ = nullptr; // if set, use this instead of buffers_
std::size_t curbuf_ = 0; // index into buffers_
// Swap journaling manager (lifetime = editor) // Swap journaling manager (lifetime = editor)
std::unique_ptr<kte::SwapManager> swap_; std::unique_ptr<kte::SwapManager> swap_;
@@ -639,6 +674,7 @@ private:
// Quit state // Quit state
bool quit_requested_ = false; bool quit_requested_ = false;
bool new_window_requested_ = false;
bool quit_confirm_pending_ = false; bool quit_confirm_pending_ = false;
bool close_confirm_pending_ = false; // awaiting y/N to save-before-close 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 bool close_after_save_ = false; // if true, close buffer after successful Save/SaveAs

View File

@@ -330,6 +330,7 @@ enum class ThemeId {
Amber = 10, Amber = 10,
WeylandYutani = 11, WeylandYutani = 11,
Orbital = 12, Orbital = 12,
Tufte = 13,
}; };
// Current theme tracking // Current theme tracking
@@ -377,6 +378,7 @@ BackgroundModeName()
#include "themes/WeylandYutani.h" #include "themes/WeylandYutani.h"
#include "themes/Zenburn.h" #include "themes/Zenburn.h"
#include "themes/Orbital.h" #include "themes/Orbital.h"
#include "themes/Tufte.h"
// Theme abstraction and registry (generalized theme system) // Theme abstraction and registry (generalized theme system)
@@ -488,6 +490,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 { struct ZenburnTheme final : Theme {
[[nodiscard]] const char *Name() const override [[nodiscard]] const char *Name() const override
{ {
@@ -657,7 +681,7 @@ ThemeRegistry()
static std::vector<std::unique_ptr<Theme> > reg; static std::vector<std::unique_ptr<Theme> > reg;
if (reg.empty()) { if (reg.empty()) {
// Alphabetical by canonical name: // 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, 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::AmberTheme>());
reg.emplace_back(std::make_unique<detail::EInkTheme>()); reg.emplace_back(std::make_unique<detail::EInkTheme>());
reg.emplace_back(std::make_unique<detail::EverforestTheme>()); reg.emplace_back(std::make_unique<detail::EverforestTheme>());
@@ -669,6 +693,7 @@ ThemeRegistry()
reg.emplace_back(std::make_unique<detail::OrbitalTheme>()); reg.emplace_back(std::make_unique<detail::OrbitalTheme>());
reg.emplace_back(std::make_unique<detail::Plan9Theme>()); reg.emplace_back(std::make_unique<detail::Plan9Theme>());
reg.emplace_back(std::make_unique<detail::SolarizedTheme>()); 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::WeylandYutaniTheme>());
reg.emplace_back(std::make_unique<detail::ZenburnTheme>()); reg.emplace_back(std::make_unique<detail::ZenburnTheme>());
} }
@@ -855,10 +880,12 @@ ThemeIndexFromId(const ThemeId id)
return 9; return 9;
case ThemeId::Solarized: case ThemeId::Solarized:
return 10; return 10;
case ThemeId::WeylandYutani: case ThemeId::Tufte:
return 11; return 11;
case ThemeId::Zenburn: case ThemeId::WeylandYutani:
return 12; return 12;
case ThemeId::Zenburn:
return 13;
} }
return 0; return 0;
} }
@@ -892,8 +919,10 @@ ThemeIdFromIndex(const size_t idx)
case 10: case 10:
return ThemeId::Solarized; return ThemeId::Solarized;
case 11: case 11:
return ThemeId::WeylandYutani; return ThemeId::Tufte;
case 12: case 12:
return ThemeId::WeylandYutani;
case 13:
return ThemeId::Zenburn; return ThemeId::Zenburn;
} }
} }

View File

@@ -27,6 +27,7 @@ HelpText::Text()
" C-k SPACE Toggle mark\n" " C-k SPACE Toggle mark\n"
" C-k C-d Kill entire line\n" " C-k C-d Kill entire line\n"
" C-k C-q Quit now (no confirm)\n" " C-k C-q Quit now (no confirm)\n"
" C-k C-s Save\n"
" C-k C-x Save and quit\n" " C-k C-x Save and quit\n"
" C-k a Mark start of file, jump to end\n" " C-k a Mark start of file, jump to end\n"
" C-k b Switch buffer\n" " C-k b Switch buffer\n"
@@ -63,6 +64,10 @@ HelpText::Text()
" ESC BACKSPACE Delete previous word (Alt-Backspace)\n" " ESC BACKSPACE Delete previous word (Alt-Backspace)\n"
" ESC q Reflow paragraph\n" " ESC q Reflow paragraph\n"
"\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" "Control keys:\n"
" C-a C-e Line start / end\n" " C-a C-e Line start / end\n"
" C-b C-f Move left / right\n" " C-b C-f Move left / right\n"
@@ -74,12 +79,20 @@ HelpText::Text()
" C-t Regex search & replace\n" " C-t Regex search & replace\n"
" C-h Search & replace\n" " C-h Search & replace\n"
" C-l / C-g Refresh / Cancel\n" " C-l / C-g Refresh / Cancel\n"
" C-u [digits] Universal argument (repeat count)\n"
"\n" "\n"
"Buffers:\n +HELP+ is read-only. Press C-k ' to toggle; C-k h restores it.\n" "Buffers:\n +HELP+ is read-only. Press C-k ' to toggle; C-k h restores it.\n"
"\n" "\n"
"GUI appearance (command prompt):\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" " : 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" " : 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) 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 bool
GUIFrontend::Init(int &argc, char **argv, Editor &ed) GUIFrontend::Init(int &argc, char **argv, Editor &ed)
{ {
(void) argc; (void) argc;
(void) argv; (void) argv;
// Attach editor to input handler for editor-owned features (e.g., universal argument)
input_.Attach(&ed); // Load GUI configuration (fullscreen, columns/rows, font size, theme, background)
// editor dimensions will be initialized during the first Step() frame config_ = GUIConfig::Load();
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER) != 0) { if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER) != 0) {
return false; return false;
} }
// Load GUI configuration (fullscreen, columns/rows, font size, theme, background)
GUIConfig cfg = GUIConfig::Load();
// GL attributes for core profile // GL attributes for core profile
SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, 0); SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, 0);
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE); SDL_GL_SetAttribute(SDL_GL_CONTEXT_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 // Compute desired window size from config
Uint32 win_flags = SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI; Uint32 win_flags = SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI;
if (cfg.fullscreen) { int init_w = 1280, init_h = 800;
// "Fullscreen": fill the usable bounds of the primary display. if (config_.fullscreen) {
// On macOS, do NOT use true fullscreen so the menu/status bar remains visible.
SDL_Rect usable{}; SDL_Rect usable{};
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) { if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
width_ = usable.w; init_w = usable.w;
height_ = usable.h; init_h = usable.h;
} }
#if !defined(__APPLE__) #if !defined(__APPLE__)
// Non-macOS: desktop fullscreen uses the current display resolution.
win_flags |= SDL_WINDOW_FULLSCREEN_DESKTOP; win_flags |= SDL_WINDOW_FULLSCREEN_DESKTOP;
#endif #endif
} else { } else {
// Windowed: width = columns * font_size, height = (rows * 2) * font_size int w = config_.columns * static_cast<int>(config_.font_size);
int w = cfg.columns * static_cast<int>(cfg.font_size); int h = config_.rows * static_cast<int>(config_.font_size * 1.2);
int h = cfg.rows * static_cast<int>(cfg.font_size * 1.2);
// As a safety, clamp to display usable bounds if retrievable
SDL_Rect usable{}; SDL_Rect usable{};
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) { if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
w = std::min(w, usable.w); w = std::min(w, usable.w);
h = std::min(h, usable.h); h = std::min(h, usable.h);
} }
width_ = std::max(320, w); init_w = std::max(320, w);
height_ = std::max(200, h); init_h = std::max(200, h);
} }
SDL_SetHint(SDL_HINT_VIDEO_ALLOW_SCREENSAVER, "1"); SDL_SetHint(SDL_HINT_VIDEO_ALLOW_SCREENSAVER, "1");
window_ = SDL_CreateWindow( SDL_Window *win = SDL_CreateWindow(
"kge - kyle's graphical editor " KTE_VERSION_STR, "kge - kyle's graphical editor " KTE_VERSION_STR,
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
width_, height_, init_w, init_h,
win_flags); win_flags);
if (!window_) { if (!win) {
return false; return false;
} }
SDL_EnableScreenSaver(); SDL_EnableScreenSaver();
#if defined(__APPLE__) #if defined(__APPLE__)
// macOS: when "fullscreen" is requested, position the window at the if (config_.fullscreen) {
// top-left of the usable display area to mimic fullscreen while keeping
// the system menu bar visible.
if (cfg.fullscreen) {
SDL_Rect usable{}; SDL_Rect usable{};
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) { if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
SDL_SetWindowPosition(window_, usable.x, usable.y); SDL_SetWindowPosition(win, usable.x, usable.y);
} }
} }
#endif #endif
gl_ctx_ = SDL_GL_CreateContext(window_); SDL_GLContext gl_ctx = SDL_GL_CreateContext(win);
if (!gl_ctx_) if (!gl_ctx) {
SDL_DestroyWindow(win);
return false; return false;
SDL_GL_MakeCurrent(window_, gl_ctx_); }
SDL_GL_MakeCurrent(win, gl_ctx);
SDL_GL_SetSwapInterval(1); // vsync SDL_GL_SetSwapInterval(1); // vsync
// Create primary ImGui context
IMGUI_CHECKVERSION(); IMGUI_CHECKVERSION();
ImGui::CreateContext(); ImGuiContext *imgui_ctx = ImGui::CreateContext();
ImGuiIO &io = ImGui::GetIO(); ImGuiIO &io = ImGui::GetIO();
// Set custom ini filename path to ~/.config/kte/imgui.ini // Set custom ini filename path to ~/.config/kte/imgui.ini
if (const char *home = std::getenv("HOME")) { if (const char *home = std::getenv("HOME")) {
namespace fs = std::filesystem; namespace fs = std::filesystem;
fs::path config_dir = fs::path(home) / ".config" / "kte"; fs::path config_dir = fs::path(home) / ".config" / "kte";
std::error_code ec; std::error_code ec;
if (!fs::exists(config_dir)) { if (!fs::exists(config_dir)) {
fs::create_directories(config_dir, ec); fs::create_directories(config_dir, ec);
} }
if (fs::exists(config_dir)) { if (fs::exists(config_dir)) {
static std::string ini_path = (config_dir / "imgui.ini").string(); static std::string ini_path = (config_dir / "imgui.ini").string();
io.IniFilename = ini_path.c_str(); io.IniFilename = ini_path.c_str();
} }
} }
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; // Enable Keyboard Controls io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad; // Enable Gamepad Controls io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad;
ImGui::StyleColorsDark(); ImGui::StyleColorsDark();
// Apply background mode and selected theme (default: Nord). Can be changed at runtime via commands. if (config_.background == "light")
if (cfg.background == "light")
kte::SetBackgroundMode(kte::BackgroundMode::Light); kte::SetBackgroundMode(kte::BackgroundMode::Light);
else else
kte::SetBackgroundMode(kte::BackgroundMode::Dark); 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 apply_syntax_to_buffer(ed.CurrentBuffer(), config_);
if (Buffer *b = ed.CurrentBuffer()) {
if (cfg.syntax) {
b->SetSyntaxEnabled(true);
// Ensure a highlighter is available if possible
b->EnsureHighlighter();
if (auto *eng = b->Highlighter()) {
if (!eng->HasHighlighter()) {
// Try detect from filename and first line; fall back to cpp or existing filetype
std::string first_line;
const auto &rows = b->Rows();
if (!rows.empty())
first_line = static_cast<std::string>(rows[0]);
std::string ft = kte::HighlighterRegistry::DetectForPath(
b->Filename(), first_line);
if (!ft.empty()) {
eng->SetHighlighter(kte::HighlighterRegistry::CreateFor(ft));
b->SetFiletype(ft);
eng->InvalidateFrom(0);
} else {
// Unknown/unsupported -> install a null highlighter to keep syntax enabled
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
b->SetFiletype("");
eng->InvalidateFrom(0);
}
}
}
} else {
b->SetSyntaxEnabled(false);
}
}
if (!ImGui_ImplSDL2_InitForOpenGL(window_, gl_ctx_)) if (!ImGui_ImplSDL2_InitForOpenGL(win, gl_ctx))
return false; return false;
if (!ImGui_ImplOpenGL3_Init(kGlslVersion)) if (!ImGui_ImplOpenGL3_Init(kGlslVersion))
return false; 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; int w, h;
SDL_GetWindowSize(window_, &w, &h); SDL_GetWindowSize(win, &w, &h);
width_ = w; init_w = w;
height_ = h; init_h = h;
#if defined(__APPLE__) #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) { if (w > 1 && h > 1) {
SDL_SetWindowSize(window_, w - 1, h - 1); SDL_SetWindowSize(win, w - 1, h - 1);
SDL_SetWindowSize(window_, w, h); SDL_SetWindowSize(win, w, h);
// Update cached size in case backend reports immediately SDL_GetWindowSize(win, &w, &h);
SDL_GetWindowSize(window_, &w, &h); init_w = w;
width_ = w; init_h = h;
height_ = h;
} }
#endif #endif
// Install embedded fonts into registry and load configured font // Install embedded fonts
kte::Fonts::InstallDefaultFonts(); kte::Fonts::InstallDefaultFonts();
// Initialize font atlas using configured font name and size; fallback to embedded default helper if (!kte::Fonts::FontRegistry::Instance().LoadFont(config_.font, (float) config_.font_size)) {
if (!kte::Fonts::FontRegistry::Instance().LoadFont(cfg.font, (float) cfg.font_size)) { LoadGuiFont_(nullptr, (float) config_.font_size);
LoadGuiFont_(nullptr, (float) cfg.font_size); kte::Fonts::FontRegistry::Instance().RequestLoadFont("default", (float) config_.font_size);
// Record defaults in registry so subsequent size changes have a base
kte::Fonts::FontRegistry::Instance().RequestLoadFont("default", (float) cfg.font_size);
std::string n; std::string n;
float s = 0.0f; float s = 0.0f;
if (kte::Fonts::FontRegistry::Instance().ConsumePendingFontRequest(n, s)) { 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; return true;
} }
@@ -223,137 +384,214 @@ GUIFrontend::Init(int &argc, char **argv, Editor &ed)
void void
GUIFrontend::Step(Editor &ed, bool &running) 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; SDL_Event e;
while (SDL_PollEvent(&e)) { while (SDL_PollEvent(&e)) {
ImGui_ImplSDL2_ProcessEvent(&e); // Determine which window this event belongs to
Uint32 event_win_id = 0;
switch (e.type) { switch (e.type) {
case SDL_QUIT:
running = false;
break;
case SDL_WINDOWEVENT: case SDL_WINDOWEVENT:
if (e.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) { event_win_id = e.window.windowID;
width_ = e.window.data1; break;
height_ = e.window.data2; 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; break;
default: default:
break; break;
} }
// Map input to commands
input_.ProcessSDLEvent(e); if (e.type == SDL_QUIT) {
running = false;
break;
}
// Find the target window and route the event to its ImGui context
WindowState *target = nullptr;
std::size_t target_idx = 0;
if (event_win_id != 0) {
for (std::size_t i = 0; i < windows_.size(); ++i) {
if (SDL_GetWindowID(windows_[i]->window) == event_win_id) {
target = windows_[i].get();
target_idx = i;
break;
}
}
}
if (target && target->imgui_ctx) {
// Set this window's ImGui context so ImGui_ImplSDL2_ProcessEvent
// updates the correct IO state.
ImGui::SetCurrentContext(target->imgui_ctx);
ImGui_ImplSDL2_ProcessEvent(&e);
}
if (e.type == SDL_WINDOWEVENT) {
if (e.window.event == SDL_WINDOWEVENT_CLOSE) {
if (target) {
if (target_idx == 0) {
running = false;
} else {
target->alive = false;
}
}
} else if (e.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) {
if (target) {
target->width = e.window.data1;
target->height = e.window.data2;
}
}
}
// Route input events to the correct window's input handler
if (target) {
target->input.ProcessSDLEvent(e);
}
} }
// Apply pending font change before starting a new frame if (!running)
return;
// --- Apply pending font change (to all contexts) ---
{ {
std::string fname; std::string fname;
float fsize = 0.0f; float fsize = 0.0f;
if (kte::Fonts::FontRegistry::Instance().ConsumePendingFontRequest(fname, fsize)) { if (kte::Fonts::FontRegistry::Instance().ConsumePendingFontRequest(fname, fsize)) {
if (!fname.empty() && fsize > 0.0f) { if (!fname.empty() && fsize > 0.0f) {
kte::Fonts::FontRegistry::Instance().LoadFont(fname, fsize); for (auto &ws : windows_) {
// Recreate backend font texture if (!ws->alive || !ws->imgui_ctx)
ImGui_ImplOpenGL3_DestroyFontsTexture(); continue;
ImGui_ImplOpenGL3_CreateFontsTexture(); ImGui::SetCurrentContext(ws->imgui_ctx);
SDL_GL_MakeCurrent(ws->window, ws->gl_ctx);
kte::Fonts::FontRegistry::Instance().LoadFont(fname, fsize);
ImGui_ImplOpenGL3_DestroyFontsTexture();
ImGui_ImplOpenGL3_CreateFontsTexture();
}
} }
} }
} }
// Start a new ImGui frame BEFORE processing commands so dimensions are correct // --- Step each window ---
ImGui_ImplOpenGL3_NewFrame(); // We iterate by index because OpenNewWindow_ may append to windows_.
ImGui_ImplSDL2_NewFrame(window_); for (std::size_t wi = 0; wi < windows_.size(); ++wi) {
ImGui::NewFrame(); WindowState &ws = *windows_[wi];
if (!ws.alive)
continue;
// Update editor logical rows/cols using current ImGui metrics and display size Editor &wed = (wi == 0) ? ed : ws.editor;
{
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. // Shared buffer list may have been modified by another window.
const float pad_x = 6.0f; wed.ValidateBufferIndex();
const float pad_y = 6.0f;
// Use the same logic as ImGuiRenderer for available height and status bar reservation. // Activate this window's GL and ImGui contexts
float wanted_bar_h = ImGui::GetFrameHeight(); SDL_GL_MakeCurrent(ws.window, ws.gl_ctx);
float total_avail_h = std::max(0.0f, disp_h - 2.0f * pad_y); ImGui::SetCurrentContext(ws.imgui_ctx);
float actual_avail_h = std::floor((total_avail_h - wanted_bar_h) / row_h) * row_h;
// Visible content rows inside the scroll child // Start a new ImGui frame
auto content_rows = static_cast<std::size_t>(std::max(0.0f, std::floor(actual_avail_h / row_h))); ImGui_ImplOpenGL3_NewFrame();
// Editor::Rows includes the status line; add 1 back for it. ImGui_ImplSDL2_NewFrame(ws.window);
std::size_t rows = content_rows + 1; ImGui::NewFrame();
float avail_w = std::max(0.0f, disp_w - 2.0f * pad_x); // Update editor dimensions
std::size_t cols = static_cast<std::size_t>(std::max(1.0f, std::floor(avail_w / ch_w))); {
ImGuiIO &io = ImGui::GetIO();
// Only update if changed to avoid churn float disp_w = io.DisplaySize.x > 0 ? io.DisplaySize.x : static_cast<float>(ws.width);
if (rows != ed.Rows() || cols != ed.Cols()) { float disp_h = io.DisplaySize.y > 0 ? io.DisplaySize.y : static_cast<float>(ws.height);
ed.SetDimensions(rows, cols); update_editor_dimensions(wed, disp_w, disp_h);
} }
}
// Allow deferred opens (including swap recovery prompts) to run. // Allow deferred opens
ed.ProcessPendingOpens(); wed.ProcessPendingOpens();
// Execute pending mapped inputs (drain queue) AFTER dimensions are updated // Drain input queue
for (;;) { for (;;) {
MappedInput mi; MappedInput mi;
if (!input_.Poll(mi)) if (!ws.input.Poll(mi))
break; break;
if (mi.hasCommand) { if (mi.hasCommand) {
// Track kill ring before and after to sync GUI clipboard when it changes if (mi.id == CommandId::NewWindow) {
const std::string before = ed.KillRingHead(); // Open a new window; handled after this loop
Execute(ed, mi.id, mi.arg, mi.count); wed.SetNewWindowRequested(true);
const std::string after = ed.KillRingHead(); } else {
if (after != before && !after.empty()) { const std::string before = wed.KillRingHead();
// Update the system clipboard to mirror the kill ring head in GUI Execute(wed, mi.id, mi.arg, mi.count);
SDL_SetClipboardText(after.c_str()); const std::string after = wed.KillRingHead();
if (after != before && !after.empty()) {
SDL_SetClipboardText(after.c_str());
}
}
} }
} }
if (wi == 0 && wed.QuitRequested()) {
running = false;
}
// Draw
ws.renderer.Draw(wed);
// Render
ImGui::Render();
int display_w, display_h;
SDL_GL_GetDrawableSize(ws.window, &display_w, &display_h);
glViewport(0, 0, display_w, display_h);
glClearColor(0.1f, 0.1f, 0.11f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
SDL_GL_SwapWindow(ws.window);
} }
if (ed.QuitRequested()) { // Handle deferred new-window requests (must happen outside the render loop
running = false; // 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);
}
} }
// No runtime font UI; always use embedded font. // Remove dead secondary windows
for (auto it = windows_.begin() + 1; it != windows_.end();) {
if (!(*it)->alive) {
DestroyWindowResources_(**it);
it = windows_.erase(it);
} else {
++it;
}
}
// Draw editor UI // Restore primary context
renderer_.Draw(ed); if (!windows_.empty()) {
ImGui::SetCurrentContext(windows_[0]->imgui_ctx);
// Render SDL_GL_MakeCurrent(windows_[0]->window, windows_[0]->gl_ctx);
ImGui::Render(); }
int display_w, display_h;
SDL_GL_GetDrawableSize(window_, &display_w, &display_h);
glViewport(0, 0, display_w, display_h);
glClearColor(0.1f, 0.1f, 0.11f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
SDL_GL_SwapWindow(window_);
} }
void void
GUIFrontend::Shutdown() GUIFrontend::Shutdown()
{ {
ImGui_ImplOpenGL3_Shutdown(); // Destroy all windows (secondary first, then primary)
ImGui_ImplSDL2_Shutdown(); for (auto it = windows_.rbegin(); it != windows_.rend(); ++it) {
ImGui::DestroyContext(); DestroyWindowResources_(**it);
if (gl_ctx_) {
SDL_GL_DeleteContext(gl_ctx_);
gl_ctx_ = nullptr;
}
if (window_) {
SDL_DestroyWindow(window_);
window_ = nullptr;
} }
windows_.clear();
SDL_Quit(); SDL_Quit();
} }
@@ -367,7 +605,6 @@ GUIFrontend::LoadGuiFont_(const char * /*path*/, const float size_px)
ImFontConfig config; ImFontConfig config;
config.MergeMode = false; config.MergeMode = false;
// Load Basic Latin + Latin Supplement
io.Fonts->AddFontFromMemoryCompressedTTF( io.Fonts->AddFontFromMemoryCompressedTTF(
kte::Fonts::DefaultFontData, kte::Fonts::DefaultFontData,
kte::Fonts::DefaultFontSize, kte::Fonts::DefaultFontSize,
@@ -375,7 +612,6 @@ GUIFrontend::LoadGuiFont_(const char * /*path*/, const float size_px)
&config, &config,
io.Fonts->GetGlyphRangesDefault()); io.Fonts->GetGlyphRangesDefault());
// Merge Greek and Mathematical symbols from IosevkaExtended
config.MergeMode = true; config.MergeMode = true;
static const ImWchar extended_ranges[] = { static const ImWchar extended_ranges[] = {
0x0370, 0x03FF, // Greek and Coptic 0x0370, 0x03FF, // Greek and Coptic

View File

@@ -2,13 +2,18 @@
* GUIFrontend - couples ImGuiInputHandler + GUIRenderer and owns SDL2/ImGui lifecycle * GUIFrontend - couples ImGuiInputHandler + GUIRenderer and owns SDL2/ImGui lifecycle
*/ */
#pragma once #pragma once
#include <memory>
#include <vector>
#include "Frontend.h" #include "Frontend.h"
#include "GUIConfig.h" #include "GUIConfig.h"
#include "ImGuiInputHandler.h" #include "ImGuiInputHandler.h"
#include "ImGuiRenderer.h" #include "ImGuiRenderer.h"
#include "Editor.h"
struct SDL_Window; struct SDL_Window;
struct ImGuiContext;
typedef void *SDL_GLContext; typedef void *SDL_GLContext;
class GUIFrontend final : public Frontend { class GUIFrontend final : public Frontend {
@@ -24,13 +29,31 @@ public:
void Shutdown() override; void Shutdown() override;
private: 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); static bool LoadGuiFont_(const char *path, float size_px);
GUIConfig config_{}; GUIConfig config_{};
ImGuiInputHandler input_{}; // Primary window (index 0 in windows_); created during Init.
ImGuiRenderer renderer_{}; std::vector<std::unique_ptr<WindowState> > windows_;
SDL_Window *window_ = nullptr; };
SDL_GLContext gl_ctx_ = nullptr;
int width_ = 1280;
int height_ = 800;
};

View File

@@ -337,6 +337,18 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
SDL_Keymod mods = SDL_Keymod(e.key.keysym.mod); SDL_Keymod mods = SDL_Keymod(e.key.keysym.mod);
const SDL_Keycode key = e.key.keysym.sym; 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;
}
}
// Handle Paste: Ctrl+V (Windows/Linux) or Cmd+V (macOS) // 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. // 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)) { if ((mods & (KMOD_CTRL | KMOD_GUI)) && (key == SDLK_v)) {
@@ -446,7 +458,7 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
if (ed_ &&ed_ if (ed_ &&ed_
-> ->
UArg() != 0 UArg() != 0
) )

View File

@@ -76,19 +76,16 @@ ImGuiRenderer::Draw(Editor &ed)
// Two-way sync between Buffer::Rowoffs and ImGui scroll position: // Two-way sync between Buffer::Rowoffs and ImGui scroll position:
// - If command layer changed Buffer::Rowoffs since last frame, drive ImGui scroll from it. // - 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. // - 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_rowoffs = static_cast<long>(buf->Rowoffs());
const long buf_coloffs = static_cast<long>(buf->Coloffs()); const long buf_coloffs = static_cast<long>(buf->Coloffs());
// Detect programmatic change (e.g., page_down command changed rowoffs) // Detect programmatic change (e.g., page_down command changed rowoffs)
// Use SetNextWindowScroll BEFORE BeginChild to set initial scroll position // 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; float target_y = static_cast<float>(buf_rowoffs) * row_h;
ImGui::SetNextWindowScroll(ImVec2(-1.0f, target_y)); 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_x = static_cast<float>(buf_coloffs) * space_w;
float target_y = static_cast<float>(buf_rowoffs) * row_h; float target_y = static_cast<float>(buf_rowoffs) * row_h;
ImGui::SetNextWindowScroll(ImVec2(target_x, target_y)); 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 // Synchronize buffer offsets from ImGui scroll if user scrolled manually
bool forced_scroll = false; 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_top = static_cast<long>(scroll_y / row_h);
const long scroll_left = static_cast<long>(scroll_x / space_w); const long scroll_left = static_cast<long>(scroll_x / space_w);
// Check if rowoffs was programmatically changed this frame // 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; forced_scroll = true;
} }
// If user scrolled (not programmatic), update buffer offsets accordingly // 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)) { if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
mbuf->SetOffsets(static_cast<std::size_t>(std::max(0L, scroll_top)), mbuf->SetOffsets(static_cast<std::size_t>(std::max(0L, scroll_top)),
mbuf->Coloffs()); 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)) { if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
mbuf->SetOffsets(mbuf->Rowoffs(), mbuf->SetOffsets(mbuf->Rowoffs(),
static_cast<std::size_t>(std::max(0L, scroll_left))); static_cast<std::size_t>(std::max(0L, scroll_left)));
@@ -142,11 +136,11 @@ ImGuiRenderer::Draw(Editor &ed)
} }
// Update trackers for next frame // Update trackers for next frame
prev_scroll_y = scroll_y; prev_scroll_y_ = scroll_y;
prev_scroll_x = scroll_x; prev_scroll_x_ = scroll_x;
} }
prev_buf_rowoffs = buf_rowoffs; prev_buf_rowoffs_ = buf_rowoffs;
prev_buf_coloffs = buf_coloffs; prev_buf_coloffs_ = buf_coloffs;
// Cache current horizontal offset in rendered columns for click handling // Cache current horizontal offset in rendered columns for click handling
const std::size_t coloffs_now = buf->Coloffs(); const std::size_t coloffs_now = buf->Coloffs();
@@ -169,7 +163,7 @@ ImGuiRenderer::Draw(Editor &ed)
const std::size_t vsel_sy = vsel_active ? buf->VisualLineStartY() : 0; const std::size_t vsel_sy = vsel_active ? buf->VisualLineStartY() : 0;
const std::size_t vsel_ey = vsel_active ? buf->VisualLineEndY() : 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> { auto mouse_pos_to_buf = [&]() -> std::pair<std::size_t, std::size_t> {
ImVec2 mp = ImGui::GetIO().MousePos; ImVec2 mp = ImGui::GetIO().MousePos;
// Convert mouse pos to buffer row // Convert mouse pos to buffer row
@@ -211,7 +205,7 @@ ImGuiRenderer::Draw(Editor &ed)
// Mouse-driven selection: set mark on double-click or drag, update cursor on any press/drag // Mouse-driven selection: set mark on double-click or drag, update cursor on any press/drag
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
mouse_selecting = true; mouse_selecting_ = true;
auto [by, bx] = mouse_pos_to_buf(); auto [by, bx] = mouse_pos_to_buf();
char tmp[64]; char tmp[64];
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx); std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx);
@@ -225,7 +219,7 @@ ImGuiRenderer::Draw(Editor &ed)
} }
} }
} }
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(); auto [by, bx] = mouse_pos_to_buf();
// If we are dragging (mouse moved while down), ensure mark is set to start selection // If we are dragging (mouse moved while down), ensure mark is set to start selection
if (ImGui::IsMouseDragging(ImGuiMouseButton_Left, 1.0f)) { if (ImGui::IsMouseDragging(ImGuiMouseButton_Left, 1.0f)) {
@@ -242,8 +236,8 @@ ImGuiRenderer::Draw(Editor &ed)
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx); std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx);
Execute(ed, CommandId::MoveCursorTo, std::string(tmp)); Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
} }
if (mouse_selecting && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { if (mouse_selecting_ && ImGui::IsMouseReleased(ImGuiMouseButton_Left)) {
mouse_selecting = false; mouse_selecting_ = false;
} }
for (std::size_t i = rowoffs; i < lines.size(); ++i) { for (std::size_t i = rowoffs; i < lines.size(); ++i) {
// Capture the screen position before drawing the line // Capture the screen position before drawing the line

View File

@@ -11,4 +11,13 @@ public:
~ImGuiRenderer() override = default; ~ImGuiRenderer() override = default;
void Draw(Editor &ed) override; 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

@@ -48,6 +48,7 @@ stdenv.mkDerivation {
"-DBUILD_GUI=${if graphical then "ON" else "OFF"}" "-DBUILD_GUI=${if graphical then "ON" else "OFF"}"
"-DKTE_USE_QT=${if graphical-qt then "ON" else "OFF"}" "-DKTE_USE_QT=${if graphical-qt then "ON" else "OFF"}"
"-DCMAKE_BUILD_TYPE=Debug" "-DCMAKE_BUILD_TYPE=Debug"
"-DKTE_STATIC_LINK=OFF"
]; ];
installPhase = '' installPhase = ''

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

@@ -5,6 +5,7 @@
#include <cstdint> #include <cstdint>
#include <cstdio> #include <cstdio>
#include <cstring>
#include <fstream> #include <fstream>
#include <string> #include <string>
#include <vector> #include <vector>

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;
}