10 Commits

Author SHA1 Message Date
952e1ed3f2 Add 'CenterOnCursor' command and improve cursor scrolling logic
- Introduced `CommandId::CenterOnCursor` to center viewport on the cursor line.
- Improved scrolling behavior in `ImGuiRenderer` to avoid aggressive centering and keep visible lines stable.
- Updated `make-app-release` to rename the output app to `kge-qt.app`.
- Adjusted padding in `ImGuiFrontend` to align with `ImGuiRenderer` settings for consistent scrolling.
- Bumped version to 1.4.1.
2025-12-05 08:15:23 -08:00
7069943df5 bump version 2025-12-04 23:08:11 -08:00
ee2c9939d7 Introduce QtFrontend with renderer, input handler, and theming support.
- Added `QtFrontend`, `QtRenderer`, and `QtInputHandler` for Qt-based UI rendering and input handling.
- Implemented support for theming, font customization, and palette overrides in GUITheme.
- Renamed and refactored ImGui-specific components (e.g., `GUIRenderer` -> `ImGuiRenderer`).
- Added cross-frontend integration for commands and visual font picker.
2025-12-04 21:33:55 -08:00
f5a4625652 Add QtFrontend plans. 2025-12-04 15:51:06 -08:00
37472c71ec Fix UI cursor positioning issues.
Accurately recompute cursor position to prevent drift in terminal and GUI renderers.
2025-12-04 15:18:02 -08:00
5ff4b2ed3e Reflow-paragraph is fixed.
- Forgot to check whether the universal argument value (1 by default), so it was trying to reflow to column 1.
- Minor formatting fixups.
2025-12-04 15:14:30 -08:00
ab2f9918f3 fix build on nixos 2025-12-04 13:11:43 -08:00
d2b53601e2 bump version 2025-12-04 08:49:36 -08:00
78b9345799 Add swap file journaling for crash recovery.
- Introduced `SwapManager` for buffering and writing incremental edits to sidecar `.kte.swp` files.
- Implemented basic operations: insertion, deletion, split, join, and checkpointing.
- Added recovery design doc (`docs/plans/swap-files.md`).
- Updated editor initialization to integrate `SwapManager` instance for crash recovery across buffers.
2025-12-04 08:48:32 -08:00
495183ebd2 Various cleanups.
- Update input handling to retain SDL_TEXTINPUT after Tab insertion for better platform consistency.
- Allow multiple app instances in macOS by modifying `Info.plist`.
- Bump version to 1.3.8-alpha.
2025-12-04 00:05:13 -08:00
40 changed files with 4471 additions and 590 deletions

View File

@@ -16,6 +16,11 @@
#include "syntax/HighlighterEngine.h"
#include "Highlight.h"
// Forward declaration for swap journal integration
namespace kte {
class SwapRecorder;
}
class Buffer {
public:
@@ -423,6 +428,13 @@ public:
}
// Swap journal integration (set by Editor)
void SetSwapRecorder(kte::SwapRecorder *rec)
{
swap_rec_ = rec;
}
// Raw, low-level editing APIs used by UndoSystem apply().
// These must NOT trigger undo recording. They also do not move the cursor.
void insert_text(int row, int col, std::string_view text);
@@ -465,4 +477,6 @@ private:
bool syntax_enabled_ = true;
std::string filetype_;
std::unique_ptr<kte::HighlighterEngine> highlighter_;
// Non-owning pointer to swap recorder managed by Editor/SwapManager
kte::SwapRecorder *swap_rec_ = nullptr;
};

View File

@@ -4,11 +4,12 @@ project(kte)
include(GNUInstallDirs)
set(CMAKE_CXX_STANDARD 20)
set(KTE_VERSION "1.3.7")
set(KTE_VERSION "1.4.1")
# Default to terminal-only build to avoid SDL/OpenGL dependency by default.
# Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available.
set(BUILD_GUI ON CACHE BOOL "Enable building the graphical version.")
set(KTE_USE_QT OFF CACHE BOOL "Build the QT frontend instead of ImGui.")
set(BUILD_TESTS OFF CACHE BOOL "Enable building test programs.")
option(KTE_USE_PIECE_TABLE "Use PieceTable instead of GapBuffer implementation" ON)
set(KTE_FONT_SIZE "18.0" CACHE STRING "Default font size for GUI")
@@ -101,14 +102,30 @@ set(FONT_SOURCES
fonts/FontRegistry.cc
)
set(GUI_SOURCES
${FONT_SOURCES}
GUIConfig.cc
GUIRenderer.cc
GUIInputHandler.cc
GUIFrontend.cc
)
if (BUILD_GUI)
set(GUI_SOURCES
GUIConfig.cc
)
if (KTE_USE_QT)
find_package(Qt6 COMPONENTS Widgets REQUIRED)
set(GUI_SOURCES
${GUI_SOURCES}
QtFrontend.cc
QtInputHandler.cc
QtRenderer.cc
)
# Expose preprocessor switch so sources can exclude ImGui-specific code
add_compile_definitions(KTE_USE_QT)
else ()
set(GUI_SOURCES
${GUI_SOURCES}
${FONT_SOURCES}
ImGuiFrontend.cc
ImGuiInputHandler.cc
ImGuiRenderer.cc
)
endif ()
endif ()
set(COMMON_SOURCES
GapBuffer.cc
@@ -118,6 +135,7 @@ set(COMMON_SOURCES
Command.cc
HelpText.cc
KKeymap.cc
Swap.cc
TerminalInputHandler.cc
TerminalRenderer.cc
TerminalFrontend.cc
@@ -203,6 +221,7 @@ set(COMMON_HEADERS
Command.h
HelpText.h
KKeymap.h
Swap.h
InputHandler.h
TerminalInputHandler.h
Renderer.h
@@ -220,14 +239,29 @@ set(COMMON_HEADERS
${SYNTAX_HEADERS}
)
set(GUI_HEADERS
${THEME_HEADERS}
${FONT_HEADERS}
GUIConfig.h
GUIRenderer.h
GUIInputHandler.h
GUIFrontend.h
)
if (BUILD_GUI)
set(GUI_HEADERS
GUIConfig.h
)
if (KTE_USE_QT)
set(GUI_HEADERS
${GUI_HEADERS}
QtFrontend.h
QtInputHandler.h
QtRenderer.h
)
else ()
set(GUI_HEADERS
${GUI_HEADERS}
${THEME_HEADERS}
${FONT_HEADERS}
ImGuiFrontend.h
ImGuiInputHandler.h
ImGuiRenderer.h
)
endif ()
endif ()
# kte (terminal-first) executable
add_executable(kte
@@ -317,10 +351,17 @@ if (${BUILD_GUI})
)
target_compile_definitions(kge PRIVATE KTE_BUILD_GUI=1 KTE_DEFAULT_GUI=1 KTE_FONT_SIZE=${KTE_FONT_SIZE})
if (KTE_USE_QT)
target_compile_definitions(kge PRIVATE KTE_USE_QT=1)
endif ()
if (KTE_UNDO_DEBUG)
target_compile_definitions(kge PRIVATE KTE_UNDO_DEBUG=1)
endif ()
target_link_libraries(kge ${CURSES_LIBRARIES} imgui)
if (KTE_USE_QT)
target_link_libraries(kge ${CURSES_LIBRARIES} Qt6::Widgets)
else ()
target_link_libraries(kge ${CURSES_LIBRARIES} imgui)
endif ()
# On macOS, build kge as a proper .app bundle
if (APPLE)

View File

@@ -18,11 +18,31 @@
#include "syntax/HighlighterEngine.h"
#include "syntax/CppHighlighter.h"
#ifdef KTE_BUILD_GUI
#include "GUITheme.h"
#include "fonts/FontRegistry.h"
#include "imgui.h"
# include "GUITheme.h"
# if !defined(KTE_USE_QT)
# include "fonts/FontRegistry.h"
# include "imgui.h"
# endif
# if defined(KTE_USE_QT)
# include <QFontDatabase>
# include <QStringList>
# endif
#endif
// Define cross-frontend theme change flags declared in GUITheme.h
namespace kte {
bool gThemeChangePending = false;
std::string gThemeChangeRequest;
// Qt font change globals
bool gFontChangePending = false;
std::string gFontFamilyRequest;
float gFontSizeRequest = 0.0f;
std::string gCurrentFontFamily;
float gCurrentFontSize = 0.0f;
// Request Qt visual font dialog
bool gFontDialogRequested = false;
}
// Keep buffer viewport offsets so that the cursor stays within the visible
// window based on the editor's current dimensions. The bottom row is reserved
@@ -89,6 +109,33 @@ ensure_cursor_visible(const Editor &ed, Buffer &buf)
}
static bool
cmd_center_on_cursor(CommandContext &ctx)
{
Buffer *buf = ctx.editor.CurrentBuffer();
if (!buf)
return false;
const auto &rows = buf->Rows();
std::size_t total = rows.size();
std::size_t content = ctx.editor.ContentRows();
if (content == 0)
content = 1;
std::size_t cy = buf->Cury();
std::size_t half = content / 2;
std::size_t new_rowoffs = (cy > half) ? (cy - half) : 0;
// Clamp to valid range
if (total > content) {
std::size_t max_rowoffs = total - content;
if (new_rowoffs > max_rowoffs)
new_rowoffs = max_rowoffs;
} else {
new_rowoffs = 0;
}
buf->SetOffsets(new_rowoffs, buf->Coloffs());
return true;
}
static void
ensure_at_least_one_line(Buffer &buf)
{
@@ -697,6 +744,10 @@ cmd_refresh(CommandContext &ctx)
ctx.editor.ClearSearchOrigin();
ctx.editor.SetSearchIndex(-1);
}
// Clear any pending close/overwrite state associated with prompts
ctx.editor.SetCloseConfirmPending(false);
ctx.editor.SetCloseAfterSave(false);
ctx.editor.ClearPendingOverwritePath();
ctx.editor.CancelPrompt();
ctx.editor.SetStatus("Canceled");
return true;
@@ -765,6 +816,15 @@ cmd_unknown_kcommand(CommandContext &ctx)
}
static bool
cmd_unknown_esc_command(CommandContext &ctx)
{
(void) ctx;
ctx.editor.SetStatus("invalid escape command");
return true;
}
// --- Syntax highlighting commands ---
static void
apply_filetype(Buffer &buf, const std::string &ft)
@@ -901,8 +961,8 @@ cmd_set_option(CommandContext &ctx)
}
// GUI theme cycling commands (available in GUI build; show message otherwise)
#ifdef KTE_BUILD_GUI
// GUI theme cycling commands (available in GUI build; ImGui-only for now)
#if defined(KTE_BUILD_GUI) && !defined(KTE_USE_QT)
static bool
cmd_theme_next(CommandContext &ctx)
{
@@ -938,7 +998,7 @@ cmd_theme_prev(CommandContext &ctx)
// Theme set by name command
#ifdef KTE_BUILD_GUI
#if defined(KTE_BUILD_GUI) && !defined(KTE_USE_QT)
static bool
cmd_theme_set_by_name(const CommandContext &ctx)
{
@@ -982,15 +1042,41 @@ 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);
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);
return true;
# else
(void) ctx;
// No-op in terminal build
return true;
# endif
}
#endif
// Font set by name (GUI)
#ifdef KTE_BUILD_GUI
#if defined(KTE_BUILD_GUI) && !defined(KTE_USE_QT)
static bool
cmd_font_set_by_name(const CommandContext &ctx)
{
@@ -1043,14 +1129,38 @@ cmd_font_set_by_name(const CommandContext &ctx)
static bool
cmd_font_set_by_name(CommandContext &ctx)
{
(void) ctx;
// Qt build: queue font family change
std::string name = ctx.arg;
// trim
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()) {
// Show current font when no argument provided
std::string cur = kte::gCurrentFontFamily.empty() ? std::string("default") : kte::gCurrentFontFamily;
ctx.editor.SetStatus(std::string("Current font: ") + cur);
return true;
}
kte::gFontFamilyRequest = name;
// Keep size if not specified by user; signal change
kte::gFontChangePending = true;
ctx.editor.SetStatus(std::string("Font requested: ") + name);
return true;
}
#endif
// Font size set (GUI)
#ifdef KTE_BUILD_GUI
// Font size set (GUI, ImGui-only for now)
#if defined(KTE_BUILD_GUI) && !defined(KTE_USE_QT)
static bool
cmd_font_set_size(const CommandContext &ctx)
{
@@ -1109,14 +1219,45 @@ cmd_font_set_size(const CommandContext &ctx)
static bool
cmd_font_set_size(CommandContext &ctx)
{
(void) ctx;
// Qt build: parse size and queue change
std::string a = ctx.arg;
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(a);
rtrim(a);
if (a.empty()) {
float cur = (kte::gCurrentFontSize > 0.0f) ? kte::gCurrentFontSize : 18.0f;
ctx.editor.SetStatus(std::string("Current font size: ") + std::to_string((int) std::round(cur)));
return true;
}
char *endp = nullptr;
float size = strtof(a.c_str(), &endp);
if (endp == a.c_str() || !std::isfinite(size)) {
ctx.editor.SetStatus("font-size: expected number");
return true;
}
if (size < 6.0f)
size = 6.0f;
if (size > 96.0f)
size = 96.0f;
kte::gFontSizeRequest = size;
kte::gFontChangePending = true;
ctx.editor.SetStatus(std::string("Font size requested: ") + std::to_string((int) std::round(size)));
return true;
}
#endif
// Background set command (GUI)
#ifdef KTE_BUILD_GUI
// Background set command (GUI, ImGui-only for now)
#if defined(KTE_BUILD_GUI) && !defined(KTE_USE_QT)
static bool
cmd_background_set(const CommandContext &ctx)
{
@@ -1285,6 +1426,20 @@ cmd_visual_file_picker_toggle(const CommandContext &ctx)
}
// GUI: request visual font picker (Qt frontend will consume flag)
static bool
cmd_visual_font_picker_toggle(const CommandContext &ctx)
{
#ifdef KTE_BUILD_GUI
kte::gFontDialogRequested = true;
ctx.editor.SetStatus("Font chooser");
#else
ctx.editor.SetStatus("Font chooser not available in terminal");
#endif
return true;
}
static bool
cmd_jump_to_line_start(const CommandContext &ctx)
{
@@ -1374,6 +1529,14 @@ cmd_buffer_close(const CommandContext &ctx)
std::size_t idx = ctx.editor.CurrentBufferIndex();
Buffer *b = ctx.editor.CurrentBuffer();
std::string name = b ? buffer_display_name(*b) : std::string("");
// If buffer is dirty, prompt to save first (for both named and unnamed buffers)
if (b && b->Dirty()) {
ctx.editor.StartPrompt(Editor::PromptKind::Confirm, "Save", "");
ctx.editor.SetCloseConfirmPending(true);
ctx.editor.SetStatus(std::string("Save changes to ") + name + "? (y/N)");
return true;
}
// Otherwise close immediately
if (b && b->Undo())
b->Undo()->discard_pending();
ctx.editor.CloseBuffer(idx);
@@ -1578,7 +1741,8 @@ cmd_insert_text(CommandContext &ctx)
std::string argprefix = text.substr(sp + 1);
// Only special-case argument completion for certain commands
if (cmd == "theme") {
#ifdef KTE_BUILD_GUI
#if defined(KTE_BUILD_GUI)
# if !defined(KTE_USE_QT)
std::vector<std::string> cands;
const auto &reg = kte::ThemeRegistry();
for (const auto &t: reg) {
@@ -1586,6 +1750,67 @@ cmd_insert_text(CommandContext &ctx)
if (argprefix.empty() || n.rfind(argprefix, 0) == 0)
cands.push_back(n);
}
# else
// Qt: offer known theme names handled by ApplyQtThemeByName
static const char *qt_themes[] = {
"nord",
"solarized-dark",
"solarized-light",
"gruvbox-dark",
"gruvbox-light",
"eink"
};
std::vector<std::string> cands;
for (const char *t: qt_themes) {
std::string n(t);
if (argprefix.empty() || n.rfind(argprefix, 0) == 0)
cands.push_back(n);
}
# endif
if (cands.empty()) {
// no change
} else if (cands.size() == 1) {
ctx.editor.SetPromptText(cmd + std::string(" ") + cands[0]);
} else {
std::string lcp = cands[0];
for (size_t i = 1; i < cands.size(); ++i) {
const std::string &s = cands[i];
size_t j = 0;
while (j < lcp.size() && j < s.size() && lcp[j] == s[j])
++j;
lcp.resize(j);
if (lcp.empty())
break;
}
if (!lcp.empty() && lcp != argprefix)
ctx.editor.SetPromptText(cmd + std::string(" ") + lcp);
}
ctx.editor.SetStatus(std::string(": ") + ctx.editor.PromptText());
return true;
#else
(void) argprefix; // no completion in non-GUI build
#endif
}
if (cmd == "font") {
#if defined(KTE_BUILD_GUI) && defined(KTE_USE_QT)
// Complete against installed font families (case-insensitive prefix)
std::vector<std::string> cands;
QStringList fams = QFontDatabase::families();
std::string apfx_lower = argprefix;
std::transform(apfx_lower.begin(), apfx_lower.end(), apfx_lower.begin(),
[](unsigned char c) {
return (char) std::tolower(c);
});
for (const auto &fam: fams) {
std::string n = fam.toStdString();
std::string nlower = n;
std::transform(nlower.begin(), nlower.end(), nlower.begin(),
[](unsigned char c) {
return (char) std::tolower(c);
});
if (apfx_lower.empty() || nlower.rfind(apfx_lower, 0) == 0)
cands.push_back(n);
}
if (cands.empty()) {
// no change
} else if (cands.size() == 1) {
@@ -1607,7 +1832,7 @@ cmd_insert_text(CommandContext &ctx)
ctx.editor.SetStatus(std::string(": ") + ctx.editor.PromptText());
return true;
#else
(void) argprefix; // no completion in non-GUI build
(void) argprefix;
#endif
}
// default: no special arg completion
@@ -2191,6 +2416,28 @@ cmd_newline(CommandContext &ctx)
ctx.editor.SetStatus("Saved as " + value);
if (auto *u = buf->Undo())
u->mark_saved();
// If a close-after-save was requested (from closing a dirty, unnamed buffer),
// close the buffer now.
if (ctx.editor.CloseAfterSave()) {
ctx.editor.SetCloseAfterSave(false);
std::size_t idx_close = ctx.editor.CurrentBufferIndex();
std::string name_close = buffer_display_name(*buf);
if (buf->Undo())
buf->Undo()->discard_pending();
ctx.editor.CloseBuffer(idx_close);
if (ctx.editor.BufferCount() == 0) {
Buffer empty;
ctx.editor.AddBuffer(std::move(empty));
ctx.editor.SwitchTo(0);
}
const Buffer *cur = ctx.editor.CurrentBuffer();
ctx.editor.SetStatus(
std::string("Closed: ") + name_close +
std::string(" Now: ")
+ (cur
? buffer_display_name(*cur)
: std::string("")));
}
}
}
}
@@ -2214,11 +2461,86 @@ cmd_newline(CommandContext &ctx)
ctx.editor.SetStatus("Saved as " + target);
if (auto *u = buf->Undo())
u->mark_saved();
// If this overwrite confirm was part of a close-after-save flow, close now.
if (ctx.editor.CloseAfterSave()) {
ctx.editor.SetCloseAfterSave(false);
std::size_t idx_close = ctx.editor.CurrentBufferIndex();
std::string name_close = buffer_display_name(*buf);
if (buf->Undo())
buf->Undo()->discard_pending();
ctx.editor.CloseBuffer(idx_close);
if (ctx.editor.BufferCount() == 0) {
Buffer empty;
ctx.editor.AddBuffer(std::move(empty));
ctx.editor.SwitchTo(0);
}
const Buffer *cur = ctx.editor.CurrentBuffer();
ctx.editor.SetStatus(
std::string("Closed: ") + name_close + std::string(
" Now: ")
+ (cur ? buffer_display_name(*cur) : std::string("")));
}
}
} else {
ctx.editor.SetStatus("Save canceled");
}
ctx.editor.ClearPendingOverwritePath();
// Regardless of answer, end any close-after-save pending state for safety.
ctx.editor.SetCloseAfterSave(false);
} else if (ctx.editor.CloseConfirmPending() && buf) {
bool yes = false;
if (!value.empty()) {
char c = value[0];
yes = (c == 'y' || c == 'Y');
}
// Prepare close details
std::size_t idx_close = ctx.editor.CurrentBufferIndex();
std::string name_close = buffer_display_name(*buf);
bool proceed_to_close = true;
if (yes) {
std::string err;
if (buf->IsFileBacked()) {
if (!buf->Save(err)) {
ctx.editor.SetStatus(err);
proceed_to_close = false;
} else {
buf->SetDirty(false);
if (auto *u = buf->Undo())
u->mark_saved();
}
} else if (!buf->Filename().empty()) {
if (!buf->SaveAs(buf->Filename(), err)) {
ctx.editor.SetStatus(err);
proceed_to_close = false;
} else {
buf->SetDirty(false);
if (auto *u = buf->Undo())
u->mark_saved();
}
} else {
// No filename; fall back to Save As flow and set close-after-save
ctx.editor.StartPrompt(Editor::PromptKind::SaveAs, "Save as", "");
ctx.editor.SetCloseAfterSave(true);
ctx.editor.SetStatus("Save as: ");
ctx.editor.SetCloseConfirmPending(false);
return true;
}
}
if (proceed_to_close) {
if (buf->Undo())
buf->Undo()->discard_pending();
ctx.editor.CloseBuffer(idx_close);
if (ctx.editor.BufferCount() == 0) {
Buffer empty;
ctx.editor.AddBuffer(std::move(empty));
ctx.editor.SwitchTo(0);
}
const Buffer *cur = ctx.editor.CurrentBuffer();
ctx.editor.SetStatus(
std::string("Closed: ") + name_close + std::string(" Now: ")
+ (cur ? buffer_display_name(*cur) : std::string("")));
}
ctx.editor.SetCloseConfirmPending(false);
} else {
ctx.editor.SetStatus("Nothing to confirm");
}
@@ -3652,9 +3974,11 @@ cmd_reflow_paragraph(CommandContext &ctx)
if (!buf)
return false;
ensure_at_least_one_line(*buf);
auto &rows = buf->Rows();
std::size_t y = buf->Cury();
int width = ctx.count > 0 ? ctx.count : 72;
auto &rows = buf->Rows();
std::size_t y = buf->Cury();
// Treat a universal-argument count of 1 as "no width specified".
// Editor::UArgGet() returns 1 when no explicit count was provided.
int width = ctx.count > 1 ? ctx.count : 72;
std::size_t para_start = y;
while (para_start > 0 && !rows[para_start - 1].empty())
--para_start;
@@ -3663,50 +3987,222 @@ cmd_reflow_paragraph(CommandContext &ctx)
++para_end;
if (para_start > para_end)
return false;
std::string text;
for (std::size_t i = para_start; i <= para_end; ++i) {
if (i > para_start && !text.empty() && text.back() != ' ')
text += ' ';
const auto &line = rows[i];
for (std::size_t j = 0; j < line.size(); ++j) {
char c = line[j];
if (c == '\t')
text += ' ';
else
text += c;
auto is_space = [](char c) {
return c == ' ' || c == '\t';
};
auto leading_ws = [&](const std::string &s) {
std::size_t i = 0;
while (i < s.size() && is_space(s[i]))
++i;
return s.substr(0, i);
};
auto starts_with = [](const std::string &s, const std::string &pfx) {
return s.size() >= pfx.size() && std::equal(pfx.begin(), pfx.end(), s.begin());
};
auto is_bullet_line = [&](const std::string &s, std::string &indent_out, char &marker_out,
std::size_t &after_prefix_idx) -> bool {
indent_out = leading_ws(s);
std::size_t i = indent_out.size();
if (i + 1 < s.size()) {
char m = s[i];
if ((m == '-' || m == '+' || m == '*') && s[i + 1] == ' ') {
marker_out = m;
after_prefix_idx = i + 2; // after marker + space
return true;
}
}
}
return false;
};
auto normalize_spaces = [](const std::string &in) {
std::string out;
out.reserve(in.size());
bool in_space = false;
for (char c: in) {
char cc = (c == '\t') ? ' ' : c;
if (cc == ' ') {
if (!in_space) {
out.push_back(' ');
in_space = true;
}
} else {
out.push_back(cc);
in_space = false;
}
}
// trim leading/trailing spaces
// leading
std::size_t start = 0;
while (start < out.size() && out[start] == ' ')
++start;
// trailing
std::size_t end = out.size();
while (end > start && out[end - 1] == ' ')
--end;
return out.substr(start, end - start);
};
auto wrap_with_prefixes = [&](const std::string &content,
const std::string &first_prefix,
const std::string &cont_prefix,
int w,
std::vector<std::string> &dst) {
// Tokenize by spaces
std::vector<std::string> words;
std::size_t pos = 0;
while (pos < content.size()) {
while (pos < content.size() && content[pos] == ' ')
++pos;
if (pos >= content.size())
break;
std::size_t ws = pos;
while (pos < content.size() && content[pos] != ' ')
++pos;
words.emplace_back(content.substr(ws, pos - ws));
}
std::string line = first_prefix;
std::size_t cur_len = line.size();
bool first_word_on_line = true;
auto flush_line = [&]() {
dst.emplace_back(line);
line = cont_prefix;
cur_len = line.size();
first_word_on_line = true;
};
if (words.empty()) {
// Still emit a line with just the prefix (e.g., empty bullet)
dst.emplace_back(line);
return;
}
for (std::size_t i = 0; i < words.size(); ++i) {
const std::string &wrd = words[i];
std::size_t needed = wrd.size() + (first_word_on_line ? 0 : 1);
if (static_cast<int>(cur_len + needed) > w) {
// wrap
flush_line();
}
if (!first_word_on_line) {
line.push_back(' ');
++cur_len;
}
line += wrd;
cur_len += wrd.size();
first_word_on_line = false;
}
if (!line.empty())
dst.emplace_back(line);
};
std::vector<std::string> new_lines;
std::string line;
std::size_t pos = 0;
while (pos < text.size()) {
while (pos < text.size() && text[pos] == ' ')
++pos;
if (pos >= text.size())
// Determine if this region looks like a list: any line starting with bullet
bool region_has_bullet = false;
for (std::size_t i = para_start; i <= para_end; ++i) {
std::string s = static_cast<std::string>(rows[i]);
std::string indent;
char marker;
std::size_t idx;
if (is_bullet_line(s, indent, marker, idx)) {
region_has_bullet = true;
break;
std::size_t word_start = pos;
while (pos < text.size() && text[pos] != ' ')
++pos;
std::string word = text.substr(word_start, pos - word_start);
if (line.empty()) {
line = word;
} else if (static_cast<int>(line.size() + 1 + word.size()) <= width) {
line += ' ';
line += word;
} else {
new_lines.push_back(line);
line = word;
}
}
if (!line.empty())
new_lines.push_back(line);
if (region_has_bullet) {
// Parse as list items; support hanging indent continuations
for (std::size_t i = para_start; i <= para_end; ++i) {
std::string s = static_cast<std::string>(rows[i]);
std::string indent;
char marker = 0;
std::size_t after_idx = 0;
if (is_bullet_line(s, indent, marker, after_idx)) {
std::string first_prefix = indent + std::string(1, marker) + " ";
std::string cont_prefix = indent + " ";
std::string content = s.substr(after_idx);
// consume continuation lines that are part of this bullet item
std::size_t j = i + 1;
while (j <= para_end) {
std::string ns = static_cast<std::string>(rows[j]);
if (starts_with(ns, indent + " ")) {
content += ' ';
content += ns.substr(indent.size() + 2);
++j;
continue;
}
// stop if next bullet at same indentation or different structure
std::string nindent;
char nmarker;
std::size_t nidx;
if (is_bullet_line(ns, nindent, nmarker, nidx)) {
break; // next item
}
// Not a continuation and not a bullet: stop (treat as separate paragraph chunk)
break;
}
content = normalize_spaces(content);
wrap_with_prefixes(content, first_prefix, cont_prefix, width, new_lines);
i = j - 1; // advance
} else {
// A non-bullet line within a list region; treat as its own wrapped paragraph preserving its indent
std::string base_indent = leading_ws(s);
std::string content = s.substr(base_indent.size());
std::size_t j = i + 1;
while (j <= para_end) {
std::string ns = static_cast<std::string>(rows[j]);
std::string nindent = leading_ws(ns);
std::string tmp_indent;
char tmp_marker;
std::size_t tmp_idx;
if (is_bullet_line(ns, tmp_indent, tmp_marker, tmp_idx)) {
break; // next bullet starts
}
if (nindent.size() >= base_indent.size()) {
content += ' ';
content += ns.substr(base_indent.size());
++j;
} else {
break;
}
}
content = normalize_spaces(content);
wrap_with_prefixes(content, base_indent, base_indent, width, new_lines);
i = j - 1;
}
}
} else {
// Normal paragraph: preserve indentation of first line
std::string s0 = static_cast<std::string>(rows[para_start]);
std::string pfx = leading_ws(s0);
std::string content;
for (std::size_t i = para_start; i <= para_end; ++i) {
std::string si = static_cast<std::string>(rows[i]);
// strip the same prefix length if present
if (si.size() >= pfx.size() && starts_with(si, pfx))
si.erase(0, pfx.size());
if (!content.empty())
content.push_back(' ');
content += si;
}
content = normalize_spaces(content);
wrap_with_prefixes(content, pfx, pfx, width, new_lines);
}
if (new_lines.empty())
new_lines.push_back("");
rows.erase(rows.begin() + static_cast<std::ptrdiff_t>(para_start),
rows.begin() + static_cast<std::ptrdiff_t>(para_end + 1));
rows.insert(rows.begin() + static_cast<std::ptrdiff_t>(para_start),
new_lines.begin(), new_lines.end());
buf->SetCursor(0, para_start);
// Place cursor at the end of the paragraph
std::size_t new_last_y = para_start + (new_lines.empty() ? 0 : new_lines.size() - 1);
std::size_t new_last_x = new_lines.empty() ? 0 : new_lines.back().size();
buf->SetCursor(new_last_x, new_last_y);
buf->SetDirty(true);
ensure_cursor_visible(ctx.editor, *buf);
return true;
@@ -3815,35 +4311,45 @@ InstallDefaultCommands()
CommandRegistry::Register({CommandId::SaveAndQuit, "save-quit", "Save and quit (request)", cmd_save_and_quit});
CommandRegistry::Register({CommandId::Refresh, "refresh", "Force redraw", cmd_refresh});
CommandRegistry::Register(
{CommandId::KPrefix, "k-prefix", "Entering k-command prefix (show hint)", cmd_kprefix});
{CommandId::KPrefix, "k-prefix", "Entering k-command prefix (show hint)", cmd_kprefix, false, false});
CommandRegistry::Register({
CommandId::UnknownKCommand, "unknown-k", "Unknown k-command (status)",
cmd_unknown_kcommand
});
CommandRegistry::Register({CommandId::FindStart, "find-start", "Begin incremental search", cmd_find_start});
CommandRegistry::Register({
CommandId::RegexFindStart, "regex-find-start", "Begin regex search", cmd_regex_find_start
cmd_unknown_kcommand, false, false
});
CommandRegistry::Register({
CommandId::RegexpReplace, "regex-replace", "Begin regex search & replace", cmd_regex_replace_start
CommandId::UnknownEscCommand, "unknown-esc", "Unknown ESC command (status)",
cmd_unknown_esc_command, false, false
});
CommandRegistry::Register({
CommandId::SearchReplace, "search-replace", "Begin search & replace", cmd_search_replace_start
CommandId::FindStart, "find-start", "Begin incremental search", cmd_find_start, false, false
});
CommandRegistry::Register({
CommandId::OpenFileStart, "open-file-start", "Begin open-file prompt", cmd_open_file_start
CommandId::RegexFindStart, "regex-find-start", "Begin regex search", cmd_regex_find_start, false, false
});
CommandRegistry::Register({
CommandId::RegexpReplace, "regex-replace", "Begin regex search & replace", cmd_regex_replace_start,
false, false
});
CommandRegistry::Register({
CommandId::SearchReplace, "search-replace", "Begin search & replace", cmd_search_replace_start, false,
false
});
CommandRegistry::Register({
CommandId::OpenFileStart, "open-file-start", "Begin open-file prompt", cmd_open_file_start, false, false
});
// Buffers
CommandRegistry::Register({
CommandId::BufferSwitchStart, "buffer-switch-start", "Begin buffer switch prompt",
cmd_buffer_switch_start
cmd_buffer_switch_start, false, false
});
CommandRegistry::Register({CommandId::BufferNext, "buffer-next", "Switch to next buffer", cmd_buffer_next});
CommandRegistry::Register({CommandId::BufferPrev, "buffer-prev", "Switch to previous buffer", cmd_buffer_prev});
CommandRegistry::Register({CommandId::BufferClose, "buffer-close", "Close current buffer", cmd_buffer_close});
CommandRegistry::Register({
CommandId::BufferClose, "buffer-close", "Close current buffer", cmd_buffer_close, false, false
});
// Editing
CommandRegistry::Register({
CommandId::InsertText, "insert", "Insert text at cursor (no newlines)", cmd_insert_text
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::Backspace, "backspace", "Delete char before cursor", cmd_backspace});
@@ -3885,21 +4391,22 @@ InstallDefaultCommands()
CommandId::DeleteWordNext, "delete-word-next", "Delete next word", cmd_delete_word_next
});
CommandRegistry::Register({
CommandId::MoveCursorTo, "move-cursor-to", "Move cursor to y:x", cmd_move_cursor_to
CommandId::MoveCursorTo, "move-cursor-to", "Move cursor to y:x", cmd_move_cursor_to, false, false
});
// Direct navigation by line number
CommandRegistry::Register({
CommandId::JumpToLine, "goto-line", "Prompt for line and jump", cmd_jump_to_line_start
CommandId::JumpToLine, "goto-line", "Prompt for line and jump", cmd_jump_to_line_start, false, false
});
// Undo/Redo
CommandRegistry::Register({CommandId::Undo, "undo", "Undo last edit", cmd_undo});
CommandRegistry::Register({CommandId::Redo, "redo", "Redo edit", cmd_redo});
CommandRegistry::Register({CommandId::Undo, "undo", "Undo last edit", cmd_undo, false, true});
CommandRegistry::Register({CommandId::Redo, "redo", "Redo edit", cmd_redo, false, true});
// Region formatting
CommandRegistry::Register({CommandId::IndentRegion, "indent-region", "Indent region", cmd_indent_region});
CommandRegistry::Register(
{CommandId::UnindentRegion, "unindent-region", "Unindent region", cmd_unindent_region});
CommandRegistry::Register({
CommandId::ReflowParagraph, "reflow-paragraph", "Reflow paragraph to column width", cmd_reflow_paragraph
CommandId::ReflowParagraph, "reflow-paragraph",
"Reflow paragraph to column width", cmd_reflow_paragraph
});
// Read-only
CommandRegistry::Register({
@@ -3910,32 +4417,32 @@ InstallDefaultCommands()
CommandRegistry::Register({CommandId::ThemePrev, "theme-prev", "Cycle to previous GUI theme", cmd_theme_prev});
// Theme by name (public in command prompt)
CommandRegistry::Register({
CommandId::ThemeSetByName, "theme", "Set GUI theme by name", cmd_theme_set_by_name, true
CommandId::ThemeSetByName, "theme", "Set GUI theme by name", cmd_theme_set_by_name, true, false
});
// Font by name (public)
CommandRegistry::Register({
CommandId::FontSetByName, "font", "Set GUI font by name", cmd_font_set_by_name, true
CommandId::FontSetByName, "font", "Set GUI font by name", cmd_font_set_by_name, true, false
});
// Font size (public)
CommandRegistry::Register({
CommandId::FontSetSize, "font-size", "Set GUI font size (pixels)", cmd_font_set_size, true
CommandId::FontSetSize, "font-size", "Set GUI font size (pixels)", cmd_font_set_size, true, false
});
// Background light/dark (public)
CommandRegistry::Register({
CommandId::BackgroundSet, "background", "Set GUI background light|dark", cmd_background_set, true
CommandId::BackgroundSet, "background", "Set GUI background light|dark", cmd_background_set, true, false
});
// Generic command prompt (C-k ;)
CommandRegistry::Register({
CommandId::CommandPromptStart, "command-prompt-start", "Start generic command prompt",
cmd_command_prompt_start
cmd_command_prompt_start, false, false
});
// Buffer operations
CommandRegistry::Register({
CommandId::ReloadBuffer, "reload-buffer", "Reload buffer from disk", cmd_reload_buffer
CommandId::ReloadBuffer, "reload-buffer", "Reload buffer from disk", cmd_reload_buffer, false, false
});
// Help
CommandRegistry::Register({
CommandId::ShowHelp, "help", "+HELP+ buffer with manual text", cmd_show_help
CommandId::ShowHelp, "help", "+HELP+ buffer with manual text", cmd_show_help, false, false
});
CommandRegistry::Register({
CommandId::MarkAllAndJumpEnd, "mark-all-jump-end", "Set mark at beginning and jump to end",
@@ -3944,23 +4451,32 @@ InstallDefaultCommands()
// GUI
CommandRegistry::Register({
CommandId::VisualFilePickerToggle, "file-picker-toggle", "Toggle visual file picker",
cmd_visual_file_picker_toggle
cmd_visual_file_picker_toggle, false, false
});
CommandRegistry::Register({
CommandId::VisualFontPickerToggle, "font-picker-toggle", "Show visual font picker",
cmd_visual_font_picker_toggle, false, false
});
// Working directory
CommandRegistry::Register({
CommandId::ShowWorkingDirectory, "show-working-directory", "Show current working directory",
cmd_show_working_directory
cmd_show_working_directory, false, false
});
CommandRegistry::Register({
CommandId::ChangeWorkingDirectory, "change-working-directory", "Change current working directory",
cmd_change_working_directory_start
cmd_change_working_directory_start, false, false
});
// UI helpers
CommandRegistry::Register(
{CommandId::UArgStatus, "uarg-status", "Update universal-arg status", cmd_uarg_status});
{CommandId::UArgStatus, "uarg-status", "Update universal-arg status", cmd_uarg_status, false, false});
// Syntax highlighting (public commands)
CommandRegistry::Register({CommandId::Syntax, "syntax", "Syntax: on|off|reload", cmd_syntax, true});
CommandRegistry::Register({CommandId::SetOption, "set", "Set option: key=value", cmd_set_option, true});
// Viewport control
CommandRegistry::Register({
CommandId::CenterOnCursor, "center-on-cursor", "Center viewport on current line", cmd_center_on_cursor,
false, false
});
}
@@ -3989,7 +4505,22 @@ Execute(Editor &ed, CommandId id, const std::string &arg, int count)
}
}
CommandContext ctx{ed, arg, count};
// Source repeat count from editor-level universal argument per new design.
int final_count = 0;
if (cmd->repeatable) {
final_count = ed.UArgGet(); // returns 1 if no active uarg
} else {
// Special-case non-repeatables that should NOT consume/clear uarg:
// - KPrefix: keeps uarg for the following k-suffix command.
// - UnknownKCommand / UnknownEscCommand: user mistyped; keep uarg for next try.
if (id != CommandId::KPrefix && id != CommandId::UnknownKCommand && id !=
CommandId::UnknownEscCommand) {
ed.UArgClear();
}
final_count = 0;
}
CommandContext ctx{ed, arg, final_count};
return cmd->handler ? cmd->handler(ctx) : false;
}

