Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4b3188069 | |||
| 2571ab79c1 | |||
| d768e56727 | |||
| 11c523ad52 | |||
| c261261e26 | |||
| 27dcb41857 | |||
| bc3433e988 |
@@ -4,7 +4,7 @@ project(kte)
|
||||
include(GNUInstallDirs)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(KTE_VERSION "1.7.0")
|
||||
set(KTE_VERSION "1.8.2")
|
||||
|
||||
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
|
||||
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
|
||||
@@ -14,6 +14,7 @@ set(BUILD_TESTS ON CACHE BOOL "Enable building test programs.")
|
||||
set(KTE_FONT_SIZE "18.0" CACHE STRING "Default font size for GUI")
|
||||
option(KTE_UNDO_DEBUG "Enable undo instrumentation logs" OFF)
|
||||
option(KTE_ENABLE_TREESITTER "Enable optional Tree-sitter highlighter adapter" OFF)
|
||||
option(KTE_STATIC_LINK "Enable static linking on Linux" ON)
|
||||
|
||||
# Optionally enable AddressSanitizer (ASan)
|
||||
option(ENABLE_ASAN "Enable AddressSanitizer for builds" OFF)
|
||||
@@ -285,7 +286,7 @@ endif ()
|
||||
target_link_libraries(kte ${CURSES_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)
|
||||
endif ()
|
||||
|
||||
@@ -336,6 +337,8 @@ if (BUILD_TESTS)
|
||||
tests/test_visual_line_mode.cc
|
||||
tests/test_benchmarks.cc
|
||||
tests/test_migration_coverage.cc
|
||||
tests/test_smart_newline.cc
|
||||
tests/test_reflow_undo.cc
|
||||
|
||||
# minimal engine sources required by Buffer
|
||||
PieceTable.cc
|
||||
@@ -373,7 +376,7 @@ if (BUILD_TESTS)
|
||||
endif ()
|
||||
|
||||
# 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)
|
||||
endif ()
|
||||
endif ()
|
||||
@@ -416,7 +419,7 @@ if (BUILD_GUI)
|
||||
endif ()
|
||||
|
||||
# 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)
|
||||
endif ()
|
||||
|
||||
|
||||
128
Command.cc
128
Command.cc
@@ -115,6 +115,14 @@ ensure_cursor_visible(const Editor &ed, Buffer &buf)
|
||||
}
|
||||
|
||||
|
||||
static bool
|
||||
cmd_new_window(CommandContext &ctx)
|
||||
{
|
||||
ctx.editor.SetNewWindowRequested(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
static bool
|
||||
cmd_center_on_cursor(CommandContext &ctx)
|
||||
{
|
||||
@@ -1109,34 +1117,33 @@ cmd_theme_set_by_name(const CommandContext &ctx)
|
||||
static bool
|
||||
cmd_theme_set_by_name(CommandContext &ctx)
|
||||
{
|
||||
|
||||
# if defined(KTE_BUILD_GUI) && defined(KTE_USE_QT)
|
||||
// Qt GUI build: schedule theme change for frontend
|
||||
std::string name = ctx.arg;
|
||||
// trim spaces
|
||||
auto ltrim = [](std::string &s) {
|
||||
s.erase(s.begin(), std::find_if(s.begin(), s.end(), [](unsigned char ch) {
|
||||
return !std::isspace(ch);
|
||||
}));
|
||||
};
|
||||
auto rtrim = [](std::string &s) {
|
||||
s.erase(std::find_if(s.rbegin(), s.rend(), [](unsigned char ch) {
|
||||
return !std::isspace(ch);
|
||||
}).base(), s.end());
|
||||
};
|
||||
ltrim (name);
|
||||
rtrim (name);
|
||||
// Qt GUI build: schedule theme change for frontend
|
||||
std::string name = ctx.arg;
|
||||
// trim spaces
|
||||
auto ltrim = [](std::string &s) {
|
||||
s.erase(s.begin(), std::find_if(s.begin(), s.end(), [](unsigned char ch) {
|
||||
return !std::isspace(ch);
|
||||
}));
|
||||
};
|
||||
auto rtrim = [](std::string &s) {
|
||||
s.erase(std::find_if(s.rbegin(), s.rend(), [](unsigned char ch) {
|
||||
return !std::isspace(ch);
|
||||
}).base(), s.end());
|
||||
};
|
||||
ltrim(name);
|
||||
rtrim(name);
|
||||
if (name.empty()) {
|
||||
ctx.editor.SetStatus("theme: provide a name (e.g., nord, solarized-dark, gruvbox-light, eink)");
|
||||
return true;
|
||||
}
|
||||
kte::gThemeChangeRequest= name;
|
||||
kte::gThemeChangePending=true;
|
||||
ctx.editor.SetStatus (std::string("Theme requested: ") + name);
|
||||
kte::gThemeChangeRequest = name;
|
||||
kte::gThemeChangePending = true;
|
||||
ctx.editor.SetStatus(std::string("Theme requested: ") + name);
|
||||
return true;
|
||||
# else
|
||||
(void) ctx;
|
||||
// No-op in terminal build
|
||||
(void) ctx;
|
||||
// No-op in terminal build
|
||||
return true;
|
||||
# endif
|
||||
}
|
||||
@@ -2255,10 +2262,8 @@ cmd_show_help(CommandContext &ctx)
|
||||
};
|
||||
|
||||
auto populate_from_text = [](Buffer &b, const std::string &text) {
|
||||
// Clear existing rows
|
||||
while (b.Nrows() > 0) {
|
||||
b.delete_row(0);
|
||||
}
|
||||
// Clear existing content
|
||||
b.replace_all_bytes("");
|
||||
// Parse text and insert rows
|
||||
std::string line;
|
||||
line.reserve(128);
|
||||
@@ -2949,6 +2954,58 @@ cmd_newline(CommandContext &ctx)
|
||||
}
|
||||
|
||||
|
||||
static bool
|
||||
cmd_smart_newline(CommandContext &ctx)
|
||||
{
|
||||
Buffer *buf = ctx.editor.CurrentBuffer();
|
||||
if (!buf) {
|
||||
ctx.editor.SetStatus("No buffer to edit");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (buf->IsReadOnly()) {
|
||||
ctx.editor.SetStatus("Read-only buffer");
|
||||
return true;
|
||||
}
|
||||
|
||||
// Smart newline behavior: add a newline with the same indentation as the current line.
|
||||
// Find indentation of current line
|
||||
std::size_t y = buf->Cury();
|
||||
std::string line = buf->GetLineString(y);
|
||||
std::string indent;
|
||||
for (char c: line) {
|
||||
if (c == ' ' || c == '\t') {
|
||||
indent += c;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Perform standard newline first
|
||||
if (!cmd_newline(ctx)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Now insert the indentation at the new cursor position
|
||||
if (!indent.empty()) {
|
||||
std::size_t new_y = buf->Cury();
|
||||
std::size_t new_x = buf->Curx();
|
||||
buf->insert_text(static_cast<int>(new_y), static_cast<int>(new_x), indent);
|
||||
buf->SetCursor(new_x + indent.size(), new_y);
|
||||
buf->SetDirty(true);
|
||||
|
||||
if (auto *u = buf->Undo()) {
|
||||
u->Begin(UndoType::Insert);
|
||||
u->Append(indent);
|
||||
u->commit();
|
||||
}
|
||||
}
|
||||
|
||||
ensure_cursor_visible(ctx.editor, *buf);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
static bool
|
||||
cmd_backspace(CommandContext &ctx)
|
||||
{
|
||||
@@ -4624,7 +4681,14 @@ cmd_reflow_paragraph(CommandContext &ctx)
|
||||
new_lines.push_back("");
|
||||
|
||||
// Replace paragraph lines via PieceTable-backed operations
|
||||
UndoSystem *u = buf->Undo();
|
||||
for (std::size_t i = para_end; i + 1 > para_start; --i) {
|
||||
if (u) {
|
||||
buf->SetCursor(0, i);
|
||||
u->Begin(UndoType::DeleteRow);
|
||||
u->Append(static_cast<std::string>(buf->Rows()[i]));
|
||||
u->commit();
|
||||
}
|
||||
buf->delete_row(static_cast<int>(i));
|
||||
if (i == 0)
|
||||
break; // prevent wrap on size_t
|
||||
@@ -4633,6 +4697,12 @@ cmd_reflow_paragraph(CommandContext &ctx)
|
||||
std::size_t insert_y = para_start;
|
||||
for (const auto &ln: new_lines) {
|
||||
buf->insert_row(static_cast<int>(insert_y), std::string_view(ln));
|
||||
if (u) {
|
||||
buf->SetCursor(0, insert_y);
|
||||
u->Begin(UndoType::InsertRow);
|
||||
u->Append(std::string_view(ln));
|
||||
u->commit();
|
||||
}
|
||||
insert_y += 1;
|
||||
}
|
||||
|
||||
@@ -4806,6 +4876,9 @@ InstallDefaultCommands()
|
||||
CommandId::InsertText, "insert", "Insert text at cursor (no newlines)", cmd_insert_text, false, true
|
||||
});
|
||||
CommandRegistry::Register({CommandId::Newline, "newline", "Insert newline at cursor", cmd_newline});
|
||||
CommandRegistry::Register({
|
||||
CommandId::SmartNewline, "smart-newline", "Insert newline with auto-indent", cmd_smart_newline
|
||||
});
|
||||
CommandRegistry::Register({CommandId::Backspace, "backspace", "Delete char before cursor", cmd_backspace});
|
||||
CommandRegistry::Register({CommandId::DeleteChar, "delete-char", "Delete char at cursor", cmd_delete_char});
|
||||
CommandRegistry::Register({CommandId::KillToEOL, "kill-to-eol", "Delete to end of line", cmd_kill_to_eol});
|
||||
@@ -4935,6 +5008,11 @@ InstallDefaultCommands()
|
||||
CommandId::CenterOnCursor, "center-on-cursor", "Center viewport on current line", cmd_center_on_cursor,
|
||||
false, false
|
||||
});
|
||||
// GUI: new window
|
||||
CommandRegistry::Register({
|
||||
CommandId::NewWindow, "new-window", "Open a new editor window (GUI only)", cmd_new_window,
|
||||
false, false
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ enum class CommandId {
|
||||
// Editing
|
||||
InsertText, // arg: text to insert at cursor (UTF-8, no newlines)
|
||||
Newline, // insert a newline at cursor
|
||||
SmartNewline, // insert a newline with auto-indent (Shift-Enter)
|
||||
Backspace, // delete char before cursor (may join lines)
|
||||
DeleteChar, // delete char at cursor (may join lines)
|
||||
KillToEOL, // delete from cursor to end of line; if at EOL, delete newline
|
||||
@@ -110,6 +111,8 @@ enum class CommandId {
|
||||
SetOption, // generic ":set key=value" (v1: filetype=<lang>)
|
||||
// Viewport control
|
||||
CenterOnCursor, // center the viewport on the current cursor line (C-k k)
|
||||
// GUI: open a new editor window sharing the same buffer list
|
||||
NewWindow,
|
||||
};
|
||||
|
||||
|
||||
|
||||
29
Editor.h
29
Editor.h
@@ -246,6 +246,18 @@ public:
|
||||
}
|
||||
|
||||
|
||||
void SetNewWindowRequested(bool on)
|
||||
{
|
||||
new_window_requested_ = on;
|
||||
}
|
||||
|
||||
|
||||
[[nodiscard]] bool NewWindowRequested() const
|
||||
{
|
||||
return new_window_requested_;
|
||||
}
|
||||
|
||||
|
||||
void SetQuitConfirmPending(bool on)
|
||||
{
|
||||
quit_confirm_pending_ = on;
|
||||
@@ -570,13 +582,22 @@ public:
|
||||
// Direct access when needed (try to prefer methods above)
|
||||
[[nodiscard]] const std::vector<Buffer> &Buffers() const
|
||||
{
|
||||
return buffers_;
|
||||
return shared_buffers_ ? *shared_buffers_ : buffers_;
|
||||
}
|
||||
|
||||
|
||||
std::vector<Buffer> &Buffers()
|
||||
{
|
||||
return buffers_;
|
||||
return shared_buffers_ ? *shared_buffers_ : buffers_;
|
||||
}
|
||||
|
||||
|
||||
// Share another editor's buffer list. When set, this editor operates on
|
||||
// the provided vector instead of its own. Pass nullptr to detach.
|
||||
void SetSharedBuffers(std::vector<Buffer> *shared)
|
||||
{
|
||||
shared_buffers_ = shared;
|
||||
curbuf_ = 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -628,7 +649,8 @@ private:
|
||||
bool repeatable_ = false; // whether the next command is repeatable
|
||||
|
||||
std::vector<Buffer> buffers_;
|
||||
std::size_t curbuf_ = 0; // index into buffers_
|
||||
std::vector<Buffer> *shared_buffers_ = nullptr; // if set, use this instead of buffers_
|
||||
std::size_t curbuf_ = 0; // index into buffers_
|
||||
|
||||
// Swap journaling manager (lifetime = editor)
|
||||
std::unique_ptr<kte::SwapManager> swap_;
|
||||
@@ -639,6 +661,7 @@ private:
|
||||
|
||||
// Quit state
|
||||
bool quit_requested_ = false;
|
||||
bool new_window_requested_ = false;
|
||||
bool quit_confirm_pending_ = false;
|
||||
bool close_confirm_pending_ = false; // awaiting y/N to save-before-close
|
||||
bool close_after_save_ = false; // if true, close buffer after successful Save/SaveAs
|
||||
|
||||
@@ -20,6 +20,8 @@ ErrorHandler::ErrorHandler()
|
||||
fs::create_directories(log_dir);
|
||||
}
|
||||
log_file_path_ = (log_dir / "error.log").string();
|
||||
// Create the log file immediately so it exists in the state directory
|
||||
ensure_log_file();
|
||||
} catch (...) {
|
||||
// If we can't create the directory, disable file logging
|
||||
file_logging_enabled_ = false;
|
||||
@@ -34,11 +36,7 @@ ErrorHandler::ErrorHandler()
|
||||
ErrorHandler::~ErrorHandler()
|
||||
{
|
||||
std::lock_guard<std::mutex> lg(mtx_);
|
||||
if (log_file_ &&log_file_
|
||||
->
|
||||
is_open()
|
||||
)
|
||||
{
|
||||
if (log_file_ && log_file_->is_open()) {
|
||||
log_file_->flush();
|
||||
log_file_->close();
|
||||
}
|
||||
@@ -249,11 +247,8 @@ void
|
||||
ErrorHandler::ensure_log_file()
|
||||
{
|
||||
// Must be called with mtx_ held
|
||||
if (log_file_ &&log_file_
|
||||
->
|
||||
is_open()
|
||||
)
|
||||
return;
|
||||
if (log_file_ && log_file_->is_open())
|
||||
return;
|
||||
|
||||
if (log_file_path_.empty())
|
||||
return;
|
||||
@@ -313,6 +308,6 @@ std::uint64_t
|
||||
ErrorHandler::now_ns()
|
||||
{
|
||||
using namespace std::chrono;
|
||||
return duration_cast<nanoseconds>(steady_clock::now().time_since_epoch()).count();
|
||||
return duration_cast<nanoseconds>(system_clock::now().time_since_epoch()).count();
|
||||
}
|
||||
} // namespace kte
|
||||
15
HelpText.cc
15
HelpText.cc
@@ -27,6 +27,7 @@ HelpText::Text()
|
||||
" C-k SPACE Toggle mark\n"
|
||||
" C-k C-d Kill entire line\n"
|
||||
" C-k C-q Quit now (no confirm)\n"
|
||||
" C-k C-s Save\n"
|
||||
" C-k C-x Save and quit\n"
|
||||
" C-k a Mark start of file, jump to end\n"
|
||||
" C-k b Switch buffer\n"
|
||||
@@ -63,6 +64,10 @@ HelpText::Text()
|
||||
" ESC BACKSPACE Delete previous word (Alt-Backspace)\n"
|
||||
" ESC q Reflow paragraph\n"
|
||||
"\n"
|
||||
"Universal argument:\n"
|
||||
" C-u Begin repeat count (then type digits); C-u alone multiplies by 4\n"
|
||||
" C-u N <cmd> Repeat <cmd> N times (e.g., C-u 8 C-f moves right 8 chars)\n"
|
||||
"\n"
|
||||
"Control keys:\n"
|
||||
" C-a C-e Line start / end\n"
|
||||
" C-b C-f Move left / right\n"
|
||||
@@ -74,12 +79,20 @@ HelpText::Text()
|
||||
" C-t Regex search & replace\n"
|
||||
" C-h Search & replace\n"
|
||||
" C-l / C-g Refresh / Cancel\n"
|
||||
" C-u [digits] Universal argument (repeat count)\n"
|
||||
"\n"
|
||||
"Buffers:\n +HELP+ is read-only. Press C-k ' to toggle; C-k h restores it.\n"
|
||||
"\n"
|
||||
"GUI appearance (command prompt):\n"
|
||||
" : theme NAME Set GUI theme (amber, eink, everforest, gruvbox, kanagawa-paper, lcars, nord, old-book, plan9, solarized, weyland-yutani, zenburn)\n"
|
||||
" : background MODE Set background: light | dark (affects eink, gruvbox, old-book, solarized)\n"
|
||||
"\n"
|
||||
"GUI config file options:\n"
|
||||
" font_size=NUM Set font size in pixels (default: 16; e.g., font_size=18)\n"
|
||||
"\n"
|
||||
"GUI window management:\n"
|
||||
" Cmd+N (macOS) Open a new editor window sharing the same buffers\n"
|
||||
" Ctrl+Shift+N (Linux) Open a new editor window sharing the same buffers\n"
|
||||
" Close window Secondary windows close independently; closing the\n"
|
||||
" primary window quits the editor\n"
|
||||
);
|
||||
}
|
||||
510
ImGuiFrontend.cc
510
ImGuiFrontend.cc
@@ -29,21 +29,86 @@
|
||||
|
||||
static auto kGlslVersion = "#version 150"; // GL 3.2 core (macOS compatible)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers shared between Init and OpenNewWindow_
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
bool
|
||||
GUIFrontend::Init(int &argc, char **argv, Editor &ed)
|
||||
{
|
||||
(void) argc;
|
||||
(void) argv;
|
||||
// Attach editor to input handler for editor-owned features (e.g., universal argument)
|
||||
input_.Attach(&ed);
|
||||
// editor dimensions will be initialized during the first Step() frame
|
||||
|
||||
// Load GUI configuration (fullscreen, columns/rows, font size, theme, background)
|
||||
config_ = GUIConfig::Load();
|
||||
|
||||
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER) != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Load GUI configuration (fullscreen, columns/rows, font size, theme, background)
|
||||
GUIConfig cfg = GUIConfig::Load();
|
||||
|
||||
// GL attributes for core profile
|
||||
SDL_GL_SetAttribute(SDL_GL_CONTEXT_FLAGS, 0);
|
||||
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE);
|
||||
@@ -56,61 +121,55 @@ GUIFrontend::Init(int &argc, char **argv, Editor &ed)
|
||||
// Compute desired window size from config
|
||||
Uint32 win_flags = SDL_WINDOW_OPENGL | SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI;
|
||||
|
||||
if (cfg.fullscreen) {
|
||||
// "Fullscreen": fill the usable bounds of the primary display.
|
||||
// On macOS, do NOT use true fullscreen so the menu/status bar remains visible.
|
||||
int init_w = 1280, init_h = 800;
|
||||
if (config_.fullscreen) {
|
||||
SDL_Rect usable{};
|
||||
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
|
||||
width_ = usable.w;
|
||||
height_ = usable.h;
|
||||
init_w = usable.w;
|
||||
init_h = usable.h;
|
||||
}
|
||||
#if !defined(__APPLE__)
|
||||
// Non-macOS: desktop fullscreen uses the current display resolution.
|
||||
win_flags |= SDL_WINDOW_FULLSCREEN_DESKTOP;
|
||||
#endif
|
||||
} else {
|
||||
// Windowed: width = columns * font_size, height = (rows * 2) * font_size
|
||||
int w = cfg.columns * static_cast<int>(cfg.font_size);
|
||||
int h = cfg.rows * static_cast<int>(cfg.font_size * 1.2);
|
||||
|
||||
// As a safety, clamp to display usable bounds if retrievable
|
||||
int w = config_.columns * static_cast<int>(config_.font_size);
|
||||
int h = config_.rows * static_cast<int>(config_.font_size * 1.2);
|
||||
SDL_Rect usable{};
|
||||
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
|
||||
w = std::min(w, usable.w);
|
||||
h = std::min(h, usable.h);
|
||||
}
|
||||
width_ = std::max(320, w);
|
||||
height_ = std::max(200, h);
|
||||
init_w = std::max(320, w);
|
||||
init_h = std::max(200, h);
|
||||
}
|
||||
|
||||
SDL_SetHint(SDL_HINT_VIDEO_ALLOW_SCREENSAVER, "1");
|
||||
window_ = SDL_CreateWindow(
|
||||
SDL_Window *win = SDL_CreateWindow(
|
||||
"kge - kyle's graphical editor " KTE_VERSION_STR,
|
||||
SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED,
|
||||
width_, height_,
|
||||
init_w, init_h,
|
||||
win_flags);
|
||||
if (!window_) {
|
||||
if (!win) {
|
||||
return false;
|
||||
}
|
||||
|
||||
SDL_EnableScreenSaver();
|
||||
|
||||
#if defined(__APPLE__)
|
||||
// macOS: when "fullscreen" is requested, position the window at the
|
||||
// top-left of the usable display area to mimic fullscreen while keeping
|
||||
// the system menu bar visible.
|
||||
if (cfg.fullscreen) {
|
||||
if (config_.fullscreen) {
|
||||
SDL_Rect usable{};
|
||||
if (SDL_GetDisplayUsableBounds(0, &usable) == 0) {
|
||||
SDL_SetWindowPosition(window_, usable.x, usable.y);
|
||||
SDL_SetWindowPosition(win, usable.x, usable.y);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
gl_ctx_ = SDL_GL_CreateContext(window_);
|
||||
if (!gl_ctx_)
|
||||
SDL_GLContext gl_ctx = SDL_GL_CreateContext(win);
|
||||
if (!gl_ctx) {
|
||||
SDL_DestroyWindow(win);
|
||||
return false;
|
||||
SDL_GL_MakeCurrent(window_, gl_ctx_);
|
||||
}
|
||||
SDL_GL_MakeCurrent(win, gl_ctx);
|
||||
SDL_GL_SetSwapInterval(1); // vsync
|
||||
|
||||
IMGUI_CHECKVERSION();
|
||||
@@ -121,94 +180,54 @@ GUIFrontend::Init(int &argc, char **argv, Editor &ed)
|
||||
if (const char *home = std::getenv("HOME")) {
|
||||
namespace fs = std::filesystem;
|
||||
fs::path config_dir = fs::path(home) / ".config" / "kte";
|
||||
|
||||
std::error_code ec;
|
||||
if (!fs::exists(config_dir)) {
|
||||
fs::create_directories(config_dir, ec);
|
||||
}
|
||||
|
||||
if (fs::exists(config_dir)) {
|
||||
static std::string ini_path = (config_dir / "imgui.ini").string();
|
||||
io.IniFilename = ini_path.c_str();
|
||||
}
|
||||
}
|
||||
|
||||
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard; // Enable Keyboard Controls
|
||||
io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad; // Enable Gamepad Controls
|
||||
io.ConfigFlags |= ImGuiConfigFlags_NavEnableKeyboard;
|
||||
io.ConfigFlags |= ImGuiConfigFlags_NavEnableGamepad;
|
||||
ImGui::StyleColorsDark();
|
||||
|
||||
// Apply background mode and selected theme (default: Nord). Can be changed at runtime via commands.
|
||||
if (cfg.background == "light")
|
||||
if (config_.background == "light")
|
||||
kte::SetBackgroundMode(kte::BackgroundMode::Light);
|
||||
else
|
||||
kte::SetBackgroundMode(kte::BackgroundMode::Dark);
|
||||
kte::ApplyThemeByName(cfg.theme);
|
||||
kte::ApplyThemeByName(config_.theme);
|
||||
|
||||
// Apply default syntax highlighting preference from GUI config to the current buffer
|
||||
if (Buffer *b = ed.CurrentBuffer()) {
|
||||
if (cfg.syntax) {
|
||||
b->SetSyntaxEnabled(true);
|
||||
// Ensure a highlighter is available if possible
|
||||
b->EnsureHighlighter();
|
||||
if (auto *eng = b->Highlighter()) {
|
||||
if (!eng->HasHighlighter()) {
|
||||
// Try detect from filename and first line; fall back to cpp or existing filetype
|
||||
std::string first_line;
|
||||
const auto &rows = b->Rows();
|
||||
if (!rows.empty())
|
||||
first_line = static_cast<std::string>(rows[0]);
|
||||
std::string ft = kte::HighlighterRegistry::DetectForPath(
|
||||
b->Filename(), first_line);
|
||||
if (!ft.empty()) {
|
||||
eng->SetHighlighter(kte::HighlighterRegistry::CreateFor(ft));
|
||||
b->SetFiletype(ft);
|
||||
eng->InvalidateFrom(0);
|
||||
} else {
|
||||
// Unknown/unsupported -> install a null highlighter to keep syntax enabled
|
||||
eng->SetHighlighter(std::make_unique<kte::NullHighlighter>());
|
||||
b->SetFiletype("");
|
||||
eng->InvalidateFrom(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
b->SetSyntaxEnabled(false);
|
||||
}
|
||||
}
|
||||
apply_syntax_to_buffer(ed.CurrentBuffer(), config_);
|
||||
|
||||
if (!ImGui_ImplSDL2_InitForOpenGL(window_, gl_ctx_))
|
||||
if (!ImGui_ImplSDL2_InitForOpenGL(win, gl_ctx))
|
||||
return false;
|
||||
if (!ImGui_ImplOpenGL3_Init(kGlslVersion))
|
||||
return false;
|
||||
|
||||
// Cache initial window size; logical rows/cols will be computed in Step() once a valid ImGui frame exists
|
||||
// Cache initial window size
|
||||
int w, h;
|
||||
SDL_GetWindowSize(window_, &w, &h);
|
||||
width_ = w;
|
||||
height_ = h;
|
||||
SDL_GetWindowSize(win, &w, &h);
|
||||
init_w = w;
|
||||
init_h = h;
|
||||
|
||||
#if defined(__APPLE__)
|
||||
// Workaround: On macOS Retina when starting maximized, we sometimes get a
|
||||
// subtle input vs draw alignment mismatch until the first manual resize.
|
||||
// Nudge the window size by 1px and back to trigger a proper internal
|
||||
// recomputation, without visible impact.
|
||||
if (w > 1 && h > 1) {
|
||||
SDL_SetWindowSize(window_, w - 1, h - 1);
|
||||
SDL_SetWindowSize(window_, w, h);
|
||||
// Update cached size in case backend reports immediately
|
||||
SDL_GetWindowSize(window_, &w, &h);
|
||||
width_ = w;
|
||||
height_ = h;
|
||||
SDL_SetWindowSize(win, w - 1, h - 1);
|
||||
SDL_SetWindowSize(win, w, h);
|
||||
SDL_GetWindowSize(win, &w, &h);
|
||||
init_w = w;
|
||||
init_h = h;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Install embedded fonts into registry and load configured font
|
||||
// Install embedded fonts
|
||||
kte::Fonts::InstallDefaultFonts();
|
||||
// Initialize font atlas using configured font name and size; fallback to embedded default helper
|
||||
if (!kte::Fonts::FontRegistry::Instance().LoadFont(cfg.font, (float) cfg.font_size)) {
|
||||
LoadGuiFont_(nullptr, (float) cfg.font_size);
|
||||
// Record defaults in registry so subsequent size changes have a base
|
||||
kte::Fonts::FontRegistry::Instance().RequestLoadFont("default", (float) cfg.font_size);
|
||||
if (!kte::Fonts::FontRegistry::Instance().LoadFont(config_.font, (float) config_.font_size)) {
|
||||
LoadGuiFont_(nullptr, (float) config_.font_size);
|
||||
kte::Fonts::FontRegistry::Instance().RequestLoadFont("default", (float) config_.font_size);
|
||||
std::string n;
|
||||
float s = 0.0f;
|
||||
if (kte::Fonts::FontRegistry::Instance().ConsumePendingFontRequest(n, s)) {
|
||||
@@ -216,6 +235,68 @@ 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;
|
||||
// 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)
|
||||
{
|
||||
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;
|
||||
|
||||
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);
|
||||
|
||||
// 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);
|
||||
|
||||
auto ws = std::make_unique<WindowState>();
|
||||
ws->window = win;
|
||||
ws->gl_ctx = gl_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());
|
||||
ws->input.Attach(&ws->editor);
|
||||
|
||||
windows_.push_back(std::move(ws));
|
||||
|
||||
// Restore primary GL context as current
|
||||
SDL_GL_MakeCurrent(windows_[0]->window, windows_[0]->gl_ctx);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -223,137 +304,216 @@ GUIFrontend::Init(int &argc, char **argv, Editor &ed)
|
||||
void
|
||||
GUIFrontend::Step(Editor &ed, bool &running)
|
||||
{
|
||||
// --- Event processing ---
|
||||
SDL_Event e;
|
||||
while (SDL_PollEvent(&e)) {
|
||||
ImGui_ImplSDL2_ProcessEvent(&e);
|
||||
|
||||
// Determine which window this event belongs to
|
||||
Uint32 event_win_id = 0;
|
||||
switch (e.type) {
|
||||
case SDL_QUIT:
|
||||
running = false;
|
||||
break;
|
||||
case SDL_WINDOWEVENT:
|
||||
if (e.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) {
|
||||
width_ = e.window.data1;
|
||||
height_ = e.window.data2;
|
||||
}
|
||||
event_win_id = e.window.windowID;
|
||||
break;
|
||||
case SDL_KEYDOWN:
|
||||
case SDL_KEYUP:
|
||||
event_win_id = e.key.windowID;
|
||||
break;
|
||||
case SDL_TEXTINPUT:
|
||||
event_win_id = e.text.windowID;
|
||||
break;
|
||||
case SDL_MOUSEBUTTONDOWN:
|
||||
case SDL_MOUSEBUTTONUP:
|
||||
event_win_id = e.button.windowID;
|
||||
break;
|
||||
case SDL_MOUSEWHEEL:
|
||||
event_win_id = e.wheel.windowID;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
// Map input to commands
|
||||
input_.ProcessSDLEvent(e);
|
||||
|
||||
if (e.type == SDL_QUIT) {
|
||||
running = false;
|
||||
break;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply pending font change before starting a new frame
|
||||
if (!running)
|
||||
return;
|
||||
|
||||
// --- Apply pending font change ---
|
||||
{
|
||||
std::string fname;
|
||||
float fsize = 0.0f;
|
||||
if (kte::Fonts::FontRegistry::Instance().ConsumePendingFontRequest(fname, fsize)) {
|
||||
if (!fname.empty() && fsize > 0.0f) {
|
||||
kte::Fonts::FontRegistry::Instance().LoadFont(fname, fsize);
|
||||
// Recreate backend font texture
|
||||
ImGui_ImplOpenGL3_DestroyFontsTexture();
|
||||
ImGui_ImplOpenGL3_CreateFontsTexture();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start a new ImGui frame BEFORE processing commands so dimensions are correct
|
||||
ImGui_ImplOpenGL3_NewFrame();
|
||||
ImGui_ImplSDL2_NewFrame(window_);
|
||||
ImGui::NewFrame();
|
||||
// --- Step each window ---
|
||||
// We iterate by index because OpenNewWindow_ may append to windows_.
|
||||
for (std::size_t wi = 0; wi < windows_.size(); ++wi) {
|
||||
WindowState &ws = *windows_[wi];
|
||||
if (!ws.alive)
|
||||
continue;
|
||||
|
||||
// Update editor logical rows/cols using current ImGui metrics and display size
|
||||
{
|
||||
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_);
|
||||
Editor &wed = (wi == 0) ? ed : ws.editor;
|
||||
|
||||
// Account for the GUI window padding and the status bar height used in ImGuiRenderer.
|
||||
const float pad_x = 6.0f;
|
||||
const float pad_y = 6.0f;
|
||||
SDL_GL_MakeCurrent(ws.window, ws.gl_ctx);
|
||||
|
||||
// Use the same logic as ImGuiRenderer for available height and status bar reservation.
|
||||
float wanted_bar_h = ImGui::GetFrameHeight();
|
||||
float total_avail_h = std::max(0.0f, disp_h - 2.0f * pad_y);
|
||||
float actual_avail_h = std::floor((total_avail_h - wanted_bar_h) / row_h) * row_h;
|
||||
// Start a new ImGui frame
|
||||
ImGui_ImplOpenGL3_NewFrame();
|
||||
ImGui_ImplSDL2_NewFrame(ws.window);
|
||||
ImGui::NewFrame();
|
||||
|
||||
// Visible content rows inside the scroll child
|
||||
auto content_rows = static_cast<std::size_t>(std::max(0.0f, std::floor(actual_avail_h / row_h)));
|
||||
// Editor::Rows includes the status line; add 1 back for it.
|
||||
std::size_t rows = content_rows + 1;
|
||||
|
||||
float avail_w = std::max(0.0f, disp_w - 2.0f * pad_x);
|
||||
std::size_t cols = static_cast<std::size_t>(std::max(1.0f, std::floor(avail_w / ch_w)));
|
||||
|
||||
// Only update if changed to avoid churn
|
||||
if (rows != ed.Rows() || cols != ed.Cols()) {
|
||||
ed.SetDimensions(rows, cols);
|
||||
// Update editor dimensions
|
||||
{
|
||||
ImGuiIO &io = ImGui::GetIO();
|
||||
float disp_w = io.DisplaySize.x > 0 ? io.DisplaySize.x : static_cast<float>(ws.width);
|
||||
float disp_h = io.DisplaySize.y > 0 ? io.DisplaySize.y : static_cast<float>(ws.height);
|
||||
update_editor_dimensions(wed, disp_w, disp_h);
|
||||
}
|
||||
}
|
||||
|
||||
// Allow deferred opens (including swap recovery prompts) to run.
|
||||
ed.ProcessPendingOpens();
|
||||
// Allow deferred opens
|
||||
wed.ProcessPendingOpens();
|
||||
|
||||
// Execute pending mapped inputs (drain queue) AFTER dimensions are updated
|
||||
for (;;) {
|
||||
MappedInput mi;
|
||||
if (!input_.Poll(mi))
|
||||
break;
|
||||
if (mi.hasCommand) {
|
||||
// Track kill ring before and after to sync GUI clipboard when it changes
|
||||
const std::string before = ed.KillRingHead();
|
||||
Execute(ed, mi.id, mi.arg, mi.count);
|
||||
const std::string after = ed.KillRingHead();
|
||||
if (after != before && !after.empty()) {
|
||||
// Update the system clipboard to mirror the kill ring head in GUI
|
||||
SDL_SetClipboardText(after.c_str());
|
||||
// Drain input queue
|
||||
for (;;) {
|
||||
MappedInput mi;
|
||||
if (!ws.input.Poll(mi))
|
||||
break;
|
||||
if (mi.hasCommand) {
|
||||
if (mi.id == CommandId::NewWindow) {
|
||||
// Open a new window; handled after this loop
|
||||
wed.SetNewWindowRequested(true);
|
||||
} else {
|
||||
const std::string before = wed.KillRingHead();
|
||||
Execute(wed, mi.id, mi.arg, mi.count);
|
||||
const std::string after = wed.KillRingHead();
|
||||
if (after != before && !after.empty()) {
|
||||
SDL_SetClipboardText(after.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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()) {
|
||||
running = false;
|
||||
// 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);
|
||||
it = windows_.erase(it);
|
||||
// Restore primary context
|
||||
SDL_GL_MakeCurrent(windows_[0]->window, windows_[0]->gl_ctx);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
|
||||
// No runtime font UI; always use embedded font.
|
||||
|
||||
// Draw editor UI
|
||||
renderer_.Draw(ed);
|
||||
|
||||
// Render
|
||||
ImGui::Render();
|
||||
int display_w, display_h;
|
||||
SDL_GL_GetDrawableSize(window_, &display_w, &display_h);
|
||||
glViewport(0, 0, display_w, display_h);
|
||||
glClearColor(0.1f, 0.1f, 0.11f, 1.0f);
|
||||
glClear(GL_COLOR_BUFFER_BIT);
|
||||
ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData());
|
||||
SDL_GL_SwapWindow(window_);
|
||||
}
|
||||
|
||||
|
||||
void
|
||||
GUIFrontend::Shutdown()
|
||||
{
|
||||
// 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 (gl_ctx_) {
|
||||
SDL_GL_DeleteContext(gl_ctx_);
|
||||
gl_ctx_ = nullptr;
|
||||
}
|
||||
if (window_) {
|
||||
SDL_DestroyWindow(window_);
|
||||
window_ = nullptr;
|
||||
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;
|
||||
}
|
||||
}
|
||||
windows_.clear();
|
||||
SDL_Quit();
|
||||
}
|
||||
|
||||
@@ -367,7 +527,6 @@ GUIFrontend::LoadGuiFont_(const char * /*path*/, const float size_px)
|
||||
ImFontConfig config;
|
||||
config.MergeMode = false;
|
||||
|
||||
// Load Basic Latin + Latin Supplement
|
||||
io.Fonts->AddFontFromMemoryCompressedTTF(
|
||||
kte::Fonts::DefaultFontData,
|
||||
kte::Fonts::DefaultFontSize,
|
||||
@@ -375,7 +534,6 @@ GUIFrontend::LoadGuiFont_(const char * /*path*/, const float size_px)
|
||||
&config,
|
||||
io.Fonts->GetGlyphRangesDefault());
|
||||
|
||||
// Merge Greek and Mathematical symbols from IosevkaExtended
|
||||
config.MergeMode = true;
|
||||
static const ImWchar extended_ranges[] = {
|
||||
0x0370, 0x03FF, // Greek and Coptic
|
||||
|
||||
@@ -2,10 +2,14 @@
|
||||
* GUIFrontend - couples ImGuiInputHandler + GUIRenderer and owns SDL2/ImGui lifecycle
|
||||
*/
|
||||
#pragma once
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
#include "Frontend.h"
|
||||
#include "GUIConfig.h"
|
||||
#include "ImGuiInputHandler.h"
|
||||
#include "ImGuiRenderer.h"
|
||||
#include "Editor.h"
|
||||
|
||||
|
||||
struct SDL_Window;
|
||||
@@ -24,13 +28,25 @@ public:
|
||||
void Shutdown() override;
|
||||
|
||||
private:
|
||||
// Per-window state
|
||||
struct WindowState {
|
||||
SDL_Window *window = nullptr;
|
||||
SDL_GLContext gl_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);
|
||||
|
||||
static bool LoadGuiFont_(const char *path, float size_px);
|
||||
|
||||
GUIConfig config_{};
|
||||
ImGuiInputHandler input_{};
|
||||
ImGuiRenderer renderer_{};
|
||||
SDL_Window *window_ = nullptr;
|
||||
SDL_GLContext gl_ctx_ = nullptr;
|
||||
int width_ = 1280;
|
||||
int height_ = 800;
|
||||
// Primary window (index 0 in windows_); created during Init.
|
||||
std::vector<std::unique_ptr<WindowState> > windows_;
|
||||
};
|
||||
@@ -125,7 +125,11 @@ map_key(const SDL_Keycode key,
|
||||
case SDLK_KP_ENTER:
|
||||
k_prefix = false;
|
||||
k_ctrl_pending = false;
|
||||
out = {true, CommandId::Newline, "", 0};
|
||||
if (mod & KMOD_SHIFT) {
|
||||
out = {true, CommandId::SmartNewline, "", 0};
|
||||
} else {
|
||||
out = {true, CommandId::Newline, "", 0};
|
||||
}
|
||||
return true;
|
||||
case SDLK_ESCAPE:
|
||||
k_prefix = false;
|
||||
@@ -333,6 +337,18 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
||||
SDL_Keymod mods = SDL_Keymod(e.key.keysym.mod);
|
||||
const SDL_Keycode key = e.key.keysym.sym;
|
||||
|
||||
// New window: Cmd+N (macOS) or Ctrl+Shift+N (Linux/Windows)
|
||||
{
|
||||
const bool gui_n = (mods & KMOD_GUI) && !(mods & KMOD_CTRL) && (key == SDLK_n);
|
||||
const bool ctrl_sn = (mods & KMOD_CTRL) && (mods & KMOD_SHIFT) && (key == SDLK_n);
|
||||
if (gui_n || ctrl_sn) {
|
||||
std::lock_guard<std::mutex> lk(mu_);
|
||||
q_.push(MappedInput{true, CommandId::NewWindow, std::string(), 0});
|
||||
suppress_text_input_once_ = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Paste: Ctrl+V (Windows/Linux) or Cmd+V (macOS)
|
||||
// Note: SDL defines letter keycodes in lowercase only (e.g., SDLK_v). Shift does not change keycode.
|
||||
if ((mods & (KMOD_CTRL | KMOD_GUI)) && (key == SDLK_v)) {
|
||||
@@ -439,12 +455,14 @@ ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
|
||||
}
|
||||
|
||||
// If editor universal argument is active, consume digit TEXTINPUT
|
||||
if (ed_ && ed_
|
||||
if (ed_ &&ed_
|
||||
|
||||
|
||||
->
|
||||
UArg() != 0
|
||||
) {
|
||||
|
||||
->
|
||||
UArg() != 0
|
||||
)
|
||||
{
|
||||
const char *txt = e.text.text;
|
||||
if (txt && *txt) {
|
||||
unsigned char c0 = static_cast<unsigned char>(txt[0]);
|
||||
|
||||
@@ -209,19 +209,35 @@ ImGuiRenderer::Draw(Editor &ed)
|
||||
return {by, best_col};
|
||||
};
|
||||
|
||||
// Mouse-driven selection: set mark on press, update cursor on drag
|
||||
// Mouse-driven selection: set mark on double-click or drag, update cursor on any press/drag
|
||||
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
||||
mouse_selecting = true;
|
||||
auto [by, bx] = mouse_pos_to_buf();
|
||||
char tmp[64];
|
||||
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx);
|
||||
Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
|
||||
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
|
||||
mbuf->SetMark(bx, by);
|
||||
|
||||
// Only set mark on double click.
|
||||
// Dragging will also set the mark if not already set (handled below).
|
||||
if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) {
|
||||
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
|
||||
mbuf->SetMark(bx, by);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (mouse_selecting && ImGui::IsWindowHovered() && ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
|
||||
auto [by, bx] = mouse_pos_to_buf();
|
||||
// If we are dragging (mouse moved while down), ensure mark is set to start selection
|
||||
if (ImGui::IsMouseDragging(ImGuiMouseButton_Left, 1.0f)) {
|
||||
if (Buffer *mbuf = const_cast<Buffer *>(buf)) {
|
||||
if (!mbuf->MarkSet()) {
|
||||
// We'd need to convert click_pos to buf coords, but it's complex here.
|
||||
// Setting it to where the cursor was *before* we started moving it
|
||||
// in this frame is a good approximation, or just using current.
|
||||
mbuf->SetMark(mbuf->Curx(), mbuf->Cury());
|
||||
}
|
||||
}
|
||||
}
|
||||
char tmp[64];
|
||||
std::snprintf(tmp, sizeof(tmp), "%zu:%zu", by, bx);
|
||||
Execute(ed, CommandId::MoveCursorTo, std::string(tmp));
|
||||
|
||||
@@ -226,6 +226,10 @@ KLookupEscCommand(const int ascii_key, CommandId &out) -> bool
|
||||
case 'q':
|
||||
out = CommandId::ReflowParagraph; // Esc q (reflow paragraph)
|
||||
return true;
|
||||
case '\n':
|
||||
case '\r':
|
||||
out = CommandId::SmartNewline; // Shift+Enter (some terminals send this as Alt+Enter sequences)
|
||||
return true;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -67,13 +67,20 @@ map_key_to_command(const int ch,
|
||||
if (pressed) {
|
||||
mouse_selecting = true;
|
||||
Execute(*ed, CommandId::MoveCursorTo, std::string(buf));
|
||||
if (Buffer *b = ed->CurrentBuffer()) {
|
||||
b->SetMark(b->Curx(), b->Cury());
|
||||
}
|
||||
// We don't set the mark on simple click anymore in ncurses either,
|
||||
// to be consistent. ncurses doesn't easily support double-click
|
||||
// or drag-threshold in a platform-independent way here,
|
||||
// but we can at least only set mark on MOVED.
|
||||
out.hasCommand = false;
|
||||
return true;
|
||||
}
|
||||
if (mouse_selecting && moved) {
|
||||
if (Buffer *b = ed->CurrentBuffer()) {
|
||||
if (!b->MarkSet()) {
|
||||
// Set mark at CURRENT cursor position (which is where we were before this move)
|
||||
b->SetMark(b->Curx(), b->Cury());
|
||||
}
|
||||
}
|
||||
Execute(*ed, CommandId::MoveCursorTo, std::string(buf));
|
||||
out.hasCommand = false;
|
||||
return true;
|
||||
|
||||
@@ -9,6 +9,7 @@ enum class UndoType : std::uint8_t {
|
||||
Paste,
|
||||
Newline,
|
||||
DeleteRow,
|
||||
InsertRow,
|
||||
};
|
||||
|
||||
struct UndoNode {
|
||||
|
||||
@@ -36,7 +36,8 @@ UndoSystem::Begin(UndoType type)
|
||||
const int col = static_cast<int>(buf_->Curx());
|
||||
|
||||
// Some operations should always be standalone undo steps.
|
||||
const bool always_standalone = (type == UndoType::Newline || type == UndoType::DeleteRow);
|
||||
const bool always_standalone = (type == UndoType::Newline || type == UndoType::DeleteRow || type ==
|
||||
UndoType::InsertRow);
|
||||
if (always_standalone) {
|
||||
commit();
|
||||
}
|
||||
@@ -75,6 +76,7 @@ UndoSystem::Begin(UndoType type)
|
||||
}
|
||||
case UndoType::Newline:
|
||||
case UndoType::DeleteRow:
|
||||
case UndoType::InsertRow:
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -314,6 +316,15 @@ UndoSystem::apply(const UndoNode *node, int direction)
|
||||
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
|
||||
}
|
||||
break;
|
||||
case UndoType::InsertRow:
|
||||
if (direction > 0) {
|
||||
buf_->insert_row(node->row, node->text);
|
||||
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
|
||||
} else {
|
||||
buf_->delete_row(node->row);
|
||||
buf_->SetCursor(0, static_cast<std::size_t>(node->row));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,6 +422,8 @@ UndoSystem::type_str(UndoType t)
|
||||
return "Newline";
|
||||
case UndoType::DeleteRow:
|
||||
return "DeleteRow";
|
||||
case UndoType::InsertRow:
|
||||
return "InsertRow";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ stdenv.mkDerivation {
|
||||
"-DBUILD_GUI=${if graphical then "ON" else "OFF"}"
|
||||
"-DKTE_USE_QT=${if graphical-qt then "ON" else "OFF"}"
|
||||
"-DCMAKE_BUILD_TYPE=Debug"
|
||||
"-DKTE_STATIC_LINK=OFF"
|
||||
];
|
||||
|
||||
installPhase = ''
|
||||
|
||||
3
main.cc
3
main.cc
@@ -117,6 +117,9 @@ main(int argc, char *argv[])
|
||||
{
|
||||
std::setlocale(LC_ALL, "");
|
||||
|
||||
// Ensure the error handler (and its log file) is initialised early.
|
||||
kte::ErrorHandler::Instance();
|
||||
|
||||
Editor editor;
|
||||
|
||||
// CLI parsing using getopt_long
|
||||
|
||||
69
tests/test_reflow_undo.cc
Normal file
69
tests/test_reflow_undo.cc
Normal file
@@ -0,0 +1,69 @@
|
||||
#include "Test.h"
|
||||
|
||||
#include "Buffer.h"
|
||||
#include "Command.h"
|
||||
#include "Editor.h"
|
||||
#include "UndoSystem.h"
|
||||
|
||||
#include <string>
|
||||
|
||||
|
||||
static std::string
|
||||
to_string_rows(const Buffer &buf)
|
||||
{
|
||||
std::string out;
|
||||
for (const auto &r: buf.Rows()) {
|
||||
out += static_cast<std::string>(r);
|
||||
out.push_back('\n');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
TEST (ReflowUndo)
|
||||
{
|
||||
InstallDefaultCommands();
|
||||
|
||||
Editor ed;
|
||||
ed.SetDimensions(24, 80);
|
||||
|
||||
Buffer b;
|
||||
const std::string initial =
|
||||
"This is a very long line that should be reflowed into multiple lines to see if undo works correctly.\n";
|
||||
b.insert_text(0, 0, initial);
|
||||
b.SetCursor(0, 0);
|
||||
|
||||
// Commit initial insertion so it's its own undo step
|
||||
if (auto *u = b.Undo())
|
||||
u->commit();
|
||||
|
||||
ed.AddBuffer(std::move(b));
|
||||
|
||||
Buffer *buf = ed.CurrentBuffer();
|
||||
ASSERT_TRUE(buf != nullptr);
|
||||
|
||||
const std::string original_dump = to_string_rows(*buf);
|
||||
|
||||
// Reflow with small width
|
||||
const int width = 20;
|
||||
ASSERT_TRUE(Execute(ed, "reflow-paragraph", "", width));
|
||||
|
||||
const std::string reflowed_dump = to_string_rows(*buf);
|
||||
ASSERT_TRUE(reflowed_dump != original_dump);
|
||||
ASSERT_TRUE(buf->Rows().size() > 1);
|
||||
|
||||
// Undo reflow
|
||||
ASSERT_TRUE(Execute(ed, "undo", "", 1));
|
||||
const std::string after_undo_dump = to_string_rows(*buf);
|
||||
|
||||
if (after_undo_dump != original_dump) {
|
||||
fprintf(stderr, "Undo failed.\nExpected:\n%s\nGot:\n%s\n", original_dump.c_str(),
|
||||
after_undo_dump.c_str());
|
||||
}
|
||||
EXPECT_TRUE(after_undo_dump == original_dump);
|
||||
|
||||
// Redo reflow
|
||||
ASSERT_TRUE(Execute(ed, "redo", "", 1));
|
||||
const std::string after_redo_dump = to_string_rows(*buf);
|
||||
EXPECT_TRUE(after_redo_dump == reflowed_dump);
|
||||
}
|
||||
79
tests/test_smart_newline.cc
Normal file
79
tests/test_smart_newline.cc
Normal file
@@ -0,0 +1,79 @@
|
||||
#include "Test.h"
|
||||
#include "Buffer.h"
|
||||
#include "Editor.h"
|
||||
#include "Command.h"
|
||||
#include <string>
|
||||
|
||||
|
||||
TEST (SmartNewline_AutoIndent)
|
||||
{
|
||||
Editor editor;
|
||||
InstallDefaultCommands();
|
||||
Buffer &buf = editor.Buffers().emplace_back();
|
||||
|
||||
// Set up initial state: " line1"
|
||||
buf.insert_text(0, 0, " line1");
|
||||
buf.SetCursor(7, 0); // At end of line
|
||||
|
||||
// Execute SmartNewline
|
||||
bool ok = Execute(editor, CommandId::SmartNewline);
|
||||
ASSERT_TRUE(ok);
|
||||
|
||||
// Should have two lines now
|
||||
ASSERT_EQ(buf.Nrows(), 2);
|
||||
// Line 0 remains " line1"
|
||||
ASSERT_EQ(buf.GetLineString(0), " line1");
|
||||
// Line 1 should have " " (two spaces)
|
||||
ASSERT_EQ(buf.GetLineString(1), " ");
|
||||
// Cursor should be at (2, 1)
|
||||
ASSERT_EQ(buf.Curx(), 2);
|
||||
ASSERT_EQ(buf.Cury(), 1);
|
||||
}
|
||||
|
||||
|
||||
TEST (SmartNewline_TabIndent)
|
||||
{
|
||||
Editor editor;
|
||||
InstallDefaultCommands();
|
||||
Buffer &buf = editor.Buffers().emplace_back();
|
||||
|
||||
// Set up initial state: "\tline1"
|
||||
buf.insert_text(0, 0, "\tline1");
|
||||
buf.SetCursor(6, 0); // At end of line
|
||||
|
||||
// Execute SmartNewline
|
||||
bool ok = Execute(editor, CommandId::SmartNewline);
|
||||
ASSERT_TRUE(ok);
|
||||
|
||||
// Should have two lines now
|
||||
ASSERT_EQ(buf.Nrows(), 2);
|
||||
// Line 1 should have "\t"
|
||||
ASSERT_EQ(buf.GetLineString(1), "\t");
|
||||
// Cursor should be at (1, 1)
|
||||
ASSERT_EQ(buf.Curx(), 1);
|
||||
ASSERT_EQ(buf.Cury(), 1);
|
||||
}
|
||||
|
||||
|
||||
TEST (SmartNewline_NoIndent)
|
||||
{
|
||||
Editor editor;
|
||||
InstallDefaultCommands();
|
||||
Buffer &buf = editor.Buffers().emplace_back();
|
||||
|
||||
// Set up initial state: "line1"
|
||||
buf.insert_text(0, 0, "line1");
|
||||
buf.SetCursor(5, 0); // At end of line
|
||||
|
||||
// Execute SmartNewline
|
||||
bool ok = Execute(editor, CommandId::SmartNewline);
|
||||
ASSERT_TRUE(ok);
|
||||
|
||||
// Should have two lines now
|
||||
ASSERT_EQ(buf.Nrows(), 2);
|
||||
// Line 1 should be empty
|
||||
ASSERT_EQ(buf.GetLineString(1), "");
|
||||
// Cursor should be at (0, 1)
|
||||
ASSERT_EQ(buf.Curx(), 0);
|
||||
ASSERT_EQ(buf.Cury(), 1);
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -57,7 +57,7 @@ validate_undo_tree(const UndoSystem &u)
|
||||
// The undo suite aims to cover invariants with a small, adversarial test matrix.
|
||||
|
||||
|
||||
TEST(Undo_InsertRun_Coalesces_OneStep)
|
||||
TEST (Undo_InsertRun_Coalesces_OneStep)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
@@ -81,7 +81,7 @@ TEST(Undo_InsertRun_Coalesces_OneStep)
|
||||
}
|
||||
|
||||
|
||||
TEST(Undo_InsertRun_BreaksOnNonAdjacentCursor)
|
||||
TEST (Undo_InsertRun_BreaksOnNonAdjacentCursor)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
@@ -109,7 +109,7 @@ TEST(Undo_InsertRun_BreaksOnNonAdjacentCursor)
|
||||
}
|
||||
|
||||
|
||||
TEST(Undo_BackspaceRun_Coalesces_OneStep)
|
||||
TEST (Undo_BackspaceRun_Coalesces_OneStep)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
@@ -143,7 +143,7 @@ TEST(Undo_BackspaceRun_Coalesces_OneStep)
|
||||
}
|
||||
|
||||
|
||||
TEST(Undo_DeleteKeyRun_Coalesces_OneStep)
|
||||
TEST (Undo_DeleteKeyRun_Coalesces_OneStep)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
@@ -176,7 +176,7 @@ TEST(Undo_DeleteKeyRun_Coalesces_OneStep)
|
||||
}
|
||||
|
||||
|
||||
TEST(Undo_Newline_IsStandalone)
|
||||
TEST (Undo_Newline_IsStandalone)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
@@ -211,7 +211,7 @@ TEST(Undo_Newline_IsStandalone)
|
||||
}
|
||||
|
||||
|
||||
TEST(Undo_ExplicitGroup_UndoesAsUnit)
|
||||
TEST (Undo_ExplicitGroup_UndoesAsUnit)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
@@ -239,7 +239,7 @@ TEST(Undo_ExplicitGroup_UndoesAsUnit)
|
||||
}
|
||||
|
||||
|
||||
TEST(Undo_Branching_RedoBranchSelectionDeterministic)
|
||||
TEST (Undo_Branching_RedoBranchSelectionDeterministic)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
@@ -283,7 +283,7 @@ TEST(Undo_Branching_RedoBranchSelectionDeterministic)
|
||||
}
|
||||
|
||||
|
||||
TEST(Undo_DirtyFlag_CrossesMarkSaved)
|
||||
TEST (Undo_DirtyFlag_CrossesMarkSaved)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
@@ -312,7 +312,7 @@ TEST(Undo_DirtyFlag_CrossesMarkSaved)
|
||||
}
|
||||
|
||||
|
||||
TEST(Undo_RoundTrip_Lossless_RandomEdits)
|
||||
TEST (Undo_RoundTrip_Lossless_RandomEdits)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
@@ -368,7 +368,7 @@ TEST(Undo_RoundTrip_Lossless_RandomEdits)
|
||||
|
||||
// Legacy/extended undo tests follow. Keep them available for debugging,
|
||||
// but disable them by default to keep the suite focused (~10 tests).
|
||||
#if 0
|
||||
#if 1
|
||||
|
||||
|
||||
TEST (Undo_Branching_RedoPreservedAfterNewEdit)
|
||||
@@ -713,6 +713,7 @@ TEST (Undo_StructuralInvariants_BranchingAndRoots)
|
||||
validate_undo_tree(*u);
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_BranchSelection_ThreeSiblingsAndHeadPersists)
|
||||
{
|
||||
Buffer b;
|
||||
@@ -796,7 +797,7 @@ TEST (Undo_BranchSelection_ThreeSiblingsAndHeadPersists)
|
||||
|
||||
|
||||
// Additional legacy tests below are useful, but kept disabled by default.
|
||||
#if 0
|
||||
#if 1
|
||||
|
||||
TEST (Undo_Branching_SwitchBetweenTwoRedoBranches_TextAndCursor)
|
||||
{
|
||||
@@ -1196,4 +1197,167 @@ TEST (Undo_Command_RedoCountSelectsBranch)
|
||||
validate_undo_tree(*u);
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_InsertRow_UndoDeletesRow)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Seed two lines so insert_row has proper newline context.
|
||||
b.insert_text(0, 0, std::string_view("first\nlast"));
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
|
||||
|
||||
// Insert a row at position 1 (between first and last), then record it.
|
||||
b.insert_row(1, std::string_view("second"));
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 3);
|
||||
ASSERT_EQ(std::string(b.Rows()[1]), std::string("second"));
|
||||
|
||||
b.SetCursor(0, 1);
|
||||
u->Begin(UndoType::InsertRow);
|
||||
u->Append(std::string_view("second"));
|
||||
u->commit();
|
||||
|
||||
// Undo should remove the inserted row.
|
||||
u->undo();
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("first"));
|
||||
ASSERT_EQ(std::string(b.Rows()[1]), std::string("last"));
|
||||
|
||||
// Redo should re-insert it.
|
||||
u->redo();
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 3);
|
||||
ASSERT_EQ(std::string(b.Rows()[1]), std::string("second"));
|
||||
|
||||
validate_undo_tree(*u);
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_DeleteRow_UndoRestoresRow)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
b.insert_text(0, 0, std::string_view("alpha\nbeta\ngamma"));
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 3);
|
||||
|
||||
// Record a DeleteRow for row 1 ("beta").
|
||||
b.SetCursor(0, 1);
|
||||
u->Begin(UndoType::DeleteRow);
|
||||
u->Append(static_cast<std::string>(b.Rows()[1]));
|
||||
u->commit();
|
||||
b.delete_row(1);
|
||||
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("alpha"));
|
||||
ASSERT_EQ(std::string(b.Rows()[1]), std::string("gamma"));
|
||||
|
||||
// Undo should restore "beta" at row 1.
|
||||
u->undo();
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 3);
|
||||
ASSERT_EQ(std::string(b.Rows()[1]), std::string("beta"));
|
||||
|
||||
// Redo should delete it again.
|
||||
u->redo();
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
|
||||
ASSERT_EQ(std::string(b.Rows()[1]), std::string("gamma"));
|
||||
|
||||
validate_undo_tree(*u);
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_InsertRow_IsStandalone)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Seed with two lines so InsertRow has proper newline context.
|
||||
b.insert_text(0, 0, std::string_view("x\nend"));
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
|
||||
|
||||
// Start a pending insert on row 0.
|
||||
b.SetCursor(1, 0);
|
||||
u->Begin(UndoType::Insert);
|
||||
b.insert_text(0, 1, std::string_view("y"));
|
||||
u->Append('y');
|
||||
b.SetCursor(2, 0);
|
||||
|
||||
// InsertRow should seal the pending "y" and become its own step.
|
||||
b.insert_row(1, std::string_view("row2"));
|
||||
b.SetCursor(0, 1);
|
||||
u->Begin(UndoType::InsertRow);
|
||||
u->Append(std::string_view("row2"));
|
||||
u->commit();
|
||||
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("xy"));
|
||||
ASSERT_EQ(std::string(b.Rows()[1]), std::string("row2"));
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 3);
|
||||
|
||||
// Undo InsertRow only.
|
||||
u->undo();
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 2);
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("xy"));
|
||||
|
||||
// Undo the insert "y".
|
||||
u->undo();
|
||||
ASSERT_EQ(std::string(b.Rows()[0]), std::string("x"));
|
||||
|
||||
validate_undo_tree(*u);
|
||||
}
|
||||
|
||||
|
||||
TEST (Undo_GroupedDeleteAndInsertRows_UndoesAsUnit)
|
||||
{
|
||||
Buffer b;
|
||||
UndoSystem *u = b.Undo();
|
||||
ASSERT_TRUE(u != nullptr);
|
||||
|
||||
// Seed three lines (with trailing newline so delete_row/insert_row work cleanly).
|
||||
b.insert_text(0, 0, std::string_view("aaa\nbbb\nccc\n"));
|
||||
ASSERT_EQ(b.Rows().size(), (std::size_t) 4); // 3 content + 1 empty trailing
|
||||
const std::string original = b.AsString();
|
||||
|
||||
// Group: delete content rows then insert replacements (simulates reflow).
|
||||
(void) u->BeginGroup();
|
||||
|
||||
// Delete rows 2,1,0 in reverse order (like reflow does).
|
||||
for (int i = 2; i >= 0; --i) {
|
||||
b.SetCursor(0, static_cast<std::size_t>(i));
|
||||
u->Begin(UndoType::DeleteRow);
|
||||
u->Append(static_cast<std::string>(b.Rows()[static_cast<std::size_t>(i)]));
|
||||
u->commit();
|
||||
b.delete_row(i);
|
||||
}
|
||||
|
||||
// Insert replacement rows.
|
||||
b.insert_row(0, std::string_view("aaa bbb"));
|
||||
b.SetCursor(0, 0);
|
||||
u->Begin(UndoType::InsertRow);
|
||||
u->Append(std::string_view("aaa bbb"));
|
||||
u->commit();
|
||||
|
||||
b.insert_row(1, std::string_view("ccc"));
|
||||
b.SetCursor(0, 1);
|
||||
u->Begin(UndoType::InsertRow);
|
||||
u->Append(std::string_view("ccc"));
|
||||
u->commit();
|
||||
|
||||
u->EndGroup();
|
||||
|
||||
const std::string reflowed = b.AsString();
|
||||
|
||||
// Single undo should restore original content.
|
||||
u->undo();
|
||||
ASSERT_EQ(b.AsString(), original);
|
||||
|
||||
// Redo should restore the reflowed state.
|
||||
u->redo();
|
||||
ASSERT_EQ(b.AsString(), reflowed);
|
||||
|
||||
validate_undo_tree(*u);
|
||||
}
|
||||
|
||||
|
||||
#endif // legacy tests
|
||||
Reference in New Issue
Block a user