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>
This commit is contained in:
2026-03-24 19:48:34 -07:00
parent 34eaa72033
commit 3148e16cf8
9 changed files with 397 additions and 149 deletions

View File

@@ -4,7 +4,7 @@ project(kte)
include(GNUInstallDirs)
set(CMAKE_CXX_STANDARD 20)
set(KTE_VERSION "1.8.3")
set(KTE_VERSION "1.9.0")
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
@@ -327,6 +327,7 @@ if (BUILD_TESTS)
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

View File

@@ -752,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;
@@ -759,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;
@@ -2568,6 +2572,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();

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

@@ -521,7 +521,7 @@ public:
// Buffers
[[nodiscard]] std::size_t BufferCount() const
{
return buffers_.size();
return Buffers().size();
}
@@ -531,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;

View File

@@ -30,7 +30,7 @@
static auto kGlslVersion = "#version 150"; // GL 3.2 core (macOS compatible)
// ---------------------------------------------------------------------------
// Helpers shared between Init and OpenNewWindow_
// Helpers
// ---------------------------------------------------------------------------
static void
@@ -96,6 +96,63 @@ update_editor_dimensions(Editor &ed, float disp_w, float disp_h)
}
// ---------------------------------------------------------------------------
// 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)
{
@@ -172,9 +229,10 @@ GUIFrontend::Init(int &argc, char **argv, Editor &ed)
SDL_GL_MakeCurrent(win, gl_ctx);
SDL_GL_SetSwapInterval(1); // vsync
// Create primary ImGui context
IMGUI_CHECKVERSION();
ImGui::CreateContext();
ImGuiIO &io = ImGui::GetIO();
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")) {
@@ -236,11 +294,12 @@ 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->width = init_w;
ws->height = init_h;
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.
@@ -255,8 +314,6 @@ GUIFrontend::Init(int &argc, char **argv, Editor &ed)
bool
GUIFrontend::OpenNewWindow_(Editor &primary)
{
SDL_GL_MakeCurrent(windows_[0]->window, windows_[0]->gl_ctx);
Uint32 win_flags = SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI;
int w = windows_[0]->width;
int h = windows_[0]->height;
@@ -277,25 +334,48 @@ GUIFrontend::OpenNewWindow_(Editor &primary)
SDL_GL_MakeCurrent(win, gl_ctx);
SDL_GL_SetSwapInterval(1);
// Secondary windows share the ImGui context already created in Init.
// We need to init the SDL2/OpenGL backends for this new window.
// ImGui_ImplSDL2 supports multiple windows via SDL_GetWindowID checks.
ImGui_ImplOpenGL3_Init(kGlslVersion);
// 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);
auto ws = std::make_unique<WindowState>();
ws->window = win;
ws->gl_ctx = gl_ctx;
ws->width = w;
ws->height = h;
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 GL context as current
// Restore primary context
ImGui::SetCurrentContext(windows_[0]->imgui_ctx);
SDL_GL_MakeCurrent(windows_[0]->window, windows_[0]->gl_ctx);
return true;
}
@@ -305,10 +385,10 @@ 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) {
@@ -329,6 +409,9 @@ GUIFrontend::Step(Editor &ed, bool &running)
case SDL_MOUSEWHEEL:
event_win_id = e.wheel.windowID;
break;
case SDL_MOUSEMOTION:
event_win_id = e.motion.windowID;
break;
default:
break;
}
@@ -338,59 +421,67 @@ GUIFrontend::Step(Editor &ed, bool &running)
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) {
// Mark the window as dead; primary window close = quit
for (std::size_t i = 0; i < windows_.size(); ++i) {
if (SDL_GetWindowID(windows_[i]->window) == e.window.windowID) {
if (i == 0) {
running = false;
} else {
windows_[i]->alive = false;
}
break;
if (target) {
if (target_idx == 0) {
running = false;
} else {
target->alive = false;
}
}
} else if (e.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) {
for (auto &ws: windows_) {
if (SDL_GetWindowID(ws->window) == e.window.windowID) {
ws->width = e.window.data1;
ws->height = e.window.data2;
break;
}
if (target) {
target->width = e.window.data1;
target->height = e.window.data2;
}
}
}
// Route input events to the correct window's input handler
if (event_win_id != 0) {
// Primary window (index 0) uses the external editor &ed
if (windows_.size() > 0 &&
SDL_GetWindowID(windows_[0]->window) == event_win_id) {
windows_[0]->input.ProcessSDLEvent(e);
} else {
for (std::size_t i = 1; i < windows_.size(); ++i) {
if (SDL_GetWindowID(windows_[i]->window) == event_win_id) {
windows_[i]->input.ProcessSDLEvent(e);
break;
}
}
}
if (target) {
target->input.ProcessSDLEvent(e);
}
}
if (!running)
return;
// --- Apply pending font change ---
// --- 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) {
kte::Fonts::FontRegistry::Instance().LoadFont(fname, fsize);
ImGui_ImplOpenGL3_DestroyFontsTexture();
ImGui_ImplOpenGL3_CreateFontsTexture();
for (auto &ws : windows_) {
if (!ws->alive || !ws->imgui_ctx)
continue;
ImGui::SetCurrentContext(ws->imgui_ctx);
SDL_GL_MakeCurrent(ws->window, ws->gl_ctx);
kte::Fonts::FontRegistry::Instance().LoadFont(fname, fsize);
ImGui_ImplOpenGL3_DestroyFontsTexture();
ImGui_ImplOpenGL3_CreateFontsTexture();
}
}
}
}
@@ -404,7 +495,12 @@ GUIFrontend::Step(Editor &ed, bool &running)
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();
@@ -442,12 +538,6 @@ GUIFrontend::Step(Editor &ed, bool &running)
}
}
// Handle new-window request
if (wed.NewWindowRequested()) {
wed.SetNewWindowRequested(false);
OpenNewWindow_(ed); // always share primary editor's buffers
}
if (wi == 0 && wed.QuitRequested()) {
running = false;
}
@@ -466,52 +556,40 @@ GUIFrontend::Step(Editor &ed, bool &running)
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) {
SDL_GL_MakeCurrent((*it)->window, (*it)->gl_ctx);
ImGui_ImplOpenGL3_Shutdown();
SDL_GL_DeleteContext((*it)->gl_ctx);
SDL_DestroyWindow((*it)->window);
DestroyWindowResources_(**it);
it = windows_.erase(it);
// Restore primary context
SDL_GL_MakeCurrent(windows_[0]->window, windows_[0]->gl_ctx);
} else {
++it;
}
}
// Restore primary context
if (!windows_.empty()) {
ImGui::SetCurrentContext(windows_[0]->imgui_ctx);
SDL_GL_MakeCurrent(windows_[0]->window, windows_[0]->gl_ctx);
}
}
void
GUIFrontend::Shutdown()
{
// Destroy secondary windows first
for (std::size_t i = 1; i < windows_.size(); ++i) {
SDL_GL_MakeCurrent(windows_[i]->window, windows_[i]->gl_ctx);
ImGui_ImplOpenGL3_Shutdown();
SDL_GL_DeleteContext(windows_[i]->gl_ctx);
SDL_DestroyWindow(windows_[i]->window);
}
windows_.resize(std::min(windows_.size(), std::size_t(1)));
// Destroy primary window
if (!windows_.empty()) {
SDL_GL_MakeCurrent(windows_[0]->window, windows_[0]->gl_ctx);
}
ImGui_ImplOpenGL3_Shutdown();
ImGui_ImplSDL2_Shutdown();
ImGui::DestroyContext();
if (!windows_.empty()) {
if (windows_[0]->gl_ctx) {
SDL_GL_DeleteContext(windows_[0]->gl_ctx);
windows_[0]->gl_ctx = nullptr;
}
if (windows_[0]->window) {
SDL_DestroyWindow(windows_[0]->window);
windows_[0]->window = nullptr;
}
// Destroy all windows (secondary first, then primary)
for (auto it = windows_.rbegin(); it != windows_.rend(); ++it) {
DestroyWindowResources_(**it);
}
windows_.clear();
SDL_Quit();

View File

@@ -13,6 +13,7 @@
struct SDL_Window;
struct ImGuiContext;
typedef void *SDL_GLContext;
class GUIFrontend final : public Frontend {
@@ -28,10 +29,13 @@ public:
void Shutdown() override;
private:
// Per-window state
// 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;
SDL_Window *window = nullptr;
SDL_GLContext gl_ctx = nullptr;
ImGuiContext *imgui_ctx = nullptr;
ImGuiInputHandler input{};
ImGuiRenderer renderer{};
Editor editor{};
@@ -44,6 +48,9 @@ private:
// 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_{};

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
@@ -211,7 +205,7 @@ ImGuiRenderer::Draw(Editor &ed)
// 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);
@@ -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();
// If we are dragging (mouse moved while down), ensure mark is set to start selection
if (ImGui::IsMouseDragging(ImGuiMouseButton_Left, 1.0f)) {
@@ -242,8 +236,8 @@ ImGuiRenderer::Draw(Editor &ed)
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;
};

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