View File

@@ -27,6 +27,8 @@ enum class CommandId {
SearchReplace, // begin search & replace (two-step prompt)
OpenFileStart, // begin open-file prompt
VisualFilePickerToggle,
// GUI-only: toggle/show a visual font selector dialog
VisualFontPickerToggle,
// Buffers
BufferSwitchStart, // begin buffer switch prompt
BufferClose,
@@ -90,6 +92,7 @@ enum class CommandId {
ShowHelp, // open +HELP+ buffer with manual text (C-k h)
// Meta
UnknownKCommand, // arg: single character that was not recognized after C-k
UnknownEscCommand, // invalid ESC (meta) command; show status and exit escape mode
// Generic command prompt
CommandPromptStart, // begin generic command prompt (C-k ;)
// Theme by name
@@ -103,6 +106,8 @@ enum class CommandId {
// Syntax highlighting
Syntax, // ":syntax on|off|reload"
SetOption, // generic ":set key=value" (v1: filetype=<lang>)
// Viewport control
CenterOnCursor, // center the viewport on the current cursor line (C-k k)
};
@@ -128,6 +133,9 @@ struct Command {
CommandHandler handler;
// Public commands are exposed in the ": " prompt (C-k ;)
bool isPublic = false;
// Whether this command should consume and honor a universal argument repeat count.
// Default true per issue request; authors can turn off per-command.
bool repeatable = true;
};

View File

@@ -8,7 +8,10 @@
#include "syntax/NullHighlighter.h"
Editor::Editor() = default;
Editor::Editor()
{
swap_ = std::make_unique<kte::SwapManager>();
}
void
@@ -123,6 +126,11 @@ std::size_t
Editor::AddBuffer(const Buffer &buf)
{
buffers_.push_back(buf);
// Attach swap recorder
if (swap_) {
buffers_.back().SetSwapRecorder(swap_.get());
swap_->Attach(&buffers_.back());
}
if (buffers_.size() == 1) {
curbuf_ = 0;
}
@@ -134,6 +142,10 @@ std::size_t
Editor::AddBuffer(Buffer &&buf)
{
buffers_.push_back(std::move(buf));
if (swap_) {
buffers_.back().SetSwapRecorder(swap_.get());
swap_->Attach(&buffers_.back());
}
if (buffers_.size() == 1) {
curbuf_ = 0;
}
@@ -157,6 +169,12 @@ Editor::OpenFile(const std::string &path, std::string &err)
bool ok = cur.OpenFromFile(path, err);
if (!ok)
return false;
// Ensure swap recorder is attached for this buffer
if (swap_) {
cur.SetSwapRecorder(swap_.get());
swap_->Attach(&cur);
swap_->NotifyFilenameChanged(cur);
}
// Setup highlighting using registry (extension + shebang)
cur.EnsureHighlighter();
std::string first = "";
@@ -187,6 +205,12 @@ Editor::OpenFile(const std::string &path, std::string &err)
if (!b.OpenFromFile(path, err)) {
return false;
}
if (swap_) {
b.SetSwapRecorder(swap_.get());
// path is known, notify
swap_->Attach(&b);
swap_->NotifyFilenameChanged(b);
}
// Initialize syntax highlighting by extension + shebang via registry (v2)
b.EnsureHighlighter();
std::string first = "";
@@ -278,8 +302,67 @@ Editor::Reset()
msgtm_ = 0;
uarg_ = 0;
ucount_ = 0;
repeatable_ = false;
quit_requested_ = false;
quit_confirm_pending_ = false;
// Reset close-confirm/save state
close_confirm_pending_ = false;
close_after_save_ = false;
buffers_.clear();
curbuf_ = 0;
}
// --- Universal argument helpers ---
void
Editor::UArgStart()
{
// If not active, start fresh; else multiply by 4 per ke semantics
if (uarg_ == 0) {
ucount_ = 0;
} else {
if (ucount_ == 0) {
ucount_ = 1;
}
ucount_ *= 4;
}
uarg_ = 1;
char buf[64];
std::snprintf(buf, sizeof(buf), "C-u %d", ucount_);
SetStatus(buf);
}
void
Editor::UArgDigit(int d)
{
if (d < 0)
d = 0;
if (d > 9)
d = 9;
if (uarg_ == 0) {
uarg_ = 1;
ucount_ = 0;
}
ucount_ = ucount_ * 10 + d;
char buf[64];
std::snprintf(buf, sizeof(buf), "C-u %d", ucount_);
SetStatus(buf);
}
void
Editor::UArgClear()
{
uarg_ = 0;
ucount_ = 0;
}
int
Editor::UArgGet()
{
int n = (ucount_ > 0) ? ucount_ : 1;
UArgClear();
return n;
}

View File

@@ -8,6 +8,7 @@
#include <vector>
#include "Buffer.h"
#include "Swap.h"
class Editor {
@@ -156,6 +157,33 @@ public:
}
// --- Universal argument control (C-u) ---
// Begin or extend a universal argument (like ke's uarg_start)
void UArgStart();
// Add a digit 0..9 to the current universal argument (like ke's uarg_digit)
void UArgDigit(int d);
// Clear universal-argument state (like ke's uarg_clear)
void UArgClear();
// Consume the current universal argument, returning count >= 1.
// If no universal argument active, returns 1.
int UArgGet();
// Repeatable command flag: input layer can mark the next command as repeatable
void SetRepeatable(bool on)
{
repeatable_ = on;
}
[[nodiscard]] bool Repeatable() const
{
return repeatable_;
}
// Status message storage. Rendering is renderer-dependent; the editor
// merely stores the current message and its timestamp.
void SetStatus(const std::string &message);
@@ -192,6 +220,31 @@ public:
}
// --- Buffer close/save confirmation state ---
void SetCloseConfirmPending(bool on)
{
close_confirm_pending_ = on;
}
[[nodiscard]] bool CloseConfirmPending() const
{
return close_confirm_pending_;
}
void SetCloseAfterSave(bool on)
{
close_after_save_ = on;
}
[[nodiscard]] bool CloseAfterSave() const
{
return close_after_save_;
}
[[nodiscard]] std::time_t StatusTime() const
{
return msgtm_;
@@ -465,6 +518,13 @@ public:
}
// Swap manager access (for advanced integrations/tests)
[[nodiscard]] kte::SwapManager *Swap()
{
return swap_.get();
}
// --- GUI: Visual File Picker state ---
void SetFilePickerVisible(bool on)
{
@@ -498,17 +558,23 @@ private:
std::string msg_;
std::time_t msgtm_ = 0;
int uarg_ = 0, ucount_ = 0; // C-u support
bool repeatable_ = false; // whether the next command is repeatable
std::vector<Buffer> buffers_;
std::size_t curbuf_ = 0; // index into buffers_
// Swap journaling manager (lifetime = editor)
std::unique_ptr<kte::SwapManager> swap_;
// Kill ring (Emacs-like)
std::vector<std::string> kill_ring_;
std::size_t kill_ring_max_ = 60;
// Quit state
bool quit_requested_ = false;
bool quit_confirm_pending_ = false;
bool quit_requested_ = false;
bool quit_confirm_pending_ = false;
bool close_confirm_pending_ = false; // awaiting y/N to save-before-close
bool close_after_save_ = false; // if true, close buffer after successful Save/SaveAs
// Search state
bool search_active_ = false;

View File

@@ -1,14 +0,0 @@
/*
* GUIRenderer - ImGui-based renderer for GUI mode
*/
#pragma once
#include "Renderer.h"
class GUIRenderer final : public Renderer {
public:
GUIRenderer() = default;
~GUIRenderer() override = default;
void Draw(Editor &ed) override;
};

View File

@@ -1,11 +1,307 @@
// GUITheme.h — ImGui theming helpers and background mode
// GUITheme.h — theming helpers and background mode
#pragma once
#include <cstddef>
#include <string>
#include <algorithm>
#include <cctype>
#include "Highlight.h"
// Cross-frontend theme change request hook: declared here, defined in Command.cc
namespace kte {
extern bool gThemeChangePending;
extern std::string gThemeChangeRequest; // raw user-provided name
// Qt GUI: cross-frontend font change hooks and current font state
extern bool gFontChangePending;
extern std::string gFontFamilyRequest; // requested family (case-insensitive)
extern float gFontSizeRequest; // <= 0 means keep size
extern std::string gCurrentFontFamily; // last applied family (Qt)
extern float gCurrentFontSize; // last applied size (Qt)
// Qt GUI: request to show a visual font dialog (set by command handler)
extern bool gFontDialogRequested;
}
#if defined(KTE_USE_QT)
// Qt build: avoid hard dependency on ImGui headers/types.
// Provide a lightweight color vector matching ImVec4 fields used by renderers.
struct KteColor {
float x{0}, y{0}, z{0}, w{1};
};
static inline KteColor
RGBA(unsigned int rgb, float a = 1.0f)
{
const float r = static_cast<float>((rgb >> 16) & 0xFF) / 255.0f;
const float g = static_cast<float>((rgb >> 8) & 0xFF) / 255.0f;
const float b = static_cast<float>(rgb & 0xFF) / 255.0f;
return {r, g, b, a};
}
namespace kte {
// Background mode selection for light/dark palettes
enum class BackgroundMode { Light, Dark };
// Global background mode; default to Dark to match prior defaults
static inline auto gBackgroundMode = BackgroundMode::Dark;
static inline void
SetBackgroundMode(const BackgroundMode m)
{
gBackgroundMode = m;
}
static inline BackgroundMode
GetBackgroundMode()
{
return gBackgroundMode;
}
// Minimal GUI palette for Qt builds. This mirrors the defaults used in the ImGui
// frontend (Nord-ish) and switches for light/dark background mode.
struct Palette {
KteColor bg; // editor background
KteColor fg; // default foreground text
KteColor sel_bg; // selection background
KteColor cur_bg; // cursor cell background
KteColor status_bg; // status bar background
KteColor status_fg; // status bar foreground
};
// Optional theme override (Qt): when set, GetPalette() will return this instead
// of the generic light/dark defaults. This allows honoring theme names in kge.ini.
static inline bool gPaletteOverride = false;
static inline Palette gOverridePalette{};
static inline std::string gOverrideThemeName = ""; // lowercased name
static inline Palette
GetPalette()
{
const bool dark = (GetBackgroundMode() == BackgroundMode::Dark);
if (gPaletteOverride) {
return gOverridePalette;
}
if (dark) {
return Palette{
/*bg*/ RGBA(0x1C1C1E),
/*fg*/ RGBA(0xDCDCDC),
/*sel_bg*/ RGBA(0xC8C800, 0.35f),
/*cur_bg*/ RGBA(0xC8C8FF, 0.50f),
/*status_bg*/ RGBA(0x28282C),
/*status_fg*/ RGBA(0xB4B48C)
};
} else {
// Light palette tuned for readability
return Palette{
/*bg*/ RGBA(0xFBFBFC),
/*fg*/ RGBA(0x30343A),
/*sel_bg*/ RGBA(0x268BD2, 0.22f),
/*cur_bg*/ RGBA(0x000000, 0.15f),
/*status_bg*/ RGBA(0xE6E8EA),
/*status_fg*/ RGBA(0x50555A)
};
}
}
// A few named palettes to provide visible differences between themes in Qt.
// These are approximate and palette-based (no widget style changes like ImGuiStyle).
static inline Palette
NordDark()
{
return {
/*bg*/RGBA(0x2E3440), /*fg*/RGBA(0xD8DEE9), /*sel_bg*/RGBA(0x88C0D0, 0.25f),
/*cur_bg*/RGBA(0x81A1C1, 0.35f), /*status_bg*/RGBA(0x3B4252), /*status_fg*/RGBA(0xE5E9F0)
};
}
static inline Palette
NordLight()
{
return {
/*bg*/RGBA(0xECEFF4), /*fg*/RGBA(0x2E3440), /*sel_bg*/RGBA(0x5E81AC, 0.22f),
/*cur_bg*/RGBA(0x000000, 0.12f), /*status_bg*/RGBA(0xE5E9F0), /*status_fg*/RGBA(0x4C566A)
};
}
static inline Palette
SolarizedDark()
{
return {
/*bg*/RGBA(0x002b36), /*fg*/RGBA(0x93a1a1), /*sel_bg*/RGBA(0x586e75, 0.40f),
/*cur_bg*/RGBA(0x657b83, 0.35f), /*status_bg*/RGBA(0x073642), /*status_fg*/RGBA(0xeee8d5)
};
}
static inline Palette
SolarizedLight()
{
return {
/*bg*/RGBA(0xfdf6e3), /*fg*/RGBA(0x586e75), /*sel_bg*/RGBA(0x268bd2, 0.25f),
/*cur_bg*/RGBA(0x000000, 0.10f), /*status_bg*/RGBA(0xeee8d5), /*status_fg*/RGBA(0x657b83)
};
}
static inline Palette
GruvboxDark()
{
return {
/*bg*/RGBA(0x282828), /*fg*/RGBA(0xebdbb2), /*sel_bg*/RGBA(0xd79921, 0.35f),
/*cur_bg*/RGBA(0x458588, 0.40f), /*status_bg*/RGBA(0x3c3836), /*status_fg*/RGBA(0xd5c4a1)
};
}
static inline Palette
GruvboxLight()
{
return {
/*bg*/RGBA(0xfbf1c7), /*fg*/RGBA(0x3c3836), /*sel_bg*/RGBA(0x076678, 0.22f),
/*cur_bg*/RGBA(0x000000, 0.10f), /*status_bg*/RGBA(0xebdbb2), /*status_fg*/RGBA(0x504945)
};
}
static inline Palette
EInk()
{
return {
/*bg*/RGBA(0xffffff), /*fg*/RGBA(0x000000), /*sel_bg*/RGBA(0x000000, 0.10f),
/*cur_bg*/RGBA(0x000000, 0.12f), /*status_bg*/RGBA(0x000000), /*status_fg*/RGBA(0xffffff)
};
}
// Apply a Qt theme by name. Returns true on success. Name matching is case-insensitive and
// supports common aliases (e.g., "solarized-light" or "solarized light"). If the name conveys
// a background (light/dark), BackgroundMode is updated to keep SyntaxInk consistent.
static inline bool
ApplyQtThemeByName(std::string name)
{
// normalize
std::transform(name.begin(), name.end(), name.begin(), [](unsigned char c) {
return (char) std::tolower(c);
});
auto has = [&](const std::string &s) {
return name.find(s) != std::string::npos;
};
if (name.empty() || name == "default" || name == "nord") {
// Choose variant by current background mode
if (GetBackgroundMode() == BackgroundMode::Dark) {
gOverridePalette = NordDark();
} else {
gOverridePalette = NordLight();
}
gPaletteOverride = true;
gOverrideThemeName = "nord";
return true;
}
if (has("solarized")) {
if (has("light")) {
SetBackgroundMode(BackgroundMode::Light);
gOverridePalette = SolarizedLight();
} else if (has("dark")) {
SetBackgroundMode(BackgroundMode::Dark);
gOverridePalette = SolarizedDark();
} else {
// pick from current background
gOverridePalette = (GetBackgroundMode() == BackgroundMode::Dark)
? SolarizedDark()
: SolarizedLight();
}
gPaletteOverride = true;
gOverrideThemeName = "solarized";
return true;
}
if (has("gruvbox")) {
if (has("light")) {
SetBackgroundMode(BackgroundMode::Light);
gOverridePalette = GruvboxLight();
} else if (has("dark")) {
SetBackgroundMode(BackgroundMode::Dark);
gOverridePalette = GruvboxDark();
} else {
gOverridePalette = (GetBackgroundMode() == BackgroundMode::Dark)
? GruvboxDark()
: GruvboxLight();
}
gPaletteOverride = true;
gOverrideThemeName = "gruvbox";
return true;
}
if (has("eink") || has("e-ink") || has("paper")) {
SetBackgroundMode(BackgroundMode::Light);
gOverridePalette = EInk();
gPaletteOverride = true;
gOverrideThemeName = "eink";
return true;
}
// Unknown -> clear override so default light/dark applies; return false.
gPaletteOverride = false;
gOverrideThemeName.clear();
return false;
}
// Minimal SyntaxInk mapping for Qt builds, returning KteColor
[[maybe_unused]] static KteColor
SyntaxInk(const TokenKind k)
{
const bool dark = (GetBackgroundMode() == BackgroundMode::Dark);
const KteColor def = dark ? RGBA(0xD8DEE9) : RGBA(0x2E3440);
switch (k) {
case TokenKind::Keyword:
return dark ? RGBA(0x81A1C1) : RGBA(0x5E81AC);
case TokenKind::Type:
return dark ? RGBA(0x8FBCBB) : RGBA(0x4C566A);
case TokenKind::String:
return dark ? RGBA(0xA3BE8C) : RGBA(0x6C8E5E);
case TokenKind::Char:
return dark ? RGBA(0xA3BE8C) : RGBA(0x6C8E5E);
case TokenKind::Comment:
return dark ? RGBA(0x616E88) : RGBA(0x7A869A);
case TokenKind::Number:
return dark ? RGBA(0xEBCB8B) : RGBA(0xB58900);
case TokenKind::Preproc:
return dark ? RGBA(0xD08770) : RGBA(0xAF3A03);
case TokenKind::Constant:
return dark ? RGBA(0xB48EAD) : RGBA(0x7B4B7F);
case TokenKind::Function:
return dark ? RGBA(0x88C0D0) : RGBA(0x3465A4);
case TokenKind::Operator:
return dark ? RGBA(0x2E3440) : RGBA(0x2E3440);
case TokenKind::Punctuation:
return dark ? RGBA(0xECEFF4) : RGBA(0x2E3440);
case TokenKind::Identifier:
return def;
case TokenKind::Whitespace:
return def;
case TokenKind::Error:
return dark ? RGBA(0xBF616A) : RGBA(0xCC0000);
case TokenKind::Default: default:
return def;
}
}
} // namespace kte
#else
#include <imgui.h>
#include <vector>
#include <memory>
#include <string>
#include <cstddef>
#include <algorithm>
#include <cctype>
@@ -644,4 +940,6 @@ SyntaxInk(const TokenKind k)
return def;
}
}
} // namespace kte
} // namespace kte
#endif // KTE_USE_QT

View File

@@ -11,7 +11,7 @@
#include <backends/imgui_impl_opengl3.h>
#include <backends/imgui_impl_sdl2.h>
#include "GUIFrontend.h"
#include "ImGuiFrontend.h"
#include "Command.h"
#include "Editor.h"
#include "GUIConfig.h"
@@ -31,7 +31,9 @@ static auto kGlslVersion = "#version 150"; // GL 3.2 core (macOS compatible)
bool
GUIFrontend::Init(Editor &ed)
{
(void) ed; // editor dimensions will be initialized during the first Step() frame
// 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
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER) != 0) {
return false;
}
@@ -270,10 +272,11 @@ GUIFrontend::Step(Editor &ed, bool &running)
float disp_w = io.DisplaySize.x > 0 ? io.DisplaySize.x : static_cast<float>(width_);
float disp_h = io.DisplaySize.y > 0 ? io.DisplaySize.y : static_cast<float>(height_);
// Account for the GUI window padding and the status bar height used in GUIRenderer
const ImGuiStyle &style = ImGui::GetStyle();
float pad_x = style.WindowPadding.x;
float pad_y = style.WindowPadding.y;
// Account for the GUI window padding and the status bar height used in ImGuiRenderer.
// ImGuiRenderer pushes WindowPadding = (6,6) every frame, so use the same constants here
// to avoid mismatches that would cause premature scrolling.
const float pad_x = 6.0f;
const float pad_y = 6.0f;
// Status bar reserves one frame height (with spacing) inside the window
float status_h = ImGui::GetFrameHeightWithSpacing();

View File

@@ -1,11 +1,11 @@
/*
* GUIFrontend - couples GUIInputHandler + GUIRenderer and owns SDL2/ImGui lifecycle
* GUIFrontend - couples ImGuiInputHandler + GUIRenderer and owns SDL2/ImGui lifecycle
*/
#pragma once
#include "Frontend.h"
#include "GUIConfig.h"
#include "GUIInputHandler.h"
#include "GUIRenderer.h"
#include "ImGuiInputHandler.h"
#include "ImGuiRenderer.h"
struct SDL_Window;
@@ -27,8 +27,8 @@ private:
static bool LoadGuiFont_(const char *path, float size_px);
GUIConfig config_{};
GUIInputHandler input_{};
GUIRenderer renderer_{};
ImGuiInputHandler input_{};
ImGuiRenderer renderer_{};
SDL_Window *window_ = nullptr;
SDL_GLContext gl_ctx_ = nullptr;
int width_ = 1280;

View File

@@ -5,8 +5,9 @@
#include <SDL.h>
#include <imgui.h>
#include "GUIInputHandler.h"
#include "ImGuiInputHandler.h"
#include "KKeymap.h"
#include "Editor.h"
static bool
@@ -14,20 +15,17 @@ map_key(const SDL_Keycode key,
const SDL_Keymod mod,
bool &k_prefix,
bool &esc_meta,
// universal-argument state (by ref)
bool &uarg_active,
bool &uarg_collecting,
bool &uarg_negative,
bool &uarg_had_digits,
int &uarg_value,
std::string &uarg_text,
MappedInput &out)
bool &k_ctrl_pending,
Editor *ed,
MappedInput &out,
bool &suppress_textinput_once)
{
// Ctrl handling
const bool is_ctrl = (mod & KMOD_CTRL) != 0;
const bool is_alt = (mod & (KMOD_ALT | KMOD_LALT | KMOD_RALT)) != 0;
// If previous key was ESC, interpret this as Meta via ESC keymap
// If previous key was ESC, interpret this as Meta via ESC keymap.
// Prefer KEYDOWN when we can derive a printable ASCII; otherwise defer to TEXTINPUT.
if (esc_meta) {
int ascii_key = 0;
if (key == SDLK_BACKSPACE) {
@@ -45,17 +43,18 @@ map_key(const SDL_Keycode key,
ascii_key = '>';
}
if (ascii_key != 0) {
esc_meta = false; // consume if we can decide on KEYDOWN
ascii_key = KLowerAscii(ascii_key);
CommandId id;
if (KLookupEscCommand(ascii_key, id)) {
// Only consume the ESC-meta prefix if we actually mapped a command
esc_meta = false;
out = {true, id, "", 0};
out = {true, id, "", 0};
return true;
}
// Known printable but unmapped ESC sequence: report invalid
out = {true, CommandId::UnknownEscCommand, "", 0};
return true;
}
// Unhandled meta chord at KEYDOWN: do not clear esc_meta here.
// Leave it set so SDL_TEXTINPUT fallback can translate and suppress insertion.
// No usable ASCII from KEYDOWN → keep esc_meta set and let TEXTINPUT handle it
out.hasCommand = false;
return true;
}
@@ -65,43 +64,53 @@ map_key(const SDL_Keycode key,
switch (key) {
case SDLK_LEFT:
k_prefix = false;
out = {true, CommandId::MoveLeft, "", 0};
k_ctrl_pending = false;
out = {true, CommandId::MoveLeft, "", 0};
return true;
case SDLK_RIGHT:
k_prefix = false;
out = {true, CommandId::MoveRight, "", 0};
k_ctrl_pending = false;
out = {true, CommandId::MoveRight, "", 0};
return true;
case SDLK_UP:
k_prefix = false;
out = {true, CommandId::MoveUp, "", 0};
k_ctrl_pending = false;
out = {true, CommandId::MoveUp, "", 0};
return true;
case SDLK_DOWN:
k_prefix = false;
out = {true, CommandId::MoveDown, "", 0};
k_ctrl_pending = false;
out = {true, CommandId::MoveDown, "", 0};
return true;
case SDLK_HOME:
k_prefix = false;
out = {true, CommandId::MoveHome, "", 0};
k_ctrl_pending = false;
out = {true, CommandId::MoveHome, "", 0};
return true;
case SDLK_END:
k_prefix = false;
out = {true, CommandId::MoveEnd, "", 0};
k_ctrl_pending = false;
out = {true, CommandId::MoveEnd, "", 0};
return true;
case SDLK_PAGEUP:
k_prefix = false;
out = {true, CommandId::PageUp, "", 0};
k_ctrl_pending = false;
out = {true, CommandId::PageUp, "", 0};
return true;
case SDLK_PAGEDOWN:
k_prefix = false;
out = {true, CommandId::PageDown, "", 0};
k_ctrl_pending = false;
out = {true, CommandId::PageDown, "", 0};
return true;
case SDLK_DELETE:
k_prefix = false;
out = {true, CommandId::DeleteChar, "", 0};
k_ctrl_pending = false;
out = {true, CommandId::DeleteChar, "", 0};
return true;
case SDLK_BACKSPACE:
k_prefix = false;
out = {true, CommandId::Backspace, "", 0};
k_ctrl_pending = false;
out = {true, CommandId::Backspace, "", 0};
return true;
case SDLK_TAB:
// Insert a literal tab character when not interpreting a k-prefix suffix.
@@ -114,10 +123,13 @@ map_key(const SDL_Keycode key,
break; // fall through so k-prefix handler can process
case SDLK_RETURN:
case SDLK_KP_ENTER:
out = {true, CommandId::Newline, "", 0};
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::Newline, "", 0};
return true;
case SDLK_ESCAPE:
k_prefix = false;
k_ctrl_pending = false;
esc_meta = true; // next key will be treated as Meta
out.hasCommand = false; // no immediate command for bare ESC in GUI
return true;
@@ -127,7 +139,6 @@ map_key(const SDL_Keycode key,
// If we are in k-prefix, interpret the very next key via the C-k keymap immediately.
if (k_prefix) {
k_prefix = false;
esc_meta = false;
// Normalize to ASCII; preserve case for letters using Shift
int ascii_key = 0;
@@ -147,10 +158,23 @@ map_key(const SDL_Keycode key,
ascii_key = static_cast<int>(key);
}
bool ctrl2 = (mod & KMOD_CTRL) != 0;
// If user typed a literal 'C' (or '^') as a control qualifier, keep k-prefix active
if (ascii_key == 'C' || ascii_key == 'c' || ascii_key == '^') {
k_ctrl_pending = true;
// Keep waiting for the next suffix; show status and suppress ensuing TEXTINPUT
if (ed)
ed->SetStatus("C-k C _");
suppress_textinput_once = true;
out.hasCommand = false;
return true;
}
// Otherwise, consume the k-prefix now for the actual suffix
k_prefix = false;
if (ascii_key != 0) {
int lower = KLowerAscii(ascii_key);
bool ctrl_suffix_supported = (lower == 'd' || lower == 'x' || lower == 'q');
bool pass_ctrl = ctrl2 && ctrl_suffix_supported;
bool pass_ctrl = (ctrl2 || k_ctrl_pending) && ctrl_suffix_supported;
k_ctrl_pending = false;
CommandId id;
bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id);
// Diagnostics for u/U
@@ -167,54 +191,40 @@ map_key(const SDL_Keycode key,
}
if (mapped) {
out = {true, id, "", 0};
if (ed)
ed->SetStatus(""); // clear "C-k _" hint after suffix
return true;
}
int shown = KLowerAscii(ascii_key);
char c = (shown >= 0x20 && shown <= 0x7e) ? static_cast<char>(shown) : '?';
std::string arg(1, c);
out = {true, CommandId::UnknownKCommand, arg, 0};
if (ed)
ed->SetStatus(""); // clear hint; handler will set unknown status
return true;
}
out.hasCommand = false;
// Non-printable/unmappable key as k-suffix (e.g., F-keys): report unknown and exit k-mode
out = {true, CommandId::UnknownKCommand, std::string("?"), 0};
if (ed)
ed->SetStatus("");
return true;
}
if (is_ctrl) {
// Universal argument: C-u
if (key == SDLK_u) {
if (!uarg_active) {
uarg_active = true;
uarg_collecting = true;
uarg_negative = false;
uarg_had_digits = false;
uarg_value = 4; // default
uarg_text.clear();
out = {true, CommandId::UArgStatus, uarg_text, 0};
return true;
} else if (uarg_collecting && !uarg_had_digits && !uarg_negative) {
if (uarg_value <= 0)
uarg_value = 4;
else
uarg_value *= 4; // repeated C-u multiplies by 4
out = {true, CommandId::UArgStatus, uarg_text, 0};
return true;
} else {
// End collection if already started with digits or '-'
uarg_collecting = false;
if (!uarg_had_digits && !uarg_negative && uarg_value <= 0)
uarg_value = 4;
}
if (ed)
ed->UArgStart();
out.hasCommand = false;
return true;
}
// Cancel universal arg on C-g as well (it maps to Refresh via ctrl map)
if (key == SDLK_g) {
uarg_active = false;
uarg_collecting = false;
uarg_negative = false;
uarg_had_digits = false;
uarg_value = 0;
uarg_text.clear();
if (ed)
ed->UArgClear();
// Also cancel any pending k-prefix qualifier
k_ctrl_pending = false;
k_prefix = false; // treat as cancel of prefix
}
if (key == SDLK_k || key == SDLK_KP_EQUALS) {
k_prefix = true;
@@ -258,29 +268,17 @@ map_key(const SDL_Keycode key,
}
}
// If collecting universal argument, allow digits/minus on KEYDOWN path too
if (uarg_active && uarg_collecting) {
// If collecting universal argument, allow digits on KEYDOWN path too
if (ed && ed->UArg() != 0) {
if ((key >= SDLK_0 && key <= SDLK_9) && !(mod & KMOD_SHIFT)) {
int d = static_cast<int>(key - SDLK_0);
if (!uarg_had_digits) {
uarg_value = 0;
uarg_had_digits = true;
}
if (uarg_value < 100000000) {
uarg_value = uarg_value * 10 + d;
}
uarg_text.push_back(static_cast<char>('0' + d));
out = {true, CommandId::UArgStatus, uarg_text, 0};
ed->UArgDigit(d);
out.hasCommand = false;
// We consumed a digit on KEYDOWN; SDL will often also emit TEXTINPUT for it.
// Request suppression of the very next TEXTINPUT to avoid double-counting.
suppress_textinput_once = true;
return true;
}
if (key == SDLK_MINUS && !uarg_had_digits && !uarg_negative) {
uarg_negative = true;
uarg_text = "-";
out = {true, CommandId::UArgStatus, uarg_text, 0};
return true;
}
// Any other key will end collection; process it normally
uarg_collecting = false;
}
// k_prefix handled earlier
@@ -290,7 +288,7 @@ map_key(const SDL_Keycode key,
bool
GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e)
{
MappedInput mi;
bool produced = false;
@@ -345,7 +343,9 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
segment = std::string_view(text).substr(start);
}
if (!segment.empty()) {
MappedInput ins{true, CommandId::InsertText, std::string(segment), 0};
MappedInput ins{
true, CommandId::InsertText, std::string(segment), 0
};
q_.push(ins);
}
if (has_nl) {
@@ -362,29 +362,28 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
}
}
produced = map_key(key, mods,
k_prefix_, esc_meta_,
uarg_active_, uarg_collecting_, uarg_negative_, uarg_had_digits_, uarg_value_,
uarg_text_,
mi);
// If we inserted a TAB on KEYDOWN, suppress any subsequent SDL_TEXTINPUT
// for this keystroke to avoid double insertion on platforms that emit it.
if (produced && mi.hasCommand && mi.id == CommandId::InsertText && mi.arg == "\t") {
suppress_text_input_once_ = true;
}
// If we just consumed a universal-argument digit or '-' on KEYDOWN and emitted UArgStatus,
// suppress the subsequent SDL_TEXTINPUT for this keystroke to avoid duplicating the digit in status.
if (produced && mi.hasCommand && mi.id == CommandId::UArgStatus) {
// Digits without shift, or a plain '-'
const bool is_digit_key = (key >= SDLK_0 && key <= SDLK_9) && !(mods & KMOD_SHIFT);
const bool is_minus_key = (key == SDLK_MINUS);
if (uarg_active_ && uarg_collecting_ &&(is_digit_key || is_minus_key)) {
{
bool suppress_req = false;
produced = map_key(key, mods,
k_prefix_, esc_meta_,
k_ctrl_pending_,
ed_,
mi,
suppress_req);
if (suppress_req) {
// Prevent the corresponding TEXTINPUT from delivering the same digit again
suppress_text_input_once_ = true;
}
}
// Note: Do NOT suppress SDL_TEXTINPUT after inserting a TAB. Most platforms
// do not emit TEXTINPUT for Tab, and suppressing here would incorrectly
// eat the next character typed if no TEXTINPUT follows the Tab press.
// If we just consumed a universal-argument digit or '-' on KEYDOWN and emitted UArgStatus,
// suppress the subsequent SDL_TEXTINPUT for this keystroke to avoid duplicating the digit in status.
// Additional suppression handled above when KEYDOWN consumed a uarg digit
// Suppress the immediate following SDL_TEXTINPUT when a printable KEYDOWN was used as a
// k-prefix suffix or Meta (Alt/ESC) chord, regardless of whether a command was produced.
const bool is_alt = (mods & (KMOD_ALT | KMOD_LALT | KMOD_RALT)) != 0;
@@ -404,7 +403,8 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
}
// Alt/Meta + letter can also generate TEXTINPUT on some platforms
const bool is_meta_symbol = (
key == SDLK_COMMA || key == SDLK_PERIOD || key == SDLK_LESS || key == SDLK_GREATER);
key == SDLK_COMMA || key == SDLK_PERIOD || key == SDLK_LESS || key ==
SDLK_GREATER);
if (is_alt && ((key >= SDLK_a && key <= SDLK_z) || is_meta_symbol)) {
should_suppress = true;
}
@@ -428,35 +428,26 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
break;
}
// If universal argument collection is active, consume digit/minus TEXTINPUT
if (uarg_active_ && uarg_collecting_) {
// If editor universal argument is active, consume digit TEXTINPUT
if (ed_ &&ed_
->
UArg() != 0
)
{
const char *txt = e.text.text;
if (txt && *txt) {
unsigned char c0 = static_cast<unsigned char>(txt[0]);
if (c0 >= '0' && c0 <= '9') {
int d = c0 - '0';
if (!uarg_had_digits_) {
uarg_value_ = 0;
uarg_had_digits_ = true;
}
if (uarg_value_ < 100000000) {
uarg_value_ = uarg_value_ * 10 + d;
}
uarg_text_.push_back(static_cast<char>(c0));
mi = {true, CommandId::UArgStatus, uarg_text_, 0};
produced = true; // consumed and enqueued status update
break;
}
if (c0 == '-' && !uarg_had_digits_ && !uarg_negative_) {
uarg_negative_ = true;
uarg_text_ = "-";
mi = {true, CommandId::UArgStatus, uarg_text_, 0};
produced = true;
ed_->UArgDigit(d);
produced = true; // consumed to update status
break;
}
}
// End collection and allow this TEXTINPUT to be processed normally below
uarg_collecting_ = false;
// Non-digit ends collection; allow processing normally below
}
// If we are still in k-prefix and KEYDOWN path didn't handle the suffix,
@@ -472,9 +463,21 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
ascii_key = static_cast<int>(c0);
}
if (ascii_key != 0) {
// Qualifier via TEXTINPUT: 'C' or '^'
if (ascii_key == 'C' || ascii_key == 'c' || ascii_key == '^') {
k_ctrl_pending_ = true;
if (ed_)
ed_->SetStatus("C-k C _");
// Keep k-prefix active; do not emit a command
k_prefix_ = true;
produced = true;
break;
}
// Map via k-prefix table; do not pass Ctrl for TEXTINPUT case
CommandId id;
bool mapped = KLookupKCommand(ascii_key, false, id);
bool pass_ctrl = k_ctrl_pending_;
k_ctrl_pending_ = false;
bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id);
// Diagnostics: log any k-prefix TEXTINPUT suffix mapping
char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e)
? static_cast<char>(ascii_key)
@@ -485,7 +488,9 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
mapped ? static_cast<int>(id) : -1);
std::fflush(stderr);
if (mapped) {
mi = {true, id, "", 0};
mi = {true, id, "", 0};
if (ed_)
ed_->SetStatus(""); // clear "C-k _" hint after suffix
produced = true;
break; // handled; do not insert text
} else {
@@ -495,13 +500,18 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
? static_cast<char>(shown)
: '?';
std::string arg(1, c);
mi = {true, CommandId::UnknownKCommand, arg, 0};
mi = {true, CommandId::UnknownKCommand, arg, 0};
if (ed_)
ed_->SetStatus("");
produced = true;
break;
}
}
}
// Consume even if no usable ascii was found
// If no usable ASCII was found, still report an unknown k-command and exit k-mode
mi = {true, CommandId::UnknownKCommand, std::string("?"), 0};
if (ed_)
ed_->SetStatus("");
produced = true;
break;
}
@@ -541,7 +551,8 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
}
}
}
// If we get here, swallow the TEXTINPUT (do not insert stray char)
// If we get here, unmapped ESC sequence via TEXTINPUT: report invalid
mi = {true, CommandId::UnknownEscCommand, "", 0};
produced = true;
break;
}
@@ -571,31 +582,6 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
}
if (produced && mi.hasCommand) {
// Attach universal-argument count if present, then clear the state
if (uarg_active_ &&mi
.
id != CommandId::UArgStatus
)
{
int count = 0;
if (!uarg_had_digits_ && !uarg_negative_) {
// No explicit digits: use current value (default 4 or 4^n)
count = (uarg_value_ > 0) ? uarg_value_ : 4;
} else {
count = uarg_value_;
if (uarg_negative_)
count = -count;
}
mi.count = count;
// Clear universal-argument state after applying it
uarg_active_ = false;
uarg_collecting_ = false;
uarg_negative_ = false;
uarg_had_digits_ = false;
uarg_value_ = 0;
uarg_text_.clear();
}
std::lock_guard<std::mutex> lk(mu_);
q_.push(mi);
}
@@ -604,7 +590,7 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e)
bool
GUIInputHandler::Poll(MappedInput &out)
ImGuiInputHandler::Poll(MappedInput &out)
{
std::lock_guard<std::mutex> lk(mu_);
if (q_.empty())

View File

@@ -1,5 +1,5 @@
/*
* GUIInputHandler - ImGui/SDL2-based input mapping for GUI mode
* ImGuiInputHandler - ImGui/SDL2-based input mapping for GUI mode
*/
#pragma once
#include <mutex>
@@ -10,11 +10,18 @@
union SDL_Event; // fwd decl to avoid including SDL here (SDL defines SDL_Event as a union)
class GUIInputHandler final : public InputHandler {
class ImGuiInputHandler final : public InputHandler {
public:
GUIInputHandler() = default;
ImGuiInputHandler() = default;
~ImGuiInputHandler() override = default;
void Attach(Editor *ed) override
{
ed_ = ed;
}
~GUIInputHandler() override = default;
// Translate an SDL event to editor command and enqueue if applicable.
// Returns true if it produced a mapped command or consumed input.
@@ -25,18 +32,13 @@ public:
private:
std::mutex mu_;
std::queue<MappedInput> q_;
bool k_prefix_ = false;
bool k_prefix_ = false;
bool k_ctrl_pending_ = false; // if true, next k-suffix is treated as Ctrl- (qualifier via literal 'C' or '^')
// Treat ESC as a Meta prefix: next key is looked up via ESC keymap
bool esc_meta_ = false;
// When a printable keydown generated a non-text command, suppress the very next SDL_TEXTINPUT
// event produced by SDL for the same keystroke to avoid inserting stray characters.
bool suppress_text_input_once_ = false;
// Universal argument (C-u) state for GUI
bool uarg_active_ = false; // an argument is pending for the next command
bool uarg_collecting_ = false; // collecting digits / '-' right now
bool uarg_negative_ = false; // whether a leading '-' was supplied
bool uarg_had_digits_ = false; // whether any digits were supplied
int uarg_value_ = 0; // current absolute value (>=0)
std::string uarg_text_; // raw digits/minus typed for status display
Editor *ed_ = nullptr; // attached editor for editor-owned uarg handling
};

View File

@@ -9,7 +9,7 @@
#include <imgui.h>
#include <regex>
#include "GUIRenderer.h"
#include "ImGuiRenderer.h"
#include "Highlight.h"
#include "GUITheme.h"
#include "Buffer.h"
@@ -30,7 +30,7 @@
void
GUIRenderer::Draw(Editor &ed)
ImGuiRenderer::Draw(Editor &ed)
{
// Make the editor window occupy the entire GUI container/viewport
ImGuiViewport *vp = ImGui::GetMainViewport();
@@ -140,7 +140,8 @@ GUIRenderer::Draw(Editor &ed)
prev_buf_coloffs = buf_coloffs;
// Synchronize cursor and scrolling.
// Ensure the cursor is visible even on the first frame or when it didn't move.
// Ensure the cursor is visible, but avoid aggressive centering so that
// the same lines remain visible until the cursor actually goes off-screen.
{
// Compute visible row range using the child window height
float child_h = ImGui::GetWindowHeight();
@@ -151,15 +152,30 @@ GUIRenderer::Draw(Editor &ed)
long last_row = first_row + vis_rows - 1;
long cyr = static_cast<long>(cy);
if (cyr < first_row || cyr > last_row) {
float target = (static_cast<float>(cyr) - std::max(0L, vis_rows / 2)) * row_h;
if (cyr < first_row) {
// Scroll just enough to bring the cursor line to the top
float target = static_cast<float>(cyr) * row_h;
if (target < 0.f)
target = 0.f;
float max_y = ImGui::GetScrollMaxY();
if (max_y >= 0.f && target > max_y)
target = max_y;
ImGui::SetScrollY(target);
scroll_y = ImGui::GetScrollY();
first_row = static_cast<long>(scroll_y / row_h);
last_row = first_row + vis_rows - 1;
} else if (cyr > last_row) {
// Scroll just enough to bring the cursor line to the bottom
long new_first = cyr - vis_rows + 1;
if (new_first < 0)
new_first = 0;
float target = static_cast<float>(new_first) * row_h;
float max_y = ImGui::GetScrollMaxY();
if (target < 0.f)
target = 0.f;
if (max_y >= 0.f && target > max_y)
target = max_y;
ImGui::SetScrollY(target);
// refresh local variables
scroll_y = ImGui::GetScrollY();
first_row = static_cast<long>(scroll_y / row_h);
last_row = first_row + vis_rows - 1;
@@ -369,8 +385,34 @@ GUIRenderer::Draw(Editor &ed)
// Draw syntax-colored runs (text above background highlights)
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->HasHighlighter()) {
const kte::LineHighlight &lh = buf->Highlighter()->GetLine(
kte::LineHighlight lh = buf->Highlighter()->GetLine(
*buf, static_cast<int>(i), buf->Version());
// Sanitize spans defensively: clamp to [0, line.size()], ensure end>=start, drop empties
struct SSpan {
std::size_t s;
std::size_t e;
kte::TokenKind k;
};
std::vector<SSpan> spans;
spans.reserve(lh.spans.size());
const std::size_t line_len = line.size();
for (const auto &sp: lh.spans) {
int s_raw = sp.col_start;
int e_raw = sp.col_end;
if (e_raw < s_raw)
std::swap(e_raw, s_raw);
std::size_t s = static_cast<std::size_t>(std::max(
0, std::min(s_raw, static_cast<int>(line_len))));
std::size_t e = static_cast<std::size_t>(std::max(
static_cast<int>(s), std::min(e_raw, static_cast<int>(line_len))));
if (e <= s)
continue;
spans.push_back(SSpan{s, e, sp.kind});
}
std::sort(spans.begin(), spans.end(), [](const SSpan &a, const SSpan &b) {
return a.s < b.s;
});
// Helper to convert a src column to expanded rx position
auto src_to_rx_full = [&](std::size_t sidx) -> std::size_t {
std::size_t rx = 0;
@@ -379,24 +421,22 @@ GUIRenderer::Draw(Editor &ed)
}
return rx;
};
for (const auto &sp: lh.spans) {
std::size_t rx_s = src_to_rx_full(
static_cast<std::size_t>(std::max(0, sp.col_start)));
std::size_t rx_e = src_to_rx_full(
static_cast<std::size_t>(std::max(sp.col_start, sp.col_end)));
for (const auto &sp: spans) {
std::size_t rx_s = src_to_rx_full(sp.s);
std::size_t rx_e = src_to_rx_full(sp.e);
if (rx_e <= coloffs_now)
continue;
// Clamp rx_s/rx_e to the visible portion
continue; // fully left of viewport
// Clamp to visible portion and expanded length
std::size_t draw_start = (rx_s > coloffs_now) ? rx_s : coloffs_now;
std::size_t draw_end = rx_e;
if (draw_start >= expanded.size())
continue;
draw_end = std::min<std::size_t>(draw_end, expanded.size());
continue; // fully right of expanded text
std::size_t draw_end = std::min<std::size_t>(rx_e, expanded.size());
if (draw_end <= draw_start)
continue;
// Screen position is relative to coloffs_now
std::size_t screen_x = draw_start - coloffs_now;
ImU32 col = ImGui::GetColorU32(kte::SyntaxInk(sp.kind));
ImU32 col = ImGui::GetColorU32(kte::SyntaxInk(sp.k));
ImVec2 p = ImVec2(line_pos.x + static_cast<float>(screen_x) * space_w,
line_pos.y);
ImGui::GetWindowDrawList()->AddText(
@@ -431,7 +471,19 @@ GUIRenderer::Draw(Editor &ed)
}
// Convert to viewport x by subtracting horizontal col offset
std::size_t rx_viewport = (rx_abs > coloffs_now) ? (rx_abs - coloffs_now) : 0;
ImVec2 p0 = ImVec2(line_pos.x + static_cast<float>(rx_viewport) * space_w, line_pos.y);
// For proportional fonts (Linux GUI), avoid accumulating drift by computing
// the exact pixel width of the expanded substring up to the cursor.
// expanded contains the line with tabs expanded to spaces and is what we draw.
float cursor_px = 0.0f;
if (rx_viewport > 0 && coloffs_now < expanded.size()) {
std::size_t start = coloffs_now;
std::size_t end = std::min(expanded.size(), start + rx_viewport);
// Measure substring width in pixels
ImVec2 sz = ImGui::CalcTextSize(expanded.c_str() + start,
expanded.c_str() + end);
cursor_px = sz.x;
}
ImVec2 p0 = ImVec2(line_pos.x + cursor_px, line_pos.y);
ImVec2 p1 = ImVec2(p0.x + space_w, p0.y + line_h);
ImU32 col = IM_COL32(200, 200, 255, 128); // soft highlight
ImGui::GetWindowDrawList()->AddRectFilled(p0, p1, col);

14
ImGuiRenderer.h Normal file
View File

@@ -0,0 +1,14 @@
/*
* ImGuiRenderer - ImGui-based renderer for GUI mode
*/
#pragma once
#include "Renderer.h"
class ImGuiRenderer final : public Renderer {
public:
ImGuiRenderer() = default;
~ImGuiRenderer() override = default;
void Draw(Editor &ed) override;
};

View File

@@ -6,6 +6,8 @@
#include "Command.h"
class Editor; // fwd decl
// Result of translating raw input into an editor command.
struct MappedInput {
@@ -19,6 +21,10 @@ class InputHandler {
public:
virtual ~InputHandler() = default;
// Optional: attach current Editor so handlers can consult editor state (e.g., universal argument)
// Default implementation does nothing.
virtual void Attach(Editor *) {}
// Poll for input and translate it to a command. Non-blocking.
// Returns true if a command is available in 'out'. Returns false if no input.
virtual bool Poll(MappedInput &out) = 0;

View File

@@ -42,6 +42,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool
case 'a':
out = CommandId::MarkAllAndJumpEnd;
return true;
case 'k':
out = CommandId::CenterOnCursor; // C-k k center current line
return true;
case 'b':
out = CommandId::BufferSwitchStart;
return true;
@@ -215,4 +218,4 @@ KLookupEscCommand(const int ascii_key, CommandId &out) -> bool
break;
}
return false;
}
}

988
QtFrontend.cc Normal file
View File

@@ -0,0 +1,988 @@
#include "QtFrontend.h"
#include <QApplication>
#include <QWidget>
#include <QKeyEvent>
#include <QTimer>
#include <QScreen>
#include <QFont>
#include <QFontMetrics>
#include <QFontDatabase>
#include <QFileDialog>
#include <QFontDialog>
#include <QPainter>
#include <QPaintEvent>
#include <QWheelEvent>
#include <regex>
#include "Editor.h"
#include "Command.h"
#include "Buffer.h"
#include "GUITheme.h"
#include "Highlight.h"
namespace {
class MainWindow : public QWidget {
public:
explicit MainWindow(class QtInputHandler &ih, QWidget *parent = nullptr)
: QWidget(parent), input_(ih)
{
// Match ImGui window title format
setWindowTitle(QStringLiteral("kge - kyle's graphical editor ")
+ QStringLiteral(KTE_VERSION_STR));
resize(1280, 800);
setFocusPolicy(Qt::StrongFocus);
}
bool WasClosed() const
{
return closed_;
}
void SetEditor(Editor *ed)
{
ed_ = ed;
}
void SetFontFamilyAndSize(QString family, int px)
{
if (family.isEmpty())
family = QStringLiteral("Brass Mono");
if (px <= 0)
px = 18;
font_family_ = std::move(family);
font_px_ = px;
update();
}
protected:
void keyPressEvent(QKeyEvent *event) override
{
// Route to editor keymap; if handled, accept and stop propagation so
// Qt doesn't trigger any default widget shortcuts.
if (input_.ProcessKeyEvent(*event)) {
event->accept();
return;
}
QWidget::keyPressEvent(event);
}
void paintEvent(QPaintEvent *event) override
{
Q_UNUSED(event);
QPainter p(this);
p.setRenderHint(QPainter::TextAntialiasing, true);
// Colors from GUITheme palette (Qt branch)
auto to_qcolor = [](const KteColor &c) -> QColor {
int r = int(std::round(c.x * 255.0f));
int g = int(std::round(c.y * 255.0f));
int b = int(std::round(c.z * 255.0f));
int a = int(std::round(c.w * 255.0f));
return QColor(r, g, b, a);
};
const auto pal = kte::GetPalette();
const QColor bg = to_qcolor(pal.bg);
const QColor fg = to_qcolor(pal.fg);
const QColor sel_bg = to_qcolor(pal.sel_bg);
const QColor cur_bg = to_qcolor(pal.cur_bg);
const QColor status_bg = to_qcolor(pal.status_bg);
const QColor status_fg = to_qcolor(pal.status_fg);
// Background
p.fillRect(rect(), bg);
// Font/metrics (configured or defaults)
QFont f(font_family_, font_px_);
p.setFont(f);
QFontMetrics fm(f);
const int line_h = fm.height();
const int ch_w = std::max(1, fm.horizontalAdvance(QStringLiteral(" ")));
// Layout metrics
const int pad_l = 8;
const int pad_t = 6;
const int pad_r = 8;
const int pad_b = 6;
const int status_h = line_h + 6; // status bar height
// Content area (text viewport)
const QRect content_rect(pad_l,
pad_t,
width() - pad_l - pad_r,
height() - pad_t - pad_b - status_h);
// Text viewport occupies all content area (no extra title row)
QRect viewport(content_rect.x(), content_rect.y(), content_rect.width(), content_rect.height());
// Draw buffer contents
if (ed_ && viewport.height() > 0 && viewport.width() > 0) {
const Buffer *buf = ed_->CurrentBuffer();
if (buf) {
const auto &lines = buf->Rows();
const std::size_t nrows = lines.size();
const std::size_t rowoffs = buf->Rowoffs();
const std::size_t coloffs = buf->Coloffs();
const std::size_t cy = buf->Cury();
const std::size_t cx = buf->Curx();
// Visible line count
const int max_lines = (line_h > 0) ? (viewport.height() / line_h) : 0;
const std::size_t last_row = std::min<std::size_t>(
nrows, rowoffs + std::max(0, max_lines));
// Tab width: follow ImGuiRenderer default of 4
const std::size_t tabw = 4;
// Prepare painter clip to viewport
p.save();
p.setClipRect(viewport);
// Iterate visible lines
for (std::size_t i = rowoffs, vis_idx = 0; i < last_row; ++i, ++vis_idx) {
const auto &line = static_cast<const std::string &>(lines[i]);
const int y = viewport.y() + static_cast<int>(vis_idx) * line_h;
const int baseline = y + fm.ascent();
// Helper: convert src col -> rx with tab expansion
auto src_to_rx_line = [&](std::size_t src_col) -> std::size_t {
std::size_t rx = 0;
for (std::size_t k = 0; k < src_col && k < line.size(); ++k) {
rx += (line[k] == '\t') ? (tabw - (rx % tabw)) : 1;
}
return rx;
};
// Search-match background highlights first (under text)
if (ed_->SearchActive() && !ed_->SearchQuery().empty()) {
std::vector<std::pair<std::size_t, std::size_t> > hl_src_ranges;
// Compute ranges per line (source indices)
if (ed_->PromptActive() &&
(ed_->CurrentPromptKind() == Editor::PromptKind::RegexSearch ||
ed_->CurrentPromptKind() ==
Editor::PromptKind::RegexReplaceFind)) {
try {
std::regex rx(ed_->SearchQuery());
for (auto it = std::sregex_iterator(
line.begin(), line.end(), rx);
it != std::sregex_iterator(); ++it) {
const auto &m = *it;
std::size_t sx = static_cast<std::size_t>(m.
position());
std::size_t ex =
sx + static_cast<std::size_t>(m.
length());
hl_src_ranges.emplace_back(sx, ex);
}
} catch (const std::regex_error &) {
// Invalid regex: ignore, status line already shows errors
}
} else {
const std::string &q = ed_->SearchQuery();
if (!q.empty()) {
std::size_t pos = 0;
while ((pos = line.find(q, pos)) != std::string::npos) {
hl_src_ranges.emplace_back(pos, pos + q.size());
pos += q.size();
}
}
}
if (!hl_src_ranges.empty()) {
const bool has_current =
ed_->SearchMatchLen() > 0 && ed_->SearchMatchY() == i;
const std::size_t cur_x = has_current ? ed_->SearchMatchX() : 0;
const std::size_t cur_end = has_current
? (ed_->SearchMatchX() + ed_->SearchMatchLen())
: 0;
for (const auto &rg: hl_src_ranges) {
std::size_t sx = rg.first, ex = rg.second;
std::size_t rx_s = src_to_rx_line(sx);
std::size_t rx_e = src_to_rx_line(ex);
if (rx_e <= coloffs)
continue; // fully left of view
int vx0 = viewport.x() + static_cast<int>((
(rx_s > coloffs ? rx_s - coloffs : 0)
* ch_w));
int vx1 = viewport.x() + static_cast<int>((
(rx_e - coloffs) * ch_w));
QRect r(vx0, y, std::max(0, vx1 - vx0), line_h);
if (r.width() <= 0)
continue;
bool is_current =
has_current && sx == cur_x && ex == cur_end;
QColor col = is_current
? QColor(255, 220, 120, 140)
: QColor(200, 200, 0, 90);
p.fillRect(r, col);
}
}
}
// Selection background (if active on this line)
if (buf->MarkSet() && (
i == buf->MarkCury() || i == cy || (
i > std::min(buf->MarkCury(), cy) && i < std::max(
buf->MarkCury(), cy)))) {
std::size_t sx = 0, ex = 0;
if (buf->MarkCury() == i && cy == i) {
sx = std::min(buf->MarkCurx(), cx);
ex = std::max(buf->MarkCurx(), cx);
} else if (i == buf->MarkCury()) {
sx = buf->MarkCurx();
ex = line.size();
} else if (i == cy) {
sx = 0;
ex = cx;
} else {
sx = 0;
ex = line.size();
}
std::size_t rx_s = src_to_rx_line(sx);
std::size_t rx_e = src_to_rx_line(ex);
if (rx_e > coloffs) {
int vx0 = viewport.x() + static_cast<int>((rx_s > coloffs
? rx_s - coloffs
: 0) * ch_w);
int vx1 = viewport.x() + static_cast<int>(
(rx_e - coloffs) * ch_w);
QRect sel_r(vx0, y, std::max(0, vx1 - vx0), line_h);
if (sel_r.width() > 0)
p.fillRect(sel_r, sel_bg);
}
}
// Build expanded line (tabs -> spaces) for drawing
std::string expanded;
expanded.reserve(line.size() + 8);
std::size_t rx_acc = 0;
for (char c: line) {
if (c == '\t') {
std::size_t adv = (tabw - (rx_acc % tabw));
expanded.append(adv, ' ');
rx_acc += adv;
} else {
expanded.push_back(c);
rx_acc += 1;
}
}
// Syntax highlighting spans or plain text
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->
HasHighlighter()) {
kte::LineHighlight lh = buf->Highlighter()->GetLine(
*buf, static_cast<int>(i), buf->Version());
struct SSpan {
std::size_t s;
std::size_t e;
kte::TokenKind k;
};
std::vector<SSpan> spans;
spans.reserve(lh.spans.size());
const std::size_t line_len = line.size();
for (const auto &sp: lh.spans) {
int s_raw = sp.col_start;
int e_raw = sp.col_end;
if (e_raw < s_raw)
std::swap(e_raw, s_raw);
std::size_t s = static_cast<std::size_t>(std::max(
0, std::min(s_raw, (int) line_len)));
std::size_t e = static_cast<std::size_t>(std::max(
(int) s, std::min(e_raw, (int) line_len)));
if (s < e)
spans.push_back({s, e, sp.kind});
}
std::sort(spans.begin(), spans.end(),
[](const SSpan &a, const SSpan &b) {
return a.s < b.s;
});
auto colorFor = [](kte::TokenKind k) -> QColor {
// GUITheme provides colors via ImGui vector; avoid direct dependency types
const auto v = kte::SyntaxInk(k);
return QColor(int(v.x * 255.0f), int(v.y * 255.0f),
int(v.z * 255.0f), int(v.w * 255.0f));
};
// Helper to convert src col to expanded rx
auto src_to_rx_full = [&](std::size_t sidx) -> std::size_t {
std::size_t rx = 0;
for (std::size_t k = 0; k < sidx && k < line.size(); ++k) {
rx += (line[k] == '\t') ? (tabw - (rx % tabw)) : 1;
}
return rx;
};
if (spans.empty()) {
// No highlight spans: draw the whole (visible) expanded line in default fg
if (coloffs < expanded.size()) {
const char *start =
expanded.c_str() + static_cast<int>(coloffs);
p.setPen(fg);
p.drawText(viewport.x(), baseline,
QString::fromUtf8(start));
}
} else {
// Draw colored spans
for (const auto &sp: spans) {
std::size_t rx_s = src_to_rx_full(sp.s);
std::size_t rx_e = src_to_rx_full(sp.e);
if (rx_e <= coloffs)
continue; // left of viewport
std::size_t draw_start = (rx_s > coloffs)
? rx_s
: coloffs;
std::size_t draw_end = std::min<std::size_t>(
rx_e, expanded.size());
if (draw_end <= draw_start)
continue;
std::size_t screen_x = draw_start - coloffs;
int px = viewport.x() + int(screen_x * ch_w);
int len = int(draw_end - draw_start);
p.setPen(colorFor(sp.k));
p.drawText(px, baseline,
QString::fromUtf8(
expanded.c_str() + draw_start, len));
}
}
} else {
// Draw expanded text clipped by coloffs
if (static_cast<std::size_t>(coloffs) < expanded.size()) {
const char *start =
expanded.c_str() + static_cast<int>(coloffs);
p.setPen(fg);
p.drawText(viewport.x(), baseline, QString::fromUtf8(start));
}
}
// Cursor indicator on current line
if (i == cy) {
std::size_t rx_cur = src_to_rx_line(cx);
if (rx_cur >= coloffs) {
// Compute exact pixel x by measuring expanded substring [coloffs, rx_cur)
std::size_t start = std::min<std::size_t>(
coloffs, expanded.size());
std::size_t end = std::min<
std::size_t>(rx_cur, expanded.size());
int px_advance = 0;
if (end > start) {
const QString sub = QString::fromUtf8(
expanded.c_str() + start,
static_cast<int>(end - start));
px_advance = fm.horizontalAdvance(sub);
}
int x0 = viewport.x() + px_advance;
QRect r(x0, y, ch_w, line_h);
p.fillRect(r, cur_bg);
}
}
}
p.restore();
}
}
// Status bar
const int bar_y = height() - status_h;
QRect status_rect(0, bar_y, width(), status_h);
p.fillRect(status_rect, status_bg);
p.setPen(status_fg);
if (ed_) {
const int pad = 6;
const int left_x = status_rect.x() + pad;
const int right_x_max = status_rect.x() + status_rect.width() - pad;
const int baseline_y = bar_y + (status_h + fm.ascent() - fm.descent()) / 2;
// If a prompt is active, mirror ImGui/TUI: show only the prompt across the bar
if (ed_->PromptActive()) {
std::string label = ed_->PromptLabel();
std::string text = ed_->PromptText();
// Map $HOME to ~ for path prompts (Open/Save/Chdir)
auto kind = ed_->CurrentPromptKind();
if (kind == Editor::PromptKind::OpenFile ||
kind == Editor::PromptKind::SaveAs ||
kind == Editor::PromptKind::Chdir) {
const char *home_c = std::getenv("HOME");
if (home_c && *home_c) {
std::string home(home_c);
if (text.rfind(home, 0) == 0) {
std::string rest = text.substr(home.size());
if (rest.empty())
text = "~";
else if (!rest.empty() && (rest[0] == '/' || rest[0] == '\\'))
text = std::string("~") + rest;
}
}
}
std::string prefix;
if (kind == Editor::PromptKind::Command)
prefix = ": ";
else if (!label.empty())
prefix = label + ": ";
// Compose text and elide per behavior:
const int max_w = status_rect.width() - 2 * pad;
QString qprefix = QString::fromStdString(prefix);
QString qtext = QString::fromStdString(text);
int avail_w = std::max(0, max_w - fm.horizontalAdvance(qprefix));
Qt::TextElideMode mode = Qt::ElideRight;
if (kind == Editor::PromptKind::OpenFile ||
kind == Editor::PromptKind::SaveAs ||
kind == Editor::PromptKind::Chdir) {
mode = Qt::ElideLeft;
}
QString shown = fm.elidedText(qtext, mode, avail_w);
p.drawText(left_x, baseline_y, qprefix + shown);
} else {
// Build left segment: app/version, buffer idx/total, filename [+dirty], line count
QString left;
left += QStringLiteral("kge ");
left += QStringLiteral(KTE_VERSION_STR);
const Buffer *buf = ed_->CurrentBuffer();
if (buf) {
// buffer index/total
std::size_t total = ed_->BufferCount();
if (total > 0) {
std::size_t idx1 = ed_->CurrentBufferIndex() + 1; // 1-based
left += QStringLiteral(" [");
left += QString::number(static_cast<qlonglong>(idx1));
left += QStringLiteral("/");
left += QString::number(static_cast<qlonglong>(total));
left += QStringLiteral("] ");
} else {
left += QStringLiteral(" ");
}
// buffer display name
std::string disp;
try {
disp = ed_->DisplayNameFor(*buf);
} catch (...) {
disp = buf->Filename();
}
if (disp.empty())
disp = "[No Name]";
left += QString::fromStdString(disp);
if (buf->Dirty())
left += QStringLiteral(" *");
// total lines suffix " <n>L"
unsigned long lcount = static_cast<unsigned long>(buf->Rows().size());
left += QStringLiteral(" ");
left += QString::number(static_cast<qlonglong>(lcount));
left += QStringLiteral("L");
}
// Build right segment: cursor and mark
QString right;
if (buf) {
int row1 = static_cast<int>(buf->Cury()) + 1;
int col1 = static_cast<int>(buf->Curx()) + 1;
bool have_mark = buf->MarkSet();
int mrow1 = have_mark ? static_cast<int>(buf->MarkCury()) + 1 : 0;
int mcol1 = have_mark ? static_cast<int>(buf->MarkCurx()) + 1 : 0;
if (have_mark)
right = QString("%1,%2 | M: %3,%4").arg(row1).arg(col1).arg(mrow1).arg(
mcol1);
else
right = QString("%1,%2 | M: not set").arg(row1).arg(col1);
}
// Middle message: status text
QString mid = QString::fromStdString(ed_->Status());
// Measure and layout
int left_w = fm.horizontalAdvance(left);
int right_w = fm.horizontalAdvance(right);
int lx = left_x;
int rx = std::max(left_x, right_x_max - right_w);
// If overlap, elide left to make space for right
if (lx + left_w + pad > rx) {
int max_left_w = std::max(0, rx - lx - pad);
left = fm.elidedText(left, Qt::ElideRight, max_left_w);
left_w = fm.horizontalAdvance(left);
}
// Draw left and right
p.drawText(lx, baseline_y, left);
if (!right.isEmpty())
p.drawText(rx, baseline_y, right);
// Middle message clipped between end of left and start of right
int mid_left = lx + left_w + pad;
int mid_right = std::max(mid_left, rx - pad);
int mid_w = std::max(0, mid_right - mid_left);
if (mid_w > 0 && !mid.isEmpty()) {
QString mid_show = fm.elidedText(mid, Qt::ElideRight, mid_w);
p.save();
p.setClipRect(QRect(mid_left, bar_y, mid_w, status_h));
p.drawText(mid_left, baseline_y, mid_show);
p.restore();
}
}
}
}
void resizeEvent(QResizeEvent *event) override
{
QWidget::resizeEvent(event);
if (!ed_)
return;
// Update editor dimensions based on new size
QFont f(font_family_, font_px_);
QFontMetrics fm(f);
const int line_h = std::max(12, fm.height());
const int ch_w = std::max(6, fm.horizontalAdvance(QStringLiteral(" ")));
const int pad_l = 8, pad_r = 8, pad_t = 6, pad_b = 6;
const int status_h = line_h + 6;
const int avail_w = std::max(0, width() - pad_l - pad_r);
const int avail_h = std::max(0, height() - pad_t - pad_b - status_h);
std::size_t rows = std::max<std::size_t>(1, (avail_h / line_h));
std::size_t cols = std::max<std::size_t>(1, (avail_w / ch_w));
ed_->SetDimensions(rows, cols);
}
void wheelEvent(QWheelEvent *event) override
{
if (!ed_) {
QWidget::wheelEvent(event);
return;
}
Buffer *buf = ed_->CurrentBuffer();
if (!buf) {
QWidget::wheelEvent(event);
return;
}
// Recompute metrics to map pixel deltas to rows/cols
QFont f(font_family_, font_px_);
QFontMetrics fm(f);
const int line_h = std::max(12, fm.height());
const int ch_w = std::max(6, fm.horizontalAdvance(QStringLiteral(" ")));
// Determine scroll intent: use pixelDelta when available (trackpads), otherwise angleDelta
QPoint pixel = event->pixelDelta();
QPoint angle = event->angleDelta();
double v_lines_delta = 0.0;
double h_cols_delta = 0.0;
// Horizontal scroll with Shift or explicit horizontal delta
bool horiz_mode = (event->modifiers() & Qt::ShiftModifier) || (!pixel.isNull() && pixel.x() != 0) || (
!angle.isNull() && angle.x() != 0);
if (!pixel.isNull()) {
// Trackpad smooth scrolling (pixels)
v_lines_delta = -static_cast<double>(pixel.y()) / std::max(1, line_h);
h_cols_delta = -static_cast<double>(pixel.x()) / std::max(1, ch_w);
} else if (!angle.isNull()) {
// Mouse wheel: 120 units per notch; map one notch to 3 lines similar to ImGui UX
v_lines_delta = -static_cast<double>(angle.y()) / 120.0 * 3.0;
// For horizontal wheels, each notch scrolls 8 columns
h_cols_delta = -static_cast<double>(angle.x()) / 120.0 * 8.0;
}
// Accumulate fractional deltas across events
v_scroll_accum_ += v_lines_delta;
h_scroll_accum_ += h_cols_delta;
int d_rows = 0;
int d_cols = 0;
if (std::fabs(v_scroll_accum_) >= 1.0 && (!horiz_mode || std::fabs(v_scroll_accum_) > std::fabs(
h_scroll_accum_))) {
d_rows = static_cast<int>(v_scroll_accum_);
v_scroll_accum_ -= d_rows;
}
if (std::fabs(h_scroll_accum_) >= 1.0 && (horiz_mode || std::fabs(h_scroll_accum_) >= std::fabs(
v_scroll_accum_))) {
d_cols = static_cast<int>(h_scroll_accum_);
h_scroll_accum_ -= d_cols;
}
if (d_rows != 0 || d_cols != 0) {
std::size_t new_rowoffs = buf->Rowoffs();
std::size_t new_coloffs = buf->Coloffs();
// Clamp vertical between 0 and last row (leaving at least one visible line)
if (d_rows != 0) {
long nr = static_cast<long>(new_rowoffs) + d_rows;
if (nr < 0)
nr = 0;
const auto nrows = static_cast<long>(buf->Rows().size());
if (nr > std::max(0L, nrows - 1))
nr = std::max(0L, nrows - 1);
new_rowoffs = static_cast<std::size_t>(nr);
}
if (d_cols != 0) {
long nc = static_cast<long>(new_coloffs) + d_cols;
if (nc < 0)
nc = 0;
new_coloffs = static_cast<std::size_t>(nc);
}
buf->SetOffsets(new_rowoffs, new_coloffs);
update();
event->accept();
return;
}
QWidget::wheelEvent(event);
}
void closeEvent(QCloseEvent *event) override
{
closed_ = true;
QWidget::closeEvent(event);
}
private:
QtInputHandler &input_;
bool closed_ = false;
Editor *ed_ = nullptr;
double v_scroll_accum_ = 0.0;
double h_scroll_accum_ = 0.0;
QString font_family_ = QStringLiteral("Brass Mono");
int font_px_ = 18;
};
} // namespace
bool
GUIFrontend::Init(Editor &ed)
{
int argc = 0;
char **argv = nullptr;
app_ = new QApplication(argc, argv);
window_ = new MainWindow(input_);
window_->show();
// Ensure the window becomes the active, focused window so it receives key events
window_->activateWindow();
window_->raise();
window_->setFocus(Qt::OtherFocusReason);
renderer_.Attach(window_);
input_.Attach(&ed);
if (auto *mw = dynamic_cast<MainWindow *>(window_))
mw->SetEditor(&ed);
// Load GUI configuration (kge.ini) and configure font for Qt
config_ = GUIConfig::Load();
// Apply background mode from config to match ImGui frontend behavior
if (config_.background == "light")
kte::SetBackgroundMode(kte::BackgroundMode::Light);
else
kte::SetBackgroundMode(kte::BackgroundMode::Dark);
// Apply theme by name for Qt palette-based theming (maps to named palettes).
// If unknown, falls back to the generic light/dark palette.
(void) kte::ApplyQtThemeByName(config_.theme);
if (window_)
window_->update();
// Map GUIConfig font name to a system family (Qt uses installed fonts)
auto choose_family = [](const std::string &name) -> QString {
QString fam;
std::string n = name;
std::transform(n.begin(), n.end(), n.begin(), [](unsigned char c) {
return (char) std::tolower(c);
});
if (n.empty() || n == "default" || n == "brassmono" || n == "brassmonocode") {
fam = QStringLiteral("Brass Mono");
} else if (n == "jetbrains" || n == "jetbrains mono" || n == "jetbrains-mono") {
fam = QStringLiteral("JetBrains Mono");
} else if (n == "iosevka") {
fam = QStringLiteral("Iosevka");
} else if (n == "inconsolata" || n == "inconsolataex") {
fam = QStringLiteral("Inconsolata");
} else if (n == "space" || n == "spacemono" || n == "space mono") {
fam = QStringLiteral("Space Mono");
} else if (n == "go") {
fam = QStringLiteral("Go Mono");
} else if (n == "ibm" || n == "ibm plex mono" || n == "ibm-plex-mono") {
fam = QStringLiteral("IBM Plex Mono");
} else if (n == "fira" || n == "fira code" || n == "fira-code") {
fam = QStringLiteral("Fira Code");
} else if (!name.empty()) {
fam = QString::fromStdString(name);
}
// Validate availability; choose a fallback if needed
const auto families = QFontDatabase::families();
if (!fam.isEmpty() && families.contains(fam)) {
return fam;
}
// Preferred fallback chain on macOS; otherwise, try common monospace families
const QStringList fallbacks = {
QStringLiteral("Brass Mono"),
QStringLiteral("JetBrains Mono"),
QStringLiteral("SF Mono"),
QStringLiteral("Menlo"),
QStringLiteral("Monaco"),
QStringLiteral("Courier New"),
QStringLiteral("Courier"),
QStringLiteral("Monospace")
};
for (const auto &fb: fallbacks) {
if (families.contains(fb))
return fb;
}
// As a last resort, return the request (Qt will substitute)
return fam.isEmpty() ? QStringLiteral("Monospace") : fam;
};
QString family = choose_family(config_.font);
int px_size = (config_.font_size > 0.0f) ? (int) std::lround(config_.font_size) : 18;
if (auto *mw = dynamic_cast<MainWindow *>(window_)) {
mw->SetFontFamilyAndSize(family, px_size);
}
// Track current font in globals for command/status queries
kte::gCurrentFontFamily = family.toStdString();
kte::gCurrentFontSize = static_cast<float>(px_size);
// Set initial dimensions based on font metrics
QFont f(family, px_size);
QFontMetrics fm(f);
const int line_h = std::max(12, fm.height());
const int ch_w = std::max(6, fm.horizontalAdvance(QStringLiteral("M")));
const int w = window_->width();
const int h = window_->height();
const int pad = 16;
const int status_h = line_h + 4;
const int avail_w = std::max(0, w - 2 * pad);
const int avail_h = std::max(0, h - 2 * pad - status_h);
std::size_t rows = std::max<std::size_t>(1, (avail_h / line_h) + 1); // + status
std::size_t cols = std::max<std::size_t>(1, (avail_w / ch_w));
ed.SetDimensions(rows, cols);
return true;
}
void
GUIFrontend::Step(Editor &ed, bool &running)
{
// Pump Qt events
if (app_)
app_->processEvents();
// Drain input queue
for (;;) {
MappedInput mi;
if (!input_.Poll(mi))
break;
if (mi.hasCommand) {
Execute(ed, mi.id, mi.arg, mi.count);
}
}
if (ed.QuitRequested()) {
running = false;
}
// --- Visual File Picker (Qt): invoked via CommandId::VisualFilePickerToggle ---
if (ed.FilePickerVisible()) {
QString startDir;
if (!ed.FilePickerDir().empty()) {
startDir = QString::fromStdString(ed.FilePickerDir());
}
QFileDialog dlg(window_, QStringLiteral("Open File"), startDir);
dlg.setFileMode(QFileDialog::ExistingFile);
if (dlg.exec() == QDialog::Accepted) {
const QStringList files = dlg.selectedFiles();
if (!files.isEmpty()) {
const QString fp = files.front();
std::string err;
if (ed.OpenFile(fp.toStdString(), err)) {
ed.SetStatus(std::string("Opened: ") + fp.toStdString());
} else if (!err.empty()) {
ed.SetStatus(std::string("Open failed: ") + err);
} else {
ed.SetStatus("Open failed");
}
// Update picker dir for next time
QFileInfo info(fp);
ed.SetFilePickerDir(info.dir().absolutePath().toStdString());
}
}
// Close picker overlay regardless of outcome
ed.SetFilePickerVisible(false);
if (window_)
window_->update();
}
// Apply any queued theme change requests (from command handler)
if (kte::gThemeChangePending) {
if (!kte::gThemeChangeRequest.empty()) {
// Apply Qt palette theme by name; if unknown, keep current palette
(void) kte::ApplyQtThemeByName(kte::gThemeChangeRequest);
}
kte::gThemeChangePending = false;
kte::gThemeChangeRequest.clear();
if (window_)
window_->update();
}
// Visual font picker request (Qt only)
if (kte::gFontDialogRequested) {
// Seed initial font from current or default
QFont seed;
if (!kte::gCurrentFontFamily.empty()) {
seed = QFont(QString::fromStdString(kte::gCurrentFontFamily),
(int) std::lround(kte::gCurrentFontSize > 0 ? kte::gCurrentFontSize : 18));
} else {
seed = window_ ? window_->font() : QFont();
}
bool ok = false;
const QFont chosen = QFontDialog::getFont(&ok, seed, window_, QStringLiteral("Choose Editor Font"));
if (ok) {
// Queue font change via existing hooks
kte::gFontFamilyRequest = chosen.family().toStdString();
// Use pixel size if available, otherwise convert from point size approximately
int px = chosen.pixelSize();
if (px <= 0) {
// Approximate points to pixels (96 DPI assumption); Qt will rasterize appropriately
px = (int) std::lround(chosen.pointSizeF() * 96.0 / 72.0);
if (px <= 0)
px = 18;
}
kte::gFontSizeRequest = static_cast<float>(px);
kte::gFontChangePending = true;
}
kte::gFontDialogRequested = false;
if (window_)
window_->update();
}
// Apply any queued font change requests (Qt)
if (kte::gFontChangePending) {
// Derive target family
auto map_family = [](const std::string &name) -> QString {
std::string n = name;
std::transform(n.begin(), n.end(), n.begin(), [](unsigned char c) {
return (char) std::tolower(c);
});
QString fam;
if (n == "brass" || n == "brassmono" || n == "brass mono") {
fam = QStringLiteral("Brass Mono");
} else if (n == "jetbrains" || n == "jetbrains mono" || n == "jetbrains-mono") {
fam = QStringLiteral("JetBrains Mono");
} else if (n == "iosevka") {
fam = QStringLiteral("Iosevka");
} else if (n == "inconsolata" || n == "inconsolataex") {
fam = QStringLiteral("Inconsolata");
} else if (n == "space" || n == "spacemono" || n == "space mono") {
fam = QStringLiteral("Space Mono");
} else if (n == "go") {
fam = QStringLiteral("Go Mono");
} else if (n == "ibm" || n == "ibm plex mono" || n == "ibm-plex-mono") {
fam = QStringLiteral("IBM Plex Mono");
} else if (n == "fira" || n == "fira code" || n == "fira-code") {
fam = QStringLiteral("Fira Code");
} else if (!name.empty()) {
fam = QString::fromStdString(name);
}
// Validate availability; choose fallback if needed
const auto families = QFontDatabase::families();
if (!fam.isEmpty() && families.contains(fam)) {
return fam;
}
// Fallback chain
const QStringList fallbacks = {
QStringLiteral("Brass Mono"),
QStringLiteral("JetBrains Mono"),
QStringLiteral("SF Mono"),
QStringLiteral("Menlo"),
QStringLiteral("Monaco"),
QStringLiteral("Courier New"),
QStringLiteral("Courier"),
QStringLiteral("Monospace")
};
for (const auto &fb: fallbacks) {
if (families.contains(fb))
return fb;
}
return fam.isEmpty() ? QStringLiteral("Monospace") : fam;
};
QString target_family;
if (!kte::gFontFamilyRequest.empty()) {
target_family = map_family(kte::gFontFamilyRequest);
} else if (!kte::gCurrentFontFamily.empty()) {
target_family = QString::fromStdString(kte::gCurrentFontFamily);
}
int target_px = 0;
if (kte::gFontSizeRequest > 0.0f) {
target_px = (int) std::lround(kte::gFontSizeRequest);
} else if (kte::gCurrentFontSize > 0.0f) {
target_px = (int) std::lround(kte::gCurrentFontSize);
}
if (target_px <= 0)
target_px = 18;
if (target_family.isEmpty())
target_family = QStringLiteral("Monospace");
if (auto *mw = dynamic_cast<MainWindow *>(window_)) {
mw->SetFontFamilyAndSize(target_family, target_px);
}
// Update globals
kte::gCurrentFontFamily = target_family.toStdString();
kte::gCurrentFontSize = static_cast<float>(target_px);
// Reset requests
kte::gFontChangePending = false;
kte::gFontFamilyRequest.clear();
kte::gFontSizeRequest = 0.0f;
// Recompute editor dimensions to match new metrics
QFont f(target_family, target_px);
QFontMetrics fm(f);
const int line_h = std::max(12, fm.height());
const int ch_w = std::max(6, fm.horizontalAdvance(QStringLiteral("M")));
const int w = window_ ? window_->width() : 0;
const int h = window_ ? window_->height() : 0;
const int pad = 16;
const int status_h = line_h + 4;
const int avail_w = std::max(0, w - 2 * pad);
const int avail_h = std::max(0, h - 2 * pad - status_h);
std::size_t rows = std::max<std::size_t>(1, (avail_h / line_h) + 1); // + status
std::size_t cols = std::max<std::size_t>(1, (avail_w / ch_w));
ed.SetDimensions(rows, cols);
if (window_)
window_->update();
}
// Draw current frame (request repaint)
renderer_.Draw(ed);
// Detect window close
if (auto *mw = dynamic_cast<MainWindow *>(window_)) {
if (mw->WasClosed()) {
running = false;
}
}
}
void
GUIFrontend::Shutdown()
{
if (window_) {
window_->close();
delete window_;
window_ = nullptr;
}
if (app_) {
delete app_;
app_ = nullptr;
}
}

36
QtFrontend.h Normal file
View File

@@ -0,0 +1,36 @@
/*
* QtFrontend - couples QtInputHandler + QtRenderer and owns Qt lifecycle
*/
#pragma once
#include "Frontend.h"
#include "GUIConfig.h"
#include "QtInputHandler.h"
#include "QtRenderer.h"
class QApplication;
class QWidget;
// Keep the public class name GUIFrontend to match main.cc selection logic.
class GUIFrontend final : public Frontend {
public:
GUIFrontend() = default;
~GUIFrontend() override = default;
bool Init(Editor &ed) override;
void Step(Editor &ed, bool &running) override;
void Shutdown() override;
private:
GUIConfig config_{};
QtInputHandler input_{};
QtRenderer renderer_{};
QApplication *app_ = nullptr; // owned
QWidget *window_ = nullptr; // owned
int width_ = 1280;
int height_ = 800;
};

538
QtInputHandler.cc Normal file
View File

@@ -0,0 +1,538 @@
// Refactor: route all key processing through command subsystem, mirroring ImGuiInputHandler
#include "QtInputHandler.h"
#include <QKeyEvent>
#include <ncurses.h>
#include "Editor.h"
#include "KKeymap.h"
// Temporary verbose logging to debug macOS Qt key translation issues
// Default to off; enable by defining QT_IH_DEBUG=1 at compile time when needed.
#ifndef QT_IH_DEBUG
#define QT_IH_DEBUG 0
#endif
#if QT_IH_DEBUG
#include <cstdio>
static const char *
mods_str(Qt::KeyboardModifiers m)
{
static thread_local char buf[64];
buf[0] = '\0';
bool first = true;
auto add = [&](const char *s) {
if (!first)
std::snprintf(buf + std::strlen(buf), sizeof(buf) - std::strlen(buf), "|");
std::snprintf(buf + std::strlen(buf), sizeof(buf) - std::strlen(buf), "%s", s);
first = false;
};
if (m & Qt::ShiftModifier)
add("Shift");
if (m & Qt::ControlModifier)
add("Ctrl");
if (m & Qt::AltModifier)
add("Alt");
if (m & Qt::MetaModifier)
add("Meta");
if (first)
std::snprintf(buf, sizeof(buf), "none");
return buf;
}
#define LOGF(...) std::fprintf(stderr, __VA_ARGS__)
#else
#define LOGF(...) ((void)0)
#endif
static bool
IsPrintableQt(const QKeyEvent &e)
{
// Printable if it yields non-empty text and no Ctrl/Meta modifier
if (e.modifiers() & (Qt::ControlModifier | Qt::MetaModifier))
return false;
const QString t = e.text();
return !t.isEmpty() && !t.at(0).isNull();
}
static int
ToAsciiKey(const QKeyEvent &e)
{
const QString t = e.text();
if (!t.isEmpty()) {
const QChar c = t.at(0);
if (!c.isNull())
return KLowerAscii(c.unicode());
}
// When modifiers (like Control) are held, Qt::text() can be empty on macOS.
// Fall back to mapping common virtual keys to ASCII.
switch (e.key()) {
case Qt::Key_A:
return 'a';
case Qt::Key_B:
return 'b';
case Qt::Key_C:
return 'c';
case Qt::Key_D:
return 'd';
case Qt::Key_E:
return 'e';
case Qt::Key_F:
return 'f';
case Qt::Key_G:
return 'g';
case Qt::Key_H:
return 'h';
case Qt::Key_I:
return 'i';
case Qt::Key_J:
return 'j';
case Qt::Key_K:
return 'k';
case Qt::Key_L:
return 'l';
case Qt::Key_M:
return 'm';
case Qt::Key_N:
return 'n';
case Qt::Key_O:
return 'o';
case Qt::Key_P:
return 'p';
case Qt::Key_Q:
return 'q';
case Qt::Key_R:
return 'r';
case Qt::Key_S:
return 's';
case Qt::Key_T:
return 't';
case Qt::Key_U:
return 'u';
case Qt::Key_V:
return 'v';
case Qt::Key_W:
return 'w';
case Qt::Key_X:
return 'x';
case Qt::Key_Y:
return 'y';
case Qt::Key_Z:
return 'z';
case Qt::Key_0:
return '0';
case Qt::Key_1:
return '1';
case Qt::Key_2:
return '2';
case Qt::Key_3:
return '3';
case Qt::Key_4:
return '4';
case Qt::Key_5:
return '5';
case Qt::Key_6:
return '6';
case Qt::Key_7:
return '7';
case Qt::Key_8:
return '8';
case Qt::Key_9:
return '9';
case Qt::Key_Comma:
return ',';
case Qt::Key_Period:
return '.';
case Qt::Key_Semicolon:
return ';';
case Qt::Key_Apostrophe:
return '\'';
case Qt::Key_Minus:
return '-';
case Qt::Key_Equal:
return '=';
case Qt::Key_Slash:
return '/';
case Qt::Key_Backslash:
return '\\';
case Qt::Key_BracketLeft:
return '[';
case Qt::Key_BracketRight:
return ']';
case Qt::Key_QuoteLeft:
return '`';
case Qt::Key_Space:
return ' ';
default:
break;
}
return 0;
}
// Case-preserving ASCII derivation for k-prefix handling where we need to
// distinguish between 'C' and 'c'. Falls back to virtual-key mapping if
// event text is unavailable (common when Control/Meta held on macOS).
static int
ToAsciiKeyPreserveCase(const QKeyEvent &e)
{
const QString t = e.text();
if (!t.isEmpty()) {
const QChar c = t.at(0);
if (!c.isNull())
return c.unicode();
}
// Fall back to virtual key mapping (letters as uppercase A..Z)
switch (e.key()) {
case Qt::Key_A:
return 'A';
case Qt::Key_B:
return 'B';
case Qt::Key_C:
return 'C';
case Qt::Key_D:
return 'D';
case Qt::Key_E:
return 'E';
case Qt::Key_F:
return 'F';
case Qt::Key_G:
return 'G';
case Qt::Key_H:
return 'H';
case Qt::Key_I:
return 'I';
case Qt::Key_J:
return 'J';
case Qt::Key_K:
return 'K';
case Qt::Key_L:
return 'L';
case Qt::Key_M:
return 'M';
case Qt::Key_N:
return 'N';
case Qt::Key_O:
return 'O';
case Qt::Key_P:
return 'P';
case Qt::Key_Q:
return 'Q';
case Qt::Key_R:
return 'R';
case Qt::Key_S:
return 'S';
case Qt::Key_T:
return 'T';
case Qt::Key_U:
return 'U';
case Qt::Key_V:
return 'V';
case Qt::Key_W:
return 'W';
case Qt::Key_X:
return 'X';
case Qt::Key_Y:
return 'Y';
case Qt::Key_Z:
return 'Z';
case Qt::Key_Comma:
return ',';
case Qt::Key_Period:
return '.';
case Qt::Key_Semicolon:
return ';';
case Qt::Key_Apostrophe:
return '\'';
case Qt::Key_Minus:
return '-';
case Qt::Key_Equal:
return '=';
case Qt::Key_Slash:
return '/';
case Qt::Key_Backslash:
return '\\';
case Qt::Key_BracketLeft:
return '[';
case Qt::Key_BracketRight:
return ']';
case Qt::Key_QuoteLeft:
return '`';
case Qt::Key_Space:
return ' ';
default:
break;
}
return 0;
}
bool
QtInputHandler::ProcessKeyEvent(const QKeyEvent &e)
{
const Qt::KeyboardModifiers mods = e.modifiers();
LOGF("[QtIH] keyPress key=0x%X mods=%s text='%s' k_prefix=%d k_ctrl_pending=%d esc_meta=%d\n",
e.key(), mods_str(mods), e.text().toUtf8().constData(), (int)k_prefix_, (int)k_ctrl_pending_,
(int)esc_meta_);
// Control-chord detection: only treat the physical Control key as control-like.
// Do NOT include Meta (Command) here so that ⌘-letter shortcuts do not fall into
// the Ctrl map (prevents ⌘-T being mistaken for C-t).
const bool ctrl_like = (mods & Qt::ControlModifier);
// 1) Universal argument digits (when active), consume digits without enqueuing commands
if (ed_ &&ed_
->
UArg() != 0
)
{
if (!(mods & (Qt::ControlModifier | Qt::AltModifier | Qt::MetaModifier))) {
if (e.key() >= Qt::Key_0 && e.key() <= Qt::Key_9) {
int d = e.key() - Qt::Key_0;
ed_->UArgDigit(d);
// request status refresh
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, CommandId::UArgStatus, std::string(), 0});
LOGF("[QtIH] UArg digit %d -> enqueue UArgStatus\n", d);
return true;
}
}
}
// 2) Enter k-prefix on C-k
if (ctrl_like && (e.key() == Qt::Key_K)) {
k_prefix_ = true;
k_ctrl_pending_ = false;
LOGF("[QtIH] Enter KPrefix\n");
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, CommandId::KPrefix, std::string(), 0});
return true;
}
// 3) If currently in k-prefix, resolve next key via KLookupKCommand
if (k_prefix_) {
// ESC/meta prefix should not interfere with k-suffix resolution
esc_meta_ = false;
// Support literal 'C' (uppercase) or '^' to indicate the next key is Ctrl-qualified.
// Use case-preserving derivation so that 'c' (lowercase) can still be a valid suffix
// like C-k c (BufferClose).
int ascii_raw = ToAsciiKeyPreserveCase(e);
if (ascii_raw == 'C' || ascii_raw == '^') {
k_ctrl_pending_ = true;
if (ed_)
ed_->SetStatus("C-k C _");
LOGF("[QtIH] KPrefix: set k_ctrl_pending via '%c'\n", (ascii_raw == 'C') ? 'C' : '^');
return true; // consume, wait for next key
}
int ascii_key = (ascii_raw != 0) ? ascii_raw : ToAsciiKey(e);
int lower = KLowerAscii(ascii_key);
// Only pass a control suffix for specific supported keys (d/x/q),
// matching ImGui behavior so that holding Ctrl during the suffix
// doesn't break other mappings like C-k c (BufferClose).
bool ctrl_suffix_supported = (lower == 'd' || lower == 'x' || lower == 'q');
bool pass_ctrl = (ctrl_like || k_ctrl_pending_) && ctrl_suffix_supported;
k_ctrl_pending_ = false; // consume pending qualifier on any suffix
LOGF("[QtIH] KPrefix: ascii_key=%d lower=%d pass_ctrl=%d\n", ascii_key, lower, (int)pass_ctrl);
if (ascii_key != 0) {
CommandId id;
if (KLookupKCommand(ascii_key, pass_ctrl, id)) {
LOGF("[QtIH] KPrefix: mapped to command id=%d\n", (int)id);
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, id, std::string(), 0});
} else {
// Unknown k-command: notify
std::string a;
a.push_back(static_cast<char>(ascii_key));
LOGF("[QtIH] KPrefix: unknown command for '%c'\n", (char)ascii_key);
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, CommandId::UnknownKCommand, a, 0});
}
k_prefix_ = false;
return true;
}
// If not resolvable, consume and exit k-prefix
k_prefix_ = false;
LOGF("[QtIH] KPrefix: unresolved key; exiting prefix\n");
return true;
}
// 3.5) GUI shortcut: Command/Meta + T opens the visual font picker (Qt only).
// Require Meta present and Control NOT present so Ctrl-T never triggers this.
if ((mods & Qt::MetaModifier) && !(mods & Qt::ControlModifier) && e.key() == Qt::Key_T) {
LOGF("[QtIH] Meta/Super-T -> VisualFontPickerToggle\n");
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, CommandId::VisualFontPickerToggle, std::string(), 0});
return true;
}
// 4) ESC as Meta prefix (set state). Alt/Meta chord handled below directly.
if (e.key() == Qt::Key_Escape) {
esc_meta_ = true;
LOGF("[QtIH] ESC: set esc_meta\n");
return true; // consumed
}
// 5) Alt/Meta bindings (ESC f/b equivalent). Handle either Alt/Meta or pending esc_meta_
// ESC/meta chords: on macOS, do NOT treat Meta as ESC; only Alt (Option) should trigger.
#if defined(__APPLE__)
if (esc_meta_ || (mods & Qt::AltModifier)) {
#else
if (esc_meta_ || (mods & (Qt::AltModifier | Qt::MetaModifier))) {
#endif
int ascii_key = 0;
if (e.key() == Qt::Key_Backspace) {
ascii_key = KEY_BACKSPACE;
} else if (e.key() >= Qt::Key_A && e.key() <= Qt::Key_Z) {
ascii_key = 'a' + (e.key() - Qt::Key_A);
} else if (e.key() == Qt::Key_Comma) {
ascii_key = '<';
} else if (e.key() == Qt::Key_Period) {
ascii_key = '>';
}
// If still unknown, try deriving from text (covers digits, punctuation, locale)
if (ascii_key == 0) {
ascii_key = ToAsciiKey(e);
}
esc_meta_ = false; // one-shot regardless
if (ascii_key != 0) {
ascii_key = KLowerAscii(ascii_key);
CommandId id;
if (KLookupEscCommand(ascii_key, id)) {
LOGF("[QtIH] ESC/Meta: mapped '%d' -> id=%d\n", ascii_key, (int)id);
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, id, std::string(), 0});
return true;
} else {
// Report invalid ESC sequence just like ImGui path
LOGF("[QtIH] ESC/Meta: unknown command for ascii=%d\n", ascii_key);
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, CommandId::UnknownEscCommand, std::string(), 0});
return true;
}
}
// Nothing derivable: consume (ESC prefix cleared) and do not insert text
return true;
}
// 6) Control-chord direct mappings (e.g., C-n/C-p/C-f/C-b...)
if (ctrl_like) {
// Universal argument handling: C-u starts collection; C-g cancels
if (e.key() == Qt::Key_U) {
if (ed_)
ed_->UArgStart();
LOGF("[QtIH] Ctrl-chord: start universal argument\n");
return true;
}
if (e.key() == Qt::Key_G) {
if (ed_)
ed_->UArgClear();
k_ctrl_pending_ = false;
k_prefix_ = false;
LOGF("[QtIH] Ctrl-chord: cancel universal argument and k-prefix via C-g\n");
// Fall through to map C-g to Refresh via ctrl map
}
if (e.key() >= Qt::Key_A && e.key() <= Qt::Key_Z) {
int ascii_key = 'a' + (e.key() - Qt::Key_A);
CommandId id;
if (KLookupCtrlCommand(ascii_key, id)) {
LOGF("[QtIH] Ctrl-chord: 'C-%c' -> id=%d\n", (char)ascii_key, (int)id);
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, id, std::string(), 0});
return true;
}
}
// If no mapping, continue to allow other keys below
}
// 7) Special navigation/edit keys (match ImGui behavior)
{
CommandId id;
bool has = false;
switch (e.key()) {
case Qt::Key_Return:
case Qt::Key_Enter:
id = CommandId::Newline;
has = true;
break;
case Qt::Key_Backspace:
id = CommandId::Backspace;
has = true;
break;
case Qt::Key_Delete:
id = CommandId::DeleteChar;
has = true;
break;
case Qt::Key_Left:
id = CommandId::MoveLeft;
has = true;
break;
case Qt::Key_Right:
id = CommandId::MoveRight;
has = true;
break;
case Qt::Key_Up:
id = CommandId::MoveUp;
has = true;
break;
case Qt::Key_Down:
id = CommandId::MoveDown;
has = true;
break;
case Qt::Key_Home:
id = CommandId::MoveHome;
has = true;
break;
case Qt::Key_End:
id = CommandId::MoveEnd;
has = true;
break;
case Qt::Key_PageUp:
id = CommandId::PageUp;
has = true;
break;
case Qt::Key_PageDown:
id = CommandId::PageDown;
has = true;
break;
default:
break;
}
if (has) {
LOGF("[QtIH] Special key -> id=%d\n", (int)id);
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, id, std::string(), 0});
return true;
}
}
// 8) Insert printable text
if (IsPrintableQt(e)) {
std::string s = e.text().toStdString();
if (!s.empty()) {
LOGF("[QtIH] InsertText '%s'\n", s.c_str());
std::lock_guard<std::mutex> lk(mu_);
q_.push(MappedInput{true, CommandId::InsertText, s, 0});
return true;
}
}
LOGF("[QtIH] Unhandled key\n");
return false;
}
bool
QtInputHandler::Poll(MappedInput &out)
{
std::lock_guard<std::mutex> lock(mu_);
if (q_.empty())
return false;
out = q_.front();
q_.pop();
return true;
}

40
QtInputHandler.h Normal file
View File

@@ -0,0 +1,40 @@
/*
* QtInputHandler - Qt-based input mapping for GUI mode
*/
#pragma once
#include <mutex>
#include <queue>
#include "InputHandler.h"
class QKeyEvent;
class QtInputHandler final : public InputHandler {
public:
QtInputHandler() = default;
~QtInputHandler() override = default;
void Attach(Editor *ed) override
{
ed_ = ed;
}
// Translate a Qt key event to editor command and enqueue if applicable.
// Returns true if it produced a mapped command or consumed input.
bool ProcessKeyEvent(const QKeyEvent &e);
bool Poll(MappedInput &out) override;
private:
std::mutex mu_;
std::queue<MappedInput> q_;
bool k_prefix_ = false;
bool k_ctrl_pending_ = false; // C-k C-… qualifier
bool esc_meta_ = false; // ESC-prefix for next key
bool suppress_text_input_once_ = false; // reserved (Qt sends text in keyPressEvent)
Editor *ed_ = nullptr;
};

76
QtRenderer.cc Normal file
View File

@@ -0,0 +1,76 @@
#include "QtRenderer.h"
#include <QWidget>
#include <QPainter>
#include <QPaintEvent>
#include <QFont>
#include <QFontMetrics>
#include "Editor.h"
namespace {
class EditorWidget : public QWidget {
public:
explicit EditorWidget(QWidget *parent = nullptr) : QWidget(parent)
{
setAttribute(Qt::WA_OpaquePaintEvent);
setFocusPolicy(Qt::StrongFocus);
}
void SetEditor(Editor *ed)
{
ed_ = ed;
}
protected:
void paintEvent(QPaintEvent *event) override
{
Q_UNUSED(event);
QPainter p(this);
// Background
const QColor bg(28, 28, 30);
p.fillRect(rect(), bg);
// Font and metrics
QFont f("JetBrains Mono", 13);
p.setFont(f);
QFontMetrics fm(f);
const int line_h = fm.height();
// Title
p.setPen(QColor(220, 220, 220));
p.drawText(8, fm.ascent() + 4, QStringLiteral("kte (Qt frontend)"));
// Status bar at bottom
const int bar_h = line_h + 6; // padding
const int bar_y = height() - bar_h;
QRect status_rect(0, bar_y, width(), bar_h);
p.fillRect(status_rect, QColor(40, 40, 44));
p.setPen(QColor(180, 180, 140));
if (ed_) {
const QString status = QString::fromStdString(ed_->Status());
// draw at baseline within the bar
const int baseline = bar_y + 3 + fm.ascent();
p.drawText(8, baseline, status);
}
}
private:
Editor *ed_ = nullptr;
};
} // namespace
void
QtRenderer::Draw(Editor &ed)
{
if (!widget_)
return;
// If our widget is an EditorWidget, pass the editor pointer for painting
if (auto *ew = dynamic_cast<EditorWidget *>(widget_)) {
ew->SetEditor(&ed);
}
// Request a repaint
widget_->update();
}

27
QtRenderer.h Normal file
View File

@@ -0,0 +1,27 @@
/*
* QtRenderer - minimal Qt-based renderer
*/
#pragma once
#include "Renderer.h"
class QWidget;
class QtRenderer final : public Renderer {
public:
QtRenderer() = default;
~QtRenderer() override = default;
void Attach(QWidget *widget)
{
widget_ = widget;
}
void Draw(Editor &ed) override;
private:
QWidget *widget_ = nullptr; // not owned
};

View File

@@ -8,5 +8,6 @@ ROADMAP / TODO:
- [x] When the filename is longer than the message window, scoot left to
keep it in view
- [x] Syntax highlighting
- [ ] Swap files (crash recovery). See `docs/plans/swap-files.md`
- [ ] The undo system should actually work
- [ ] LSP integration

434
Swap.cc Normal file
View File

@@ -0,0 +1,434 @@
#include "Swap.h"
#include "Buffer.h"
#include <algorithm>
#include <chrono>
#include <cstdio>
#include <cstring>
#include <filesystem>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
#include <cerrno>
namespace fs = std::filesystem;
namespace kte {
namespace {
constexpr std::uint8_t MAGIC[8] = {'K', 'T', 'E', '_', 'S', 'W', 'P', '\0'};
constexpr std::uint32_t VERSION = 1;
// Write all bytes in buf to fd, handling EINTR and partial writes.
static bool write_full(int fd, const void *buf, size_t len)
{
const std::uint8_t *p = static_cast<const std::uint8_t *>(buf);
while (len > 0) {
ssize_t n = ::write(fd, p, len);
if (n < 0) {
if (errno == EINTR)
continue;
return false;
}
if (n == 0)
return false; // shouldn't happen for regular files; treat as error
p += static_cast<size_t>(n);
len -= static_cast<size_t>(n);
}
return true;
}
}
SwapManager::SwapManager()
{
running_.store(true);
worker_ = std::thread([this] {
this->writer_loop();
});
}
SwapManager::~SwapManager()
{
running_.store(false);
cv_.notify_all();
if (worker_.joinable())
worker_.join();
// Close all journals
for (auto &kv: journals_) {
close_ctx(kv.second);
}
}
void
SwapManager::Attach(Buffer * /*buf*/)
{
// Stage 1: lazy-open on first record; nothing to do here.
}
void
SwapManager::Detach(Buffer * /*buf*/)
{
// Stage 1: keep files open until manager destruction; future work can close per-buffer.
}
void
SwapManager::NotifyFilenameChanged(Buffer &buf)
{
std::lock_guard<std::mutex> lg(mtx_);
auto it = journals_.find(&buf);
if (it == journals_.end())
return;
JournalCtx &ctx = it->second;
// Close existing file handle, update path; lazily reopen on next write
close_ctx(ctx);
ctx.path = ComputeSidecarPath(buf);
}
void
SwapManager::SetSuspended(Buffer &buf, bool on)
{
std::lock_guard<std::mutex> lg(mtx_);
auto path = ComputeSidecarPath(buf);
// Create/update context for this buffer
JournalCtx &ctx = journals_[&buf];
ctx.path = path;
ctx.suspended = on;
}
SwapManager::SuspendGuard::SuspendGuard(SwapManager &m, Buffer *b)
: m_(m), buf_(b), prev_(false)
{
// Suspend recording while guard is alive
if (buf_)
m_.SetSuspended(*buf_, true);
}
SwapManager::SuspendGuard::~SuspendGuard()
{
if (buf_)
m_.SetSuspended(*buf_, false);
}
std::string
SwapManager::ComputeSidecarPath(const Buffer &buf)
{
if (buf.IsFileBacked() || !buf.Filename().empty()) {
fs::path p(buf.Filename());
fs::path dir = p.parent_path();
std::string base = p.filename().string();
std::string side = "." + base + ".kte.swp";
return (dir / side).string();
}
// unnamed: $TMPDIR/kte/unnamed-<ptr>.kte.swp (best-effort)
const char *tmp = std::getenv("TMPDIR");
fs::path t = tmp ? fs::path(tmp) : fs::temp_directory_path();
fs::path d = t / "kte";
char bufptr[32];
std::snprintf(bufptr, sizeof(bufptr), "%p", (const void *) &buf);
return (d / (std::string("unnamed-") + bufptr + ".kte.swp")).string();
}
std::uint64_t
SwapManager::now_ns()
{
using namespace std::chrono;
return duration_cast<nanoseconds>(steady_clock::now().time_since_epoch()).count();
}
bool
SwapManager::ensure_parent_dir(const std::string &path)
{
try {
fs::path p(path);
fs::path dir = p.parent_path();
if (dir.empty())
return true;
if (!fs::exists(dir))
fs::create_directories(dir);
return true;
} catch (...) {
return false;
}
}
bool
SwapManager::write_header(JournalCtx &ctx)
{
if (ctx.fd < 0)
return false;
// Write a simple 64-byte header
std::uint8_t hdr[64];
std::memset(hdr, 0, sizeof(hdr));
std::memcpy(hdr, MAGIC, 8);
std::uint32_t ver = VERSION;
std::memcpy(hdr + 8, &ver, sizeof(ver));
std::uint64_t ts = static_cast<std::uint64_t>(std::time(nullptr));
std::memcpy(hdr + 16, &ts, sizeof(ts));
ssize_t w = ::write(ctx.fd, hdr, sizeof(hdr));
return (w == (ssize_t) sizeof(hdr));
}
bool
SwapManager::open_ctx(JournalCtx &ctx)
{
if (ctx.fd >= 0)
return true;
if (!ensure_parent_dir(ctx.path))
return false;
// Create or open with 0600 perms
int fd = ::open(ctx.path.c_str(), O_CREAT | O_RDWR, 0600);
if (fd < 0)
return false;
// Detect if file is new/empty to write header
struct stat st{};
if (fstat(fd, &st) != 0) {
::close(fd);
return false;
}
ctx.fd = fd;
ctx.file = fdopen(fd, "ab");
if (!ctx.file) {
::close(fd);
ctx.fd = -1;
return false;
}
if (st.st_size == 0) {
ctx.header_ok = write_header(ctx);
} else {
ctx.header_ok = true; // trust existing file for stage 1
// Seek to end to append
::lseek(ctx.fd, 0, SEEK_END);
}
return ctx.header_ok;
}
void
SwapManager::close_ctx(JournalCtx &ctx)
{
if (ctx.file) {
std::fflush((FILE *) ctx.file);
::fsync(ctx.fd);
std::fclose((FILE *) ctx.file);
ctx.file = nullptr;
}
if (ctx.fd >= 0) {
::close(ctx.fd);
ctx.fd = -1;
}
}
std::uint32_t
SwapManager::crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed)
{
static std::uint32_t table[256];
static bool inited = false;
if (!inited) {
for (std::uint32_t i = 0; i < 256; ++i) {
std::uint32_t c = i;
for (int j = 0; j < 8; ++j)
c = (c & 1) ? (0xEDB88320u ^ (c >> 1)) : (c >> 1);
table[i] = c;
}
inited = true;
}
std::uint32_t c = ~seed;
for (std::size_t i = 0; i < len; ++i)
c = table[(c ^ data[i]) & 0xFFu] ^ (c >> 8);
return ~c;
}
void
SwapManager::put_varu64(std::vector<std::uint8_t> &out, std::uint64_t v)
{
while (v >= 0x80) {
out.push_back(static_cast<std::uint8_t>(v) | 0x80);
v >>= 7;
}
out.push_back(static_cast<std::uint8_t>(v));
}
void
SwapManager::put_u24(std::uint8_t dst[3], std::uint32_t v)
{
dst[0] = static_cast<std::uint8_t>((v >> 16) & 0xFF);
dst[1] = static_cast<std::uint8_t>((v >> 8) & 0xFF);
dst[2] = static_cast<std::uint8_t>(v & 0xFF);
}
void
SwapManager::enqueue(Pending &&p)
{
{
std::lock_guard<std::mutex> lg(mtx_);
queue_.emplace_back(std::move(p));
}
cv_.notify_one();
}
void
SwapManager::RecordInsert(Buffer &buf, int row, int col, std::string_view text)
{
{
std::lock_guard<std::mutex> lg(mtx_);
if (journals_[&buf].suspended)
return;
}
Pending p;
p.buf = &buf;
p.type = SwapRecType::INS;
// payload: varint row, varint col, varint len, bytes
put_varu64(p.payload, static_cast<std::uint64_t>(std::max(0, row)));
put_varu64(p.payload, static_cast<std::uint64_t>(std::max(0, col)));
put_varu64(p.payload, static_cast<std::uint64_t>(text.size()));
p.payload.insert(p.payload.end(), reinterpret_cast<const std::uint8_t *>(text.data()),
reinterpret_cast<const std::uint8_t *>(text.data()) + text.size());
enqueue(std::move(p));
}
void
SwapManager::RecordDelete(Buffer &buf, int row, int col, std::size_t len)
{
{
std::lock_guard<std::mutex> lg(mtx_);
if (journals_[&buf].suspended)
return;
}
Pending p;
p.buf = &buf;
p.type = SwapRecType::DEL;
put_varu64(p.payload, static_cast<std::uint64_t>(std::max(0, row)));
put_varu64(p.payload, static_cast<std::uint64_t>(std::max(0, col)));
put_varu64(p.payload, static_cast<std::uint64_t>(len));
enqueue(std::move(p));
}
void
SwapManager::RecordSplit(Buffer &buf, int row, int col)
{
{
std::lock_guard<std::mutex> lg(mtx_);
if (journals_[&buf].suspended)
return;
}
Pending p;
p.buf = &buf;
p.type = SwapRecType::SPLIT;
put_varu64(p.payload, static_cast<std::uint64_t>(std::max(0, row)));
put_varu64(p.payload, static_cast<std::uint64_t>(std::max(0, col)));
enqueue(std::move(p));
}
void
SwapManager::RecordJoin(Buffer &buf, int row)
{
{
std::lock_guard<std::mutex> lg(mtx_);
if (journals_[&buf].suspended)
return;
}
Pending p;
p.buf = &buf;
p.type = SwapRecType::JOIN;
put_varu64(p.payload, static_cast<std::uint64_t>(std::max(0, row)));
enqueue(std::move(p));
}
void
SwapManager::writer_loop()
{
while (running_.load()) {
std::vector<Pending> batch;
{
std::unique_lock<std::mutex> lk(mtx_);
if (queue_.empty()) {
cv_.wait_for(lk, std::chrono::milliseconds(cfg_.flush_interval_ms));
}
if (!queue_.empty()) {
batch.swap(queue_);
}
}
if (batch.empty())
continue;
// Group by buffer path to minimize fsyncs
for (const Pending &p: batch) {
process_one(p);
}
// Throttled fsync: best-effort
// Iterate unique contexts and fsync if needed
// For stage 1, fsync all once per interval
std::uint64_t now = now_ns();
for (auto &kv: journals_) {
JournalCtx &ctx = kv.second;
if (ctx.fd >= 0) {
if (ctx.last_fsync_ns == 0 || (now - ctx.last_fsync_ns) / 1000000ULL >= cfg_.
fsync_interval_ms) {
::fsync(ctx.fd);
ctx.last_fsync_ns = now;
}
}
}
}
}
void
SwapManager::process_one(const Pending &p)
{
Buffer &buf = *p.buf;
// Resolve context by path derived from buffer
std::string path = ComputeSidecarPath(buf);
// Get or create context keyed by this buffer pointer (stage 1 simplification)
JournalCtx &ctx = journals_[p.buf];
if (ctx.path.empty())
ctx.path = path;
if (!open_ctx(ctx))
return;
// Build record: [type u8][len u24][payload][crc32 u32]
std::uint8_t len3[3];
put_u24(len3, static_cast<std::uint32_t>(p.payload.size()));
std::uint8_t head[4];
head[0] = static_cast<std::uint8_t>(p.type);
head[1] = len3[0];
head[2] = len3[1];
head[3] = len3[2];
std::uint32_t c = 0;
c = crc32(head, sizeof(head), c);
if (!p.payload.empty())
c = crc32(p.payload.data(), p.payload.size(), c);
// Write (handle partial writes and check results)
bool ok = write_full(ctx.fd, head, sizeof(head));
if (ok && !p.payload.empty())
ok = write_full(ctx.fd, p.payload.data(), p.payload.size());
if (ok)
ok = write_full(ctx.fd, &c, sizeof(c));
(void) ok; // stage 1: best-effort; future work could mark ctx error state
}
} // namespace kte

145
Swap.h Normal file
View File

@@ -0,0 +1,145 @@
// Swap.h - swap journal (crash recovery) writer/manager for kte
#pragma once
#include <cstdint>
#include <cstddef>
#include <string>
#include <string_view>
#include <vector>
#include <unordered_map>
#include <mutex>
#include <condition_variable>
#include <thread>
#include <atomic>
class Buffer;
namespace kte {
// Minimal record types for stage 1
enum class SwapRecType : std::uint8_t {
INS = 1,
DEL = 2,
SPLIT = 3,
JOIN = 4,
META = 0xF0,
CHKPT = 0xFE,
};
struct SwapConfig {
// Grouping and durability knobs (stage 1 defaults)
unsigned flush_interval_ms{200}; // group small writes
unsigned fsync_interval_ms{1000}; // at most once per second
};
// Lightweight interface that Buffer can call without depending on full manager impl
class SwapRecorder {
public:
virtual ~SwapRecorder() = default;
virtual void RecordInsert(Buffer &buf, int row, int col, std::string_view text) = 0;
virtual void RecordDelete(Buffer &buf, int row, int col, std::size_t len) = 0;
virtual void RecordSplit(Buffer &buf, int row, int col) = 0;
virtual void RecordJoin(Buffer &buf, int row) = 0;
virtual void NotifyFilenameChanged(Buffer &buf) = 0;
virtual void SetSuspended(Buffer &buf, bool on) = 0;
};
// SwapManager manages sidecar swap files and a single background writer thread.
class SwapManager final : public SwapRecorder {
public:
SwapManager();
~SwapManager() override;
// Attach a buffer to begin journaling. Safe to call multiple times; idempotent.
void Attach(Buffer *buf);
// Detach and close journal.
void Detach(Buffer *buf);
// SwapRecorder: Notify that the buffer's filename changed (e.g., SaveAs)
void NotifyFilenameChanged(Buffer &buf) override;
// SwapRecorder
void RecordInsert(Buffer &buf, int row, int col, std::string_view text) override;
void RecordDelete(Buffer &buf, int row, int col, std::size_t len) override;
void RecordSplit(Buffer &buf, int row, int col) override;
void RecordJoin(Buffer &buf, int row) override;
// RAII guard to suspend recording for internal operations
class SuspendGuard {
public:
SuspendGuard(SwapManager &m, Buffer *b);
~SuspendGuard();
private:
SwapManager &m_;
Buffer *buf_;
bool prev_;
};
// Per-buffer toggle
void SetSuspended(Buffer &buf, bool on) override;
private:
struct JournalCtx {
std::string path;
void *file{nullptr}; // FILE*
int fd{-1};
bool header_ok{false};
bool suspended{false};
std::uint64_t last_flush_ns{0};
std::uint64_t last_fsync_ns{0};
};
struct Pending {
Buffer *buf{nullptr};
SwapRecType type{SwapRecType::INS};
std::vector<std::uint8_t> payload; // framed payload only
bool urgent_flush{false};
};
// Helpers
static std::string ComputeSidecarPath(const Buffer &buf);
static std::uint64_t now_ns();
static bool ensure_parent_dir(const std::string &path);
static bool write_header(JournalCtx &ctx);
static bool open_ctx(JournalCtx &ctx);
static void close_ctx(JournalCtx &ctx);
static std::uint32_t crc32(const std::uint8_t *data, std::size_t len, std::uint32_t seed = 0);
static void put_varu64(std::vector<std::uint8_t> &out, std::uint64_t v);
static void put_u24(std::uint8_t dst[3], std::uint32_t v);
void enqueue(Pending &&p);
void writer_loop();
void process_one(const Pending &p);
// State
SwapConfig cfg_{};
std::unordered_map<Buffer *, JournalCtx> journals_;
std::mutex mtx_;
std::condition_variable cv_;
std::vector<Pending> queue_;
std::atomic<bool> running_{false};
std::thread worker_;
};
} // namespace kte

View File

@@ -55,6 +55,8 @@ TerminalFrontend::Init(Editor &ed)
prev_r_ = r;
prev_c_ = c;
ed.SetDimensions(static_cast<std::size_t>(r), static_cast<std::size_t>(c));
// Attach editor to input handler for editor-owned features (e.g., universal argument)
input_.Attach(&ed);
return true;
}
@@ -100,4 +102,4 @@ TerminalFrontend::Shutdown()
have_orig_tio_ = false;
}
endwin();
}
}

View File

@@ -3,6 +3,7 @@
#include "TerminalInputHandler.h"
#include "KKeymap.h"
#include "Editor.h"
namespace {
constexpr int
@@ -21,96 +22,103 @@ static bool
map_key_to_command(const int ch,
bool &k_prefix,
bool &esc_meta,
// universal-argument state (by ref)
bool &uarg_active,
bool &uarg_collecting,
bool &uarg_negative,
bool &uarg_had_digits,
int &uarg_value,
std::string &uarg_text,
bool &k_ctrl_pending,
Editor *ed,
MappedInput &out)
{
// Handle special keys from ncurses
// These keys exit k-prefix mode if active (user pressed C-k then a special key).
switch (ch) {
case KEY_MOUSE: {
k_prefix = false;
MEVENT ev{};
if (getmouse(&ev) == OK) {
// Mouse wheel → scroll viewport without moving cursor
case KEY_MOUSE: {
k_prefix = false;
k_ctrl_pending = false;
MEVENT ev{};
if (getmouse(&ev) == OK) {
// Mouse wheel → scroll viewport without moving cursor
#ifdef BUTTON4_PRESSED
if (ev.bstate & (BUTTON4_PRESSED | BUTTON4_RELEASED | BUTTON4_CLICKED)) {
out = {true, CommandId::ScrollUp, "", 0};
return true;
}
if (ev.bstate & (BUTTON4_PRESSED | BUTTON4_RELEASED | BUTTON4_CLICKED)) {
out = {true, CommandId::ScrollUp, "", 0};
return true;
}
#endif
#ifdef BUTTON5_PRESSED
if (ev.bstate & (BUTTON5_PRESSED | BUTTON5_RELEASED | BUTTON5_CLICKED)) {
out = {true, CommandId::ScrollDown, "", 0};
return true;
}
if (ev.bstate & (BUTTON5_PRESSED | BUTTON5_RELEASED | BUTTON5_CLICKED)) {
out = {true, CommandId::ScrollDown, "", 0};
return true;
}
#endif
// React to left button click/press
if (ev.bstate & (BUTTON1_CLICKED | BUTTON1_PRESSED | BUTTON1_RELEASED)) {
char buf[64];
// Use screen coordinates; command handler will translate via offsets
std::snprintf(buf, sizeof(buf), "@%d:%d", ev.y, ev.x);
out = {true, CommandId::MoveCursorTo, std::string(buf), 0};
return true;
// React to left button click/press
if (ev.bstate & (BUTTON1_CLICKED | BUTTON1_PRESSED | BUTTON1_RELEASED)) {
char buf[64];
// Use screen coordinates; command handler will translate via offsets
std::snprintf(buf, sizeof(buf), "@%d:%d", ev.y, ev.x);
out = {true, CommandId::MoveCursorTo, std::string(buf), 0};
return true;
}
}
// No actionable mouse event
out.hasCommand = false;
return true;
}
// No actionable mouse event
out.hasCommand = false;
return true;
}
case KEY_LEFT:
k_prefix = false;
out = {true, CommandId::MoveLeft, "", 0};
return true;
case KEY_RIGHT:
k_prefix = false;
out = {true, CommandId::MoveRight, "", 0};
return true;
case KEY_UP:
k_prefix = false;
out = {true, CommandId::MoveUp, "", 0};
return true;
case KEY_DOWN:
k_prefix = false;
out = {true, CommandId::MoveDown, "", 0};
return true;
case KEY_HOME:
k_prefix = false;
out = {true, CommandId::MoveHome, "", 0};
return true;
case KEY_END:
k_prefix = false;
out = {true, CommandId::MoveEnd, "", 0};
return true;
case KEY_PPAGE:
k_prefix = false;
out = {true, CommandId::PageUp, "", 0};
return true;
case KEY_NPAGE:
k_prefix = false;
out = {true, CommandId::PageDown, "", 0};
return true;
case KEY_DC:
k_prefix = false;
out = {true, CommandId::DeleteChar, "", 0};
return true;
case KEY_RESIZE:
k_prefix = false;
out = {true, CommandId::Refresh, "", 0};
return true;
default:
break;
case KEY_LEFT:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveLeft, "", 0};
return true;
case KEY_RIGHT:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveRight, "", 0};
return true;
case KEY_UP:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveUp, "", 0};
return true;
case KEY_DOWN:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveDown, "", 0};
return true;
case KEY_HOME:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveHome, "", 0};
return true;
case KEY_END:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::MoveEnd, "", 0};
return true;
case KEY_PPAGE:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::PageUp, "", 0};
return true;
case KEY_NPAGE:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::PageDown, "", 0};
return true;
case KEY_DC:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::DeleteChar, "", 0};
return true;
case KEY_RESIZE:
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::Refresh, "", 0};
return true;
default:
break;
}
// ESC as cancel of prefix; many terminals send meta sequences as ESC+...
if (ch == 27) {
// ESC
k_prefix = false;
k_ctrl_pending = false;
esc_meta = true; // next key will be considered meta-modified
out.hasCommand = false; // no command yet
return true;
@@ -119,59 +127,33 @@ map_key_to_command(const int ch,
// Control keys
if (ch == CTRL('K')) {
// C-k prefix
k_prefix = true;
out = {true, CommandId::KPrefix, "", 0};
k_prefix = true;
k_ctrl_pending = false;
out = {true, CommandId::KPrefix, "", 0};
return true;
}
if (ch == CTRL('G')) {
// cancel
k_prefix = false;
esc_meta = false;
k_prefix = false;
k_ctrl_pending = false;
esc_meta = false;
// cancel universal argument as well
uarg_active = false;
uarg_collecting = false;
uarg_negative = false;
uarg_had_digits = false;
uarg_value = 0;
uarg_text.clear();
if (ed)
ed->UArgClear();
out = {true, CommandId::Refresh, "", 0};
return true;
}
// Universal argument: C-u
if (ch == CTRL('U')) {
// Start or extend universal argument
if (!uarg_active) {
uarg_active = true;
uarg_collecting = true;
uarg_negative = false;
uarg_had_digits = false;
uarg_value = 4; // default
// Reset collected text and emit status update
uarg_text.clear();
out = {true, CommandId::UArgStatus, uarg_text, 0};
return true;
} else if (uarg_collecting && !uarg_had_digits && !uarg_negative) {
// Bare repeated C-u multiplies by 4
if (uarg_value <= 0)
uarg_value = 4;
else
uarg_value *= 4;
// Keep showing status (no digits yet)
out = {true, CommandId::UArgStatus, uarg_text, 0};
return true;
} else {
// If digits or '-' have been entered, C-u ends the argument (ready for next command)
uarg_collecting = false;
if (!uarg_had_digits && !uarg_negative && uarg_value <= 0)
uarg_value = 4;
}
// No command produced by C-u itself
out.hasCommand = false;
if (ed)
ed->UArgStart();
out.hasCommand = false; // C-u itself doesn't issue a command
return true;
}
// Tab (note: terminals encode Tab and C-i as the same code 9)
if (ch == '\t') {
k_prefix = false;
k_ctrl_pending = false;
out.hasCommand = true;
out.id = CommandId::InsertText;
out.arg = "\t";
@@ -182,22 +164,39 @@ map_key_to_command(const int ch,
// IMPORTANT: if we're in k-prefix, the very next key must be interpreted
// via the C-k keymap first, even if it's a Control chord like C-d.
if (k_prefix) {
k_prefix = false; // consume the prefix for this one key
// In k-prefix: allow a control qualifier via literal 'C' or '^'
// Detect Control keycodes first
bool ctrl = false;
int ascii_key = ch;
if (ch >= 1 && ch <= 26) {
ctrl = true;
ascii_key = 'a' + (ch - 1);
}
// If user typed literal 'C'/'c' or '^' as a qualifier, keep k-prefix and set pending
if (ascii_key == 'C' || ascii_key == 'c' || ascii_key == '^') {
k_ctrl_pending = true;
if (ed)
ed->SetStatus("C-k C _");
out.hasCommand = false;
return true;
}
// For actual suffix, consume the k-prefix
k_prefix = false;
// Do NOT lowercase here; KLookupKCommand handles case-sensitive bindings
CommandId id;
if (KLookupKCommand(ascii_key, ctrl, id)) {
bool pass_ctrl = (ctrl || k_ctrl_pending);
k_ctrl_pending = false;
if (KLookupKCommand(ascii_key, pass_ctrl, id)) {
out = {true, id, "", 0};
if (ed)
ed->SetStatus(""); // clear "C-k _" hint after suffix
} else {
int shown = KLowerAscii(ascii_key);
char c = (shown >= 0x20 && shown <= 0x7e) ? static_cast<char>(shown) : '?';
std::string arg(1, c);
out = {true, CommandId::UnknownKCommand, arg, 0};
if (ed)
ed->SetStatus(""); // clear hint; handler will set unknown status
}
return true;
}
@@ -213,8 +212,9 @@ map_key_to_command(const int ch,
// Enter
if (ch == '\n' || ch == '\r') {
k_prefix = false;
out = {true, CommandId::Newline, "", 0};
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::Newline, "", 0};
return true;
}
// If previous key was ESC, interpret as meta and use ESC keymap
@@ -224,6 +224,12 @@ map_key_to_command(const int ch,
// Handle ESC + BACKSPACE (meta-backspace, Alt-Backspace)
if (ch == KEY_BACKSPACE || ch == 127 || ch == CTRL('H')) {
ascii_key = KEY_BACKSPACE; // normalized value for lookup
} else if (ch == ',') {
// Some terminals emit ',' when Shift state is lost after ESC; treat as '<'
ascii_key = '<';
} else if (ch == '.') {
// Likewise, map '.' to '>'
ascii_key = '>';
} else if (ascii_key >= 'A' && ascii_key <= 'Z') {
ascii_key = ascii_key - 'A' + 'a';
}
@@ -232,48 +238,26 @@ map_key_to_command(const int ch,
out = {true, id, "", 0};
return true;
}
// Unhandled meta key: no command
out.hasCommand = false;
// Unhandled ESC sequence: exit escape mode and show status
out = {true, CommandId::UnknownEscCommand, "", 0};
return true;
}
// Backspace in ncurses can be KEY_BACKSPACE or 127
if (ch == KEY_BACKSPACE || ch == 127 || ch == CTRL('H')) {
k_prefix = false;
out = {true, CommandId::Backspace, "", 0};
k_prefix = false;
k_ctrl_pending = false;
out = {true, CommandId::Backspace, "", 0};
return true;
}
// k_prefix handled earlier
// If collecting universal arg, handle digits and optional leading '-'
if (uarg_active && uarg_collecting) {
if (ch >= '0' && ch <= '9') {
int d = ch - '0';
if (!uarg_had_digits) {
// First digit overrides any 4^n default
uarg_value = 0;
uarg_had_digits = true;
}
if (uarg_value < 100000000) {
// avoid overflow
uarg_value = uarg_value * 10 + d;
}
// Update raw text and status to reflect collected digits
uarg_text.push_back(static_cast<char>(ch));
out = {true, CommandId::UArgStatus, uarg_text, 0};
return true;
}
if (ch == '-' && !uarg_had_digits && !uarg_negative) {
uarg_negative = true;
// Show leading minus in status
uarg_text = "-";
out = {true, CommandId::UArgStatus, uarg_text, 0};
return true;
}
// Any other key will be processed as a command; fall through to mapping below
// but mark collection finished so we apply the argument to that command
uarg_collecting = false;
// If universal argument is active at editor level and we get a digit, feed it
if (ed && ed->UArg() != 0 && ch >= '0' && ch <= '9') {
ed->UArgDigit(ch - '0');
out.hasCommand = false; // keep collecting, no command yet
return true;
}
// Printable ASCII
@@ -300,29 +284,11 @@ TerminalInputHandler::decode_(MappedInput &out)
bool consumed = map_key_to_command(
ch,
k_prefix_, esc_meta_,
uarg_active_, uarg_collecting_, uarg_negative_, uarg_had_digits_, uarg_value_, uarg_text_,
k_ctrl_pending_,
ed_,
out);
if (!consumed)
return false;
// If a command was produced and a universal argument is active, attach it and clear state
if (out.hasCommand && uarg_active_ && out.id != CommandId::UArgStatus) {
int count = 0;
if (!uarg_had_digits_ && !uarg_negative_) {
// No explicit digits: use current value (default 4 or 4^n)
count = (uarg_value_ > 0) ? uarg_value_ : 4;
} else {
count = uarg_value_;
if (uarg_negative_)
count = -count;
}
out.count = count;
// Clear state
uarg_active_ = false;
uarg_collecting_ = false;
uarg_negative_ = false;
uarg_had_digits_ = false;
uarg_value_ = 0;
}
return true;
}

View File

@@ -11,6 +11,13 @@ public:
~TerminalInputHandler() override;
void Attach(Editor *ed) override
{
ed_ = ed;
}
bool Poll(MappedInput &out) override;
private:
@@ -18,14 +25,10 @@ private:
// ke-style prefix state
bool k_prefix_ = false; // true after C-k until next key or ESC
// Optional control qualifier inside k-prefix (e.g., user typed literal 'C' or '^')
bool k_ctrl_pending_ = false;
// Simple meta (ESC) state for ESC sequences like ESC b/f
bool esc_meta_ = false;
// Universal argument (C-u) state
bool uarg_active_ = false; // an argument is pending for the next command
bool uarg_collecting_ = false; // collecting digits / '-' right now
bool uarg_negative_ = false; // whether a leading '-' was supplied
bool uarg_had_digits_ = false; // whether any digits were supplied
int uarg_value_ = 0; // current absolute value (>=0)
std::string uarg_text_; // raw digits/minus typed for status display
Editor *ed_ = nullptr; // attached editor for uarg handling
};

View File

@@ -111,19 +111,44 @@ TerminalRenderer::Draw(Editor &ed)
std::string line = static_cast<std::string>(lines[li]);
src_i = 0;
render_col = 0;
// Syntax highlighting: fetch per-line spans
const kte::LineHighlight *lh_ptr = nullptr;
// Syntax highlighting: fetch per-line spans (sanitized copy)
std::vector<kte::HighlightSpan> sane_spans;
if (buf->SyntaxEnabled() && buf->Highlighter() && buf->Highlighter()->
HasHighlighter()) {
lh_ptr = &buf->Highlighter()->GetLine(
kte::LineHighlight lh_val = buf->Highlighter()->GetLine(
*buf, static_cast<int>(li), buf->Version());
// Sanitize defensively: clamp to [0, line.size()], ensure end>=start, drop empties
const std::size_t line_len = line.size();
sane_spans.reserve(lh_val.spans.size());
for (const auto &sp: lh_val.spans) {
int s_raw = sp.col_start;
int e_raw = sp.col_end;
if (e_raw < s_raw)
std::swap(e_raw, s_raw);
std::size_t s = static_cast<std::size_t>(std::max(
0, std::min(s_raw, static_cast<int>(line_len))));
std::size_t e = static_cast<std::size_t>(std::max(
static_cast<int>(s),
std::min(e_raw, static_cast<int>(line_len))));
if (e <= s)
continue;
sane_spans.push_back(kte::HighlightSpan{
static_cast<int>(s), static_cast<int>(e), sp.kind
});
}
std::sort(sane_spans.begin(), sane_spans.end(),
[](const kte::HighlightSpan &a, const kte::HighlightSpan &b) {
return a.col_start < b.col_start;
});
}
auto token_at = [&](std::size_t src_index) -> kte::TokenKind {
if (!lh_ptr)
if (sane_spans.empty())
return kte::TokenKind::Default;
for (const auto &sp: lh_ptr->spans) {
if (static_cast<int>(src_index) >= sp.col_start && static_cast<int>(
src_index) < sp.col_end)
int si = static_cast<int>(src_index);
for (const auto &sp: sane_spans) {
if (si < sp.col_start)
break;
if (si >= sp.col_start && si < sp.col_end)
return sp.kind;
}
return kte::TokenKind::Default;
@@ -132,23 +157,23 @@ TerminalRenderer::Draw(Editor &ed)
// Map to simple attributes; search highlight uses A_STANDOUT which takes precedence below
attrset(A_NORMAL);
switch (k) {
case kte::TokenKind::Keyword:
case kte::TokenKind::Type:
case kte::TokenKind::Constant:
case kte::TokenKind::Function:
attron(A_BOLD);
break;
case kte::TokenKind::Comment:
attron(A_DIM);
break;
case kte::TokenKind::String:
case kte::TokenKind::Char:
case kte::TokenKind::Number:
// standout a bit using A_UNDERLINE if available
attron(A_UNDERLINE);
break;
default:
break;
case kte::TokenKind::Keyword:
case kte::TokenKind::Type:
case kte::TokenKind::Constant:
case kte::TokenKind::Function:
attron(A_BOLD);
break;
case kte::TokenKind::Comment:
attron(A_DIM);
break;
case kte::TokenKind::String:
case kte::TokenKind::Char:
case kte::TokenKind::Number:
// standout a bit using A_UNDERLINE if available
attron(A_UNDERLINE);
break;
default:
break;
}
};
while (written < cols) {
@@ -269,11 +294,31 @@ TerminalRenderer::Draw(Editor &ed)
clrtoeol();
}
// Place terminal cursor at logical position accounting for tabs and coloffs
// Place terminal cursor at logical position accounting for tabs and coloffs.
// Recompute the rendered X using the same logic as the drawing loop to avoid
// any drift between the command-layer computation and the terminal renderer.
std::size_t cy = buf->Cury();
std::size_t rx = buf->Rx(); // render x computed by command layer
int cur_y = static_cast<int>(cy) - static_cast<int>(buf->Rowoffs());
int cur_x = static_cast<int>(rx) - static_cast<int>(buf->Coloffs());
std::size_t cx = buf->Curx();
int cur_y = static_cast<int>(cy) - static_cast<int>(buf->Rowoffs());
std::size_t rx_recomputed = 0;
if (cy < lines.size()) {
const std::string line_for_cursor = static_cast<std::string>(lines[cy]);
std::size_t src_i_cur = 0;
std::size_t render_col_cur = 0;
while (src_i_cur < line_for_cursor.size() && src_i_cur < cx) {
unsigned char ccur = static_cast<unsigned char>(line_for_cursor[src_i_cur]);
if (ccur == '\t') {
std::size_t next_tab = tabw - (render_col_cur % tabw);
render_col_cur += next_tab;
++src_i_cur;
} else {
++render_col_cur;
++src_i_cur;
}
}
rx_recomputed = render_col_cur;
}
int cur_x = static_cast<int>(rx_recomputed) - static_cast<int>(buf->Coloffs());
if (cur_y >= 0 && cur_y < content_rows && cur_x >= 0 && cur_x < cols) {
// remember where to leave the terminal cursor after status is drawn
saved_cur_y = cur_y;

View File

@@ -24,5 +24,8 @@
<string>10.13</string>
<key>NSHighResolutionCapable</key>
<true/>
<!-- Allow running multiple instances of the app -->
<key>LSMultipleInstancesProhibited</key>
<false/>
</dict>
</plist>

View File

@@ -9,6 +9,7 @@
installShellFiles,
graphical ? false,
graphical-qt ? false,
...
}:
let
@@ -34,10 +35,15 @@ stdenv.mkDerivation {
SDL2
libGL
xorg.libX11
]
++ lib.optionals graphical-qt [
qt5Full
qtcreator ## not sure if this is actually needed
];
cmakeFlags = [
"-DBUILD_GUI=${if graphical then "ON" else "OFF"}"
"-DBUILD_GUI=${if graphical or graphical-qt then "ON" else "OFF"}"
"-DKTE_USE_QT=${if graphical-qt then "ON" else "OFF"}"
"-DCMAKE_BUILD_TYPE=Debug"
];

View File

@@ -2,27 +2,43 @@
## Overview
`TestFrontend` is a headless implementation of the `Frontend` interface designed to facilitate programmatic testing of editor features. It allows you to queue commands and text input manually, execute them step-by-step, and inspect the editor/buffer state.
`TestFrontend` is a headless implementation of the `Frontend` interface
designed to facilitate programmatic testing of editor features. It
allows you to queue commands and text input manually, execute them
step-by-step, and inspect the editor/buffer state.
## Components
### TestInputHandler
A programmable input handler that uses a queue-based system:
- `QueueCommand(CommandId id, const std::string &arg = "", int count = 0)` - Queue a specific command
- `QueueText(const std::string &text)` - Queue text for insertion (character by character)
-
`QueueCommand(CommandId id, const std::string &arg = "", int count = 0)` -
Queue a specific command
- `QueueText(const std::string &text)` - Queue text for insertion (
character by character)
- `Poll(MappedInput &out)` - Returns queued commands one at a time
- `IsEmpty()` - Check if the input queue is empty
### TestRenderer
A minimal no-op renderer for testing:
- `Draw(Editor &ed)` - No-op implementation, just increments draw counter
- `Draw(Editor &ed)` - No-op implementation, just increments draw
counter
- `GetDrawCount()` - Returns the number of times Draw() was called
- `ResetDrawCount()` - Resets the draw counter
### TestFrontend
The main frontend class that integrates TestInputHandler and TestRenderer:
- `Init(Editor &ed)` - Initializes the frontend (sets editor dimensions to 24x80)
- `Step(Editor &ed, bool &running)` - Processes one command from the queue and renders
The main frontend class that integrates TestInputHandler and
TestRenderer:
- `Init(Editor &ed)` - Initializes the frontend (sets editor dimensions
to 24x80)
- `Step(Editor &ed, bool &running)` - Processes one command from the
queue and renders
- `Shutdown()` - Cleanup (no-op for TestFrontend)
- `Input()` - Access the TestInputHandler
- `Renderer()` - Access the TestRenderer
@@ -75,31 +91,55 @@ int main() {
## Key Features
1. **Programmable Input**: Queue any sequence of commands or text programmatically
1. **Programmable Input**: Queue any sequence of commands or text
programmatically
2. **Step-by-Step Execution**: Run the editor one command at a time
3. **State Inspection**: Access and verify editor/buffer state between commands
4. **No UI Dependencies**: Headless operation, no terminal or GUI required
5. **Integration Testing**: Test command sequences, undo/redo, multi-line editing, etc.
3. **State Inspection**: Access and verify editor/buffer state between
commands
4. **No UI Dependencies**: Headless operation, no terminal or GUI
required
5. **Integration Testing**: Test command sequences, undo/redo,
multi-line editing, etc.
## Available Commands
All commands from `CommandId` enum can be queued, including:
- `CommandId::InsertText` - Insert text (use `QueueText()` helper)
- `CommandId::Newline` - Insert newline
- `CommandId::Backspace` - Delete character before cursor
- `CommandId::Backspace` - Delete character before cursor
- `CommandId::DeleteChar` - Delete character at cursor
- `CommandId::MoveLeft`, `MoveRight`, `MoveUp`, `MoveDown` - Cursor movement
- `CommandId::MoveLeft`, `MoveRight`, `MoveUp`, `MoveDown` - Cursor
movement
- `CommandId::Undo`, `CommandId::Redo` - Undo/redo operations
- `CommandId::Save`, `CommandId::Quit` - File operations
- And many more (see Command.h)
## Integration
TestFrontend is built into both `kte` and `kge` executables as part of the common source files. You can create standalone test programs by linking against the same source files and ncurses.
TestFrontend is built into both `kte` and `kge` executables as part of
the common source files. You can create standalone test programs by
linking against the same source files and ncurses.
## Notes
- Always call `InstallDefaultCommands()` before using any commands
- Buffer must be initialized (via `OpenFile()` or `AddBuffer()`) before queuing edit commands
- Buffer must be initialized (via `OpenFile()` or `AddBuffer()`) before
queuing edit commands
- Undo/redo requires the buffer to have an UndoSystem attached
- The test frontend sets editor dimensions to 24x80 by default
## Highlighter stress harness
For renderer/highlighter race testing without a UI, `kte` provides a
lightweight stress mode:
```
kte --stress-highlighter=5
```
This runs a short synthetic workload (5 seconds by default) that edits
and scrolls a buffer while
exercising `HighlighterEngine::PrefetchViewport` and `GetLine`
concurrently. Use Debug builds with
AddressSanitizer enabled for best effect.

124
docs/plans/qt-frontend.md Normal file
View File

@@ -0,0 +1,124 @@
Based on the project structure and the presence of files like
`imgui.ini`, `GUIFrontend.h`, and `TerminalFrontend.h`, here is an
analysis of the difficulty and challenges involved in adding a GTK or Qt
version of the GUI.
### **Executive Summary: Difficulty Level - Moderate**
The project is well-architected for this task. It already supports
multiple frontends (Terminal vs. GUI), meaning the "Core Logic" (
Buffers, Syntax, Commands) is successfully decoupled from the "View" (
Rendering/Input). However, the specific move from an **Immediate Mode**
GUI (likely Dear ImGui, implied by `imgui.ini` and standard naming
patterns) to a **Retained Mode** GUI (Qt/GTK) introduces specific
architectural frictions regarding the event loop and state management.
---
### **1. Architectural Analysis**
The existence of abstract interfaces—likely `Frontend`, `Renderer`, and
`InputHandler`—is the biggest asset here.
* **Current State:**
* **Abstract Layer:** `Frontend.h`, `Renderer.h`, `InputHandler.h`
likely define the contract.
* **Implementations:**
* `Terminal*` files implement the TUI (likely ncurses or VT100).
* `GUI*` files (currently ImGui) implement the graphical
version.
* **The Path Forward:**
* You would create `QtFrontend`, `QtRenderer`, `QtInputHandler` (or
GTK equivalents).
* Because the core logic (`Editor.cc`, `Buffer.cc`) calls these
interfaces, you theoretically don't need to touch the core text
manipulation code.
### **2. Key Challenges**
#### **A. The Event Loop Inversion (Main Challenge)**
* **Current (ImGui):** Typically, the application owns the loop:
`while (running) { HandleInput(); Update(); Render(); }`. The
application explicitly tells the GUI to draw every frame.
* **Target (Qt/GTK):** The framework owns the loop: `app.exec()` or
`gtk_main()`. The framework calls *you* when events happen.
* **Difficulty:** You will need to refactor `main.cc` or the entry point
to hand over control to the Qt/GTK application object. The Editor's "
tick" function might need to be connected to a timer or an idle event
in the new framework to ensure logic updates happen.
#### **B. Rendering Paradigm: Canvas vs. Widgets**
* **The "Easy" Way (Custom Canvas):**
* Implement the `QtRenderer` by subclassing `QWidget` and overriding
`paintEvent`.
* Use `QPainter` (or Cairo in GTK) to draw text, cursors, and
selections exactly where the `Renderer` interface says to.
* **Pros:** Keeps the code similar to the current ImGui/Terminal
renderers.
* **Cons:** You lose native accessibility and some native "feel" (
scrolling physics, native text context menus).
* **The "Hard" Way (Native Widgets):**
* Trying to map an internal `Buffer` directly to a `QTextEdit` or
`GtkTextView`.
* **Difficulty:** This is usually very hard because the Editor core
likely manages its own cursor, selection, and syntax highlighting.
Syncing that internal state with a complex native widget often
leads to conflicts.
* **Recommendation:** Stick to the "Custom Canvas" approach (drawing
text manually on a surface) to preserve the custom editor
behavior (vim-like modes, specific syntax highlighting).
#### **C. Input Handling**
* **Challenge:** Mapping Qt/GTK key events to the internal `Keymap`.
* **Detail:** ImGui and Terminal libraries often provide raw scancodes
or simple chars. Qt/GTK provide complex Event objects. You will need a
translation layer in `QtInputHandler::keyPressEvent` that converts
`Qt::Key_Escape` -> `KKey::Escape` (or your internal equivalent).
### **3. Portability of Assets**
#### **Themes (Colors)**
* **Feasibility:** High.
* **Approach:** `GUITheme.h` likely contains structs with RGB/Hex
values. Qt supports stylesheets (QSS) and GTK uses CSS. You can write
a converter that reads your current theme configuration and generates
a CSS string to apply to your window, or simply use the RGB values
directly in your custom `QPainter`/Cairo drawing logic.
#### **Fonts**
* **Feasibility:** Moderate.
* **Approach:**
* **ImGui:** Usually loads a TTF into a texture atlas.
* **Qt/GTK:** Uses the system font engine (Freetype/Pango).
* **Challenge:** You won't use the texture atlas anymore. You will
simply request a font family and size (e.g.,
`QFont("JetBrains Mono", 12)`). You may need to ensure your custom
renderer calculates character width/height metrics correctly using
`QFontMetrics` (Qt) or `PangoLayout` (GTK) to align the grid
correctly.
### **4. Summary Recommendation**
If you proceed, **Qt** is generally considered easier to integrate with
C++ projects than GTK (which is C-based, though `gtkmm` exists).
1. **Create a `QtFrontend`** class inheriting from `Frontend`.
2. **Create a `QtWindow`** class inheriting from `QWidget`.
3. **Implement `QtRenderer`** that holds a pointer to the `QtWindow`.
When the core calls `DrawText()`, `QtRenderer` should queue that
command or draw directly to the widget's paint buffer.
4. **Refactor `main.cc`** to instantiate `QApplication` instead of the
current manual loop.
---
Note (2025-12): The Qt frontend defers all key processing to the
existing command subsystem and keymaps, mirroring the ImGui path. There
are no Qt-only keybindings; `QtInputHandler` translates Qt key events
into the shared keymap flow (C-k prefix, Ctrl chords, ESC/Meta,
universal-argument digits, printable insertion).

144
docs/plans/swap-files.md Normal file
View File

@@ -0,0 +1,144 @@
Swap files for kte — design plan
================================
Goals
-----
- Preserve user work across crashes, power failures, and OS kills.
- Keep the editor responsive; avoid blocking the UI on disk I/O.
- Bound recovery time and swap size.
- Favor simple, robust primitives that work well on POSIX and macOS;
keep Windows feasibility in mind.
Model overview
--------------
Per open buffer, maintain a sidecar swap journal next to the file:
- Path: `.<basename>.kte.swp` in the same directory as the file (for
unnamed/unsaved buffers, use a persession temp dir like
`$TMPDIR/kte/` with a random UUID).
- Format: appendonly journal of editing operations with periodic
checkpoints.
- Crash safety: only append, fsync as per policy; checkpoint via
writetotemp + fsync + atomic rename.
File format (v1)
----------------
Header (fixed 64 bytes):
- Magic: `KTE_SWP\0` (8 bytes)
- Version: 1 (u32)
- Flags: bitset (u32) — e.g., compression, checksums, endian.
- Created time (u64)
- Host info hash (u64) — optional, for telemetry/debug.
- File identity: hash of canonical path (u64) and original file
size+mtime (u64+u64) at start.
- Reserved/padding.
Records (stream after header):
- Each record: [type u8][len u24][payload][crc32 u32]
- Types:
- `CHKPT` — full snapshot checkpoint of entire buffer content and
minimal metadata (cursor pos, filetype). Payload optionally
compressed. Written occasionally to cap replay time.
- `INS` — insert at (row, col) text bytes (text may contain
newlines). Encoded with varints.
- `DEL` — delete length at (row, col). If spanning lines, semantics
defined as in Buffer::delete_text.
- `SPLIT`, `JOIN` — explicit structural ops (optional; can be
expressed via INS/DEL).
- `META` — update metadata (e.g., filetype, encoding hints).
Durability policy
-----------------
Configurable knobs (sane defaults in parentheses):
- Timebased flush: group edits and flush every 150300 ms (200 ms).
- Operation count flush: after N ops (200).
- Idle flush: on 500 ms idle lull, flush immediately.
- Checkpoint cadence: after M KB of journal (5122048 KB) or T seconds (
30120 s), whichever first.
- fsync policy:
- `always`: fsync every flush (safest, slowest).
- `grouped` (default): fsync at most every 12 s or on
idle/blur/quit.
- `never`: rely on OS flush (fastest, riskier).
- On POSIX, prefer `fdatasync` when available; fall back to `fsync`.
Performance & threading
-----------------------
- Background writer thread per editor instance (shared) with a bounded
MPSC queue of perbuffer records.
- Each Buffer has a small inmemory journal buffer; UI thread enqueues
ops (nonblocking) and may coalesce adjacent inserts/deletes.
- Writer batchwrites records to the swap file, computes CRCs, and
decides checkpoint boundaries.
- Backpressure: if the queue grows beyond a high watermark, signal the
UI to start coalescing more aggressively and slow enqueue (never block
hard editing path; at worst drop optional `META`).
Recovery flow
-------------
On opening a file:
1. Detect swap sidecar `.<basename>.kte.swp`.
2. Validate header, iterate records verifying CRCs.
3. Compare recorded original file identity against actual file; if
mismatch, warn user but allow recovery (content wins).
4. Reconstruct buffer: start from the last good `CHKPT` (if any), then
replay subsequent ops. If trailing partial record encountered (EOF
midrecord), truncate at last good offset.
5. Present a choice: Recover (load recovered buffer; keep the swap file
until user saves) or Discard (delete swap file and open clean file).
Stability & corruption mitigation
---------------------------------
- Appendonly with perrecord CRC32 guards against torn writes.
- Atomic checkpoint rotation: write `.<basename>.kte.swp.tmp`, fsync,
then rename over old `.swp`.
- Size caps: rotate or compact when `.swp` exceeds a threshold (e.g.,
64128 MB). Compaction creates a fresh file with a single checkpoint.
- Lowdiskspace behavior: on write failures, surface a nonmodal
warning and temporarily fall back to inmemory only; retry
opportunistically.
Security considerations
-----------------------
- Swap files mirror buffer content, which may be sensitive. Options:
- Configurable location (same dir vs. `$XDG_STATE_HOME/kte/swap`).
- Optional perfile encryption (future work) using OS keychain.
- Ensure permissions are 0600.
Interoperability & UX
---------------------
- Use a distinctive extension `.kte.swp` to avoid conflicts with other
editors.
- Status bar indicator when swap is active; commands to purge/compact.
- On save: do not delete swap immediately; keep until the buffer is
clean and idle for a short grace period (allows undo of accidental
external changes).
Implementation plan (staged)
----------------------------
1. Minimal journal writer (appendonly INS/DEL) with grouped fsync;
single pereditor writer thread.
2. Reader/recovery path with CRC validation and replay.
3. Checkpoints + atomic rotation; compaction path.
4. Config surface and UI prompts; telemetry counters.
5. Optional compression and advanced coalescing.
Defaults balancing performance and stability
-------------------------------------------
- Grouped flush with fsync every ~1 s or on idle/quit.
- Checkpoint every 1 MB or 60 s.
- Bounded queue and batch writes to minimize syscalls.
- Immediate flush on critical events (buffer close, app quit, power
source change on laptops if detectable).

View File

@@ -4,67 +4,118 @@ Syntax highlighting in kte
Overview
--------
kte provides lightweight syntax highlighting with a pluggable highlighter interface. The initial implementation targets C/C++ and focuses on speed and responsiveness.
kte provides lightweight syntax highlighting with a pluggable
highlighter interface. The initial implementation targets C/C++ and
focuses on speed and responsiveness.
Core types
----------
- `TokenKind` — token categories (keywords, types, strings, comments, numbers, preprocessor, operators, punctuation, identifiers, whitespace, etc.).
- `HighlightSpan` — a half-open column range `[col_start, col_end)` with a `TokenKind`.
- `LineHighlight` — a vector of `HighlightSpan` and the buffer `version` used to compute it.
- `TokenKind` — token categories (keywords, types, strings, comments,
numbers, preprocessor, operators, punctuation, identifiers,
whitespace, etc.).
- `HighlightSpan` — a half-open column range `[col_start, col_end)` with
a `TokenKind`.
- `LineHighlight` — a vector of `HighlightSpan` and the buffer `version`
used to compute it.
Engine and caching
------------------
- `HighlighterEngine` maintains a per-line cache of `LineHighlight` keyed by row and buffer version.
- Cache invalidation occurs when the buffer version changes or when the buffer calls `InvalidateFrom(row)`, which clears cached lines and line states from `row` downward.
- The engine supports both stateless and stateful highlighters. For stateful highlighters, it memoizes a simple per-line state and computes lines sequentially when necessary.
- `HighlighterEngine` maintains a per-line cache of `LineHighlight`
keyed by row and buffer version.
- Cache invalidation occurs when the buffer version changes or when the
buffer calls `InvalidateFrom(row)`, which clears cached lines and line
states from `row` downward.
- The engine supports both stateless and stateful highlighters. For
stateful highlighters, it memoizes a simple per-line state and
computes lines sequentially when necessary.
Stateful highlighters
---------------------
- `LanguageHighlighter` is the base interface for stateless per-line tokenization.
- `StatefulHighlighter` extends it with a `LineState` and the method `HighlightLineStateful(buf, row, prev_state, out)`.
- The engine detects `StatefulHighlighter` via dynamic_cast and feeds each line the previous lines state, caching the resulting state per line.
- `LanguageHighlighter` is the base interface for stateless per-line
tokenization.
- `StatefulHighlighter` extends it with a `LineState` and the method
`HighlightLineStateful(buf, row, prev_state, out)`.
- The engine detects `StatefulHighlighter` via dynamic_cast and feeds
each line the previous lines state, caching the resulting state per
line.
C/C++ highlighter
-----------------
- `CppHighlighter` implements `StatefulHighlighter`.
- Stateless constructs: line comments `//`, strings `"..."`, chars `'...'`, numbers, identifiers (keywords/types), preprocessor at beginning of line after leading whitespace, operators/punctuation, and whitespace.
- Stateless constructs: line comments `//`, strings `"..."`, chars
`'...'`, numbers, identifiers (keywords/types), preprocessor at
beginning of line after leading whitespace, operators/punctuation, and
whitespace.
- Stateful constructs (v2):
- Multi-line block comments `/* ... */` — the state records whether the next line continues a comment.
- Raw strings `R"delim(... )delim"` — the state tracks whether we are inside a raw string and its delimiter `delim` until the closing sequence appears.
- Multi-line block comments `/* ... */` — the state records whether
the next line continues a comment.
- Raw strings `R"delim(... )delim"` — the state tracks whether we
are inside a raw string and its delimiter `delim` until the
closing sequence appears.
Limitations and TODOs
---------------------
- Raw string detection is intentionally simple and does not handle all corner cases of the C++ standard.
- Preprocessor handling is line-based; continuation lines with `\\` are not yet tracked.
- No semantic analysis; identifiers are classified via small keyword/type sets.
- Additional languages (JSON, Markdown, Shell, Python, Go, Rust, Lisp, …) are planned.
- Terminal color mapping is conservative to support 8/16-color terminals. Rich color-pair themes can be added later.
- Raw string detection is intentionally simple and does not handle all
corner cases of the C++ standard.
- Preprocessor handling is line-based; continuation lines with `\\` are
not yet tracked.
- No semantic analysis; identifiers are classified via small
keyword/type sets.
- Additional languages (JSON, Markdown, Shell, Python, Go, Rust,
Lisp, …) are planned.
- Terminal color mapping is conservative to support 8/16-color
terminals. Rich color-pair themes can be added later.
Renderer integration
--------------------
- Terminal and GUI renderers request line spans via `Highlighter()->GetLine(buf, row, buf.Version())`.
- Search highlight and cursor overlays take precedence over syntax colors.
- Terminal and GUI renderers request line spans via
`Highlighter()->GetLine(buf, row, buf.Version())`.
- Search highlight and cursor overlays take precedence over syntax
colors.
Renderer-side robustness
------------------------
- Renderers defensively sanitize `HighlightSpan` data before use to
ensure stability even if a highlighter misbehaves:
- Clamp `col_start/col_end` to the line length and ensure
`end >= start`.
- Drop empty/invalid spans and sort by start.
- Clip drawing to the horizontally visible region and the
tab-expanded line length.
- The highlighter engine returns `LineHighlight` by value to avoid
cross-thread lifetime issues; renderers operate on a local copy for
each frame.
Extensibility (Phase 4)
-----------------------
- Public registration API: external code can register custom highlighters by filetype.
- Use `HighlighterRegistry::Register("mylang", []{ return std::make_unique<MyHighlighter>(); });`
- Registered factories are preferred over built-ins for the same filetype key.
- Filetype keys are normalized via `HighlighterRegistry::Normalize()`.
- Optional Tree-sitter adapter: disabled by default to keep dependencies minimal.
- Enable with CMake option `-DKTE_ENABLE_TREESITTER=ON` and provide
`-DTREESITTER_INCLUDE_DIR=...` and `-DTREESITTER_LIBRARY=...` if needed.
- Register a Tree-sitter-backed highlighter for a language (example assumes you link a grammar):
```c++
extern "C" const TSLanguage* tree_sitter_c();
kte::HighlighterRegistry::RegisterTreeSitter("c", &tree_sitter_c);
```
- Current adapter is a stub scaffold; it compiles and integrates cleanly when enabled, but
intentionally emits no spans until Tree-sitter node-to-token mapping is implemented.
- Public registration API: external code can register custom
highlighters by filetype.
- Use
`HighlighterRegistry::Register("mylang", []{ return std::make_unique<MyHighlighter>(); });`
- Registered factories are preferred over built-ins for the same
filetype key.
- Filetype keys are normalized via
`HighlighterRegistry::Normalize()`.
- Optional Tree-sitter adapter: disabled by default to keep dependencies
minimal.
- Enable with CMake option `-DKTE_ENABLE_TREESITTER=ON` and provide
`-DTREESITTER_INCLUDE_DIR=...` and `-DTREESITTER_LIBRARY=...` if
needed.
- Register a Tree-sitter-backed highlighter for a language (example
assumes you link a grammar):
```c++
extern "C" const TSLanguage* tree_sitter_c();
kte::HighlighterRegistry::RegisterTreeSitter("c", &tree_sitter_c);
```
- Current adapter is a stub scaffold; it compiles and integrates
cleanly when enabled, but
intentionally emits no spans until Tree-sitter node-to-token
mapping is implemented.

View File

@@ -15,6 +15,7 @@
full = kge;
kte = (pkgsFor system).callPackage ./default.nix { graphical = false; };
kge = (pkgsFor system).callPackage ./default.nix { graphical = true; };
qt = (pkgsFor system).callPackage ./default.nix { graphical-qt = true; }
});
};
}

95
main.cc
View File

@@ -6,6 +6,10 @@
#include <iostream>
#include <limits>
#include <memory>
#include <algorithm>
#include <chrono>
#include <random>
#include <thread>
#include <signal.h>
#include <string>
#include <unistd.h>
@@ -17,7 +21,11 @@
#include "TerminalFrontend.h"
#if defined(KTE_BUILD_GUI)
#include "GUIFrontend.h"
#if defined(KTE_USE_QT)
#include "QtFrontend.h"
#else
#include "ImGuiFrontend.h"
#endif
#endif
@@ -34,7 +42,71 @@ PrintUsage(const char *prog)
<< " -g, --gui Use GUI frontend (if built)\n"
<< " -t, --term Use terminal (ncurses) frontend [default]\n"
<< " -h, --help Show this help and exit\n"
<< " -V, --version Show version and exit\n";
<< " -V, --version Show version and exit\n"
<< " --stress-highlighter[=SECONDS] Run a short highlighter stress harness (debug aid)\n";
}
static int
RunStressHighlighter(unsigned seconds)
{
// Build a synthetic buffer with code-like content
Buffer buf;
buf.SetFiletype("cpp");
buf.SetSyntaxEnabled(true);
buf.EnsureHighlighter();
// Seed with many lines
const int N = 1200;
for (int i = 0; i < N; ++i) {
std::string line = "int v" + std::to_string(i) + " = " + std::to_string(i) + "; // line\n";
buf.insert_row(i, line);
}
// Remove the extra last empty row if any artifacts
// Simulate a viewport of ~60 rows
const int viewport_rows = 60;
const auto start_ts = std::chrono::steady_clock::now();
std::mt19937 rng{1234567u};
std::uniform_int_distribution<int> row_d(0, N - 1);
std::uniform_int_distribution<int> op_d(0, 2);
std::uniform_int_distribution<int> sleep_d(0, 2);
// Loop performing edits and highlighter queries while background worker runs
while (std::chrono::duration_cast<std::chrono::seconds>(std::chrono::steady_clock::now() - start_ts).count() <
seconds) {
int fr = row_d(rng);
if (fr + viewport_rows >= N)
fr = std::max(0, N - viewport_rows - 1);
buf.SetOffsets(static_cast<std::size_t>(fr), 0);
if (buf.Highlighter()) {
buf.Highlighter()->PrefetchViewport(buf, fr, viewport_rows, buf.Version());
}
// Do a few direct GetLine calls over the viewport to shake the caches
if (buf.Highlighter()) {
for (int r = 0; r < viewport_rows; r += 7) {
(void) buf.Highlighter()->GetLine(buf, fr + r, buf.Version());
}
}
// Random simple edit
int op = op_d(rng);
int r = row_d(rng);
if (op == 0) {
buf.insert_text(r, 0, "/*X*/");
buf.SetDirty(true);
} else if (op == 1) {
buf.delete_text(r, 0, 1);
buf.SetDirty(true);
} else {
// split and join occasionally
buf.split_line(r, 0);
buf.join_lines(std::min(r + 1, N - 1));
buf.SetDirty(true);
}
// tiny sleep to allow background thread to interleave
if (sleep_d(rng) == 0) {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
}
return 0;
}
@@ -54,11 +126,13 @@ main(int argc, const char *argv[])
{"term", no_argument, nullptr, 't'},
{"help", no_argument, nullptr, 'h'},
{"version", no_argument, nullptr, 'V'},
{"stress-highlighter", optional_argument, nullptr, 1000},
{nullptr, 0, nullptr, 0}
};
int opt;
int long_index = 0;
int long_index = 0;
unsigned stress_seconds = 0;
while ((opt = getopt_long(argc, const_cast<char *const *>(argv), "gthV", long_opts, &long_index)) != -1) {
switch (opt) {
case 'g':
@@ -73,6 +147,17 @@ main(int argc, const char *argv[])
case 'V':
show_version = true;
break;
case 1000: {
stress_seconds = 5; // default
if (optarg && *optarg) {
try {
unsigned v = static_cast<unsigned>(std::stoul(optarg));
if (v > 0 && v < 36000)
stress_seconds = v;
} catch (...) {}
}
break;
}
case '?':
default:
PrintUsage(argv[0]);
@@ -89,6 +174,10 @@ main(int argc, const char *argv[])
return 0;
}
if (stress_seconds > 0) {
return RunStressHighlighter(stress_seconds);
}
// Determine frontend
#if !defined(KTE_BUILD_GUI)
if (req_gui) {

View File

@@ -14,3 +14,16 @@ zip -r kge.app.zip kge.app
sha256sum kge.app.zip
open .
cd ..
mkdir -p cmake-build-release-qt
cmake -S . -B cmake-build-release -DBUILD_GUI=ON -DCMAKE_BUILD_TYPE=Release -DENABLE_ASAN=OFF
cd cmake-build-release-qt
make clean
rm -fr kge.app* kge-qt.app*
make
mv kge.app kge-qt.app
zip -r kge-qt.app.zip kge-qt.app
sha256sum kge-qt.app.zip
open .
cd ..

View File

@@ -34,22 +34,24 @@ HighlighterEngine::SetHighlighter(std::unique_ptr<LanguageHighlighter> hl)
}
const LineHighlight &
LineHighlight
HighlighterEngine::GetLine(const Buffer &buf, int row, std::uint64_t buf_version) const
{
std::unique_lock<std::mutex> lock(mtx_);
auto it = cache_.find(row);
if (it != cache_.end() && it->second.version == buf_version) {
return it->second;
return it->second; // return by value (copy)
}
// Prepare destination slot to reuse its capacity and avoid allocations
LineHighlight &slot = cache_[row];
slot.version = buf_version;
slot.spans.clear();
// We'll compute into a local result to avoid exposing references to cache
LineHighlight result;
result.version = buf_version;
result.spans.clear();
if (!hl_) {
return slot;
// Cache empty result and return it
cache_[row] = result;
return result;
}
// Copy shared_ptr-like raw pointer for use outside critical sections
@@ -58,10 +60,12 @@ HighlighterEngine::GetLine(const Buffer &buf, int row, std::uint64_t buf_version
if (!is_stateful) {
// Stateless fast path: we can release the lock while computing to reduce contention
auto &out = slot.spans;
lock.unlock();
hl_ptr->HighlightLine(buf, row, out);
return cache_.at(row);
hl_ptr->HighlightLine(buf, row, result.spans);
// Update cache and return
std::lock_guard<std::mutex> gl(mtx_);
cache_[row] = result;
return result;
}
// Stateful path: we need to walk from a known previous state. Keep lock while consulting caches,
@@ -96,7 +100,7 @@ HighlighterEngine::GetLine(const Buffer &buf, int row, std::uint64_t buf_version
StatefulHighlighter::LineState cur_state = prev_state;
for (int r = start_row + 1; r <= row; ++r) {
std::vector<HighlightSpan> tmp;
std::vector<HighlightSpan> &out = (r == row) ? slot.spans : tmp;
std::vector<HighlightSpan> &out = (r == row) ? result.spans : tmp;
auto next_state = stateful->HighlightLineStateful(buf, r, cur_state, out);
// Update state cache for r
std::lock_guard<std::mutex> gl(mtx_);
@@ -107,9 +111,10 @@ HighlighterEngine::GetLine(const Buffer &buf, int row, std::uint64_t buf_version
cur_state = next_state;
}
// Return reference under lock to ensure slot's address stability in map
// Store in cache and return by value
lock.lock();
return cache_.at(row);
cache_[row] = result;
return result;
}
@@ -164,11 +169,15 @@ HighlighterEngine::worker_loop() const
// Copy locals then release lock while computing
lock.unlock();
if (req.buf) {
int start = std::max(0, req.start_row);
int end = std::max(start, req.end_row);
int start = std::max(0, req.start_row);
int end = std::max(start, req.end_row);
int skip_f = std::min(req.skip_first, req.skip_last);
int skip_l = std::max(req.skip_first, req.skip_last);
for (int r = start; r <= end; ++r) {
// Re-check version staleness quickly by peeking cache version; not strictly necessary
// Compute line; GetLine is thread-safe
// Avoid touching rows that the foreground just computed/drew.
if (r >= skip_f && r <= skip_l)
continue;
// Compute line; GetLine is thread-safe and will refresh caches.
(void) this->GetLine(*req.buf, r, req.version);
}
}
@@ -201,11 +210,13 @@ HighlighterEngine::PrefetchViewport(const Buffer &buf, int first_row, int row_co
int warm_end = std::min(max_rows - 1, end + warm_margin);
{
std::lock_guard<std::mutex> lock(mtx_);
pending_.buf = &buf;
pending_.version = buf_version;
pending_.start_row = warm_start;
pending_.end_row = warm_end;
has_request_ = true;
pending_.buf = &buf;
pending_.version = buf_version;
pending_.start_row = warm_start;
pending_.end_row = warm_end;
pending_.skip_first = start;
pending_.skip_last = end;
has_request_ = true;
}
ensure_worker_started();
cv_.notify_one();

View File

@@ -25,8 +25,9 @@ public:
void SetHighlighter(std::unique_ptr<LanguageHighlighter> hl);
// Retrieve highlights for a given line and buffer version.
// Returns a copy to avoid lifetime issues across threads/renderers.
// If cache is stale, recompute using the current highlighter.
const LineHighlight &GetLine(const Buffer &buf, int row, std::uint64_t buf_version) const;
LineHighlight GetLine(const Buffer &buf, int row, std::uint64_t buf_version) const;
// Invalidate cached lines from row (inclusive)
void InvalidateFrom(int row);
@@ -70,6 +71,10 @@ private:
std::uint64_t version{0};
int start_row{0};
int end_row{0}; // inclusive
// Visible rows to skip touching in the background (inclusive range).
// These are computed synchronously by PrefetchViewport.
int skip_first{0};
int skip_last{-1};
};
mutable std::condition_variable cv_;