From ee2c9939d73f1397730943efb8af02194c07c96c Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Thu, 4 Dec 2025 21:33:55 -0800 Subject: [PATCH] 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. --- CMakeLists.txt | 73 +- Command.cc | 259 +++++- Command.h | 2 + GUIInputHandler.cc | 599 -------------- GUIRenderer.h | 14 - GUITheme.h | 304 ++++++- GUIFrontend.cc => ImGuiFrontend.cc | 24 +- GUIFrontend.h => ImGuiFrontend.h | 10 +- ImGuiInputHandler.cc | 601 ++++++++++++++ GUIInputHandler.h => ImGuiInputHandler.h | 8 +- GUIRenderer.cc => ImGuiRenderer.cc | 8 +- ImGuiRenderer.h | 14 + QtFrontend.cc | 988 +++++++++++++++++++++++ QtFrontend.h | 36 + QtInputHandler.cc | 538 ++++++++++++ QtInputHandler.h | 40 + QtRenderer.cc | 76 ++ QtRenderer.h | 27 + default.nix | 8 +- docs/plans/qt-frontend.md | 10 +- flake.nix | 1 + main.cc | 58 +- 22 files changed, 2972 insertions(+), 726 deletions(-) delete mode 100644 GUIInputHandler.cc delete mode 100644 GUIRenderer.h rename GUIFrontend.cc => ImGuiFrontend.cc (97%) rename GUIFrontend.h => ImGuiFrontend.h (73%) create mode 100644 ImGuiInputHandler.cc rename GUIInputHandler.h => ImGuiInputHandler.h (84%) rename GUIRenderer.cc => ImGuiRenderer.cc (99%) create mode 100644 ImGuiRenderer.h create mode 100644 QtFrontend.cc create mode 100644 QtFrontend.h create mode 100644 QtInputHandler.cc create mode 100644 QtInputHandler.h create mode 100644 QtRenderer.cc create mode 100644 QtRenderer.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 9a3a79a..a341316 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -9,6 +9,7 @@ set(KTE_VERSION "1.3.9") # 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 @@ -222,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 @@ -319,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) diff --git a/Command.cc b/Command.cc index 62439ca..1b8c442 100644 --- a/Command.cc +++ b/Command.cc @@ -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 +# include +# 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 @@ -104,24 +124,24 @@ static bool is_mutating_command(CommandId id) { switch (id) { - case CommandId::InsertText: - case CommandId::Newline: - case CommandId::Backspace: - case CommandId::DeleteChar: - case CommandId::KillToEOL: - case CommandId::KillLine: - case CommandId::Yank: - case CommandId::DeleteWordPrev: - case CommandId::DeleteWordNext: - case CommandId::IndentRegion: - case CommandId::UnindentRegion: - case CommandId::ReflowParagraph: - case CommandId::KillRegion: - case CommandId::Undo: - case CommandId::Redo: - return true; - default: - return false; + case CommandId::InsertText: + case CommandId::Newline: + case CommandId::Backspace: + case CommandId::DeleteChar: + case CommandId::KillToEOL: + case CommandId::KillLine: + case CommandId::Yank: + case CommandId::DeleteWordPrev: + case CommandId::DeleteWordNext: + case CommandId::IndentRegion: + case CommandId::UnindentRegion: + case CommandId::ReflowParagraph: + case CommandId::KillRegion: + case CommandId::Undo: + case CommandId::Redo: + return true; + default: + return false; } } @@ -914,8 +934,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) { @@ -951,7 +971,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) { @@ -995,15 +1015,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) { @@ -1056,14 +1102,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) { @@ -1122,14 +1192,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) { @@ -1298,6 +1399,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) { @@ -1599,7 +1714,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 cands; const auto ® = kte::ThemeRegistry(); for (const auto &t: reg) { @@ -1607,6 +1723,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 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 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) { @@ -1628,7 +1805,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 @@ -3770,11 +3947,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(); + 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; + 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; @@ -4201,9 +4378,9 @@ InstallDefaultCommands() 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({ CommandId::ToggleReadOnly, "toggle-read-only", "Toggle buffer read-only", cmd_toggle_read_only @@ -4249,6 +4426,10 @@ InstallDefaultCommands() CommandId::VisualFilePickerToggle, "file-picker-toggle", "Toggle visual file picker", 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", diff --git a/Command.h b/Command.h index 3c1ac46..4f6caa0 100644 --- a/Command.h +++ b/Command.h @@ -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, diff --git a/GUIInputHandler.cc b/GUIInputHandler.cc deleted file mode 100644 index f2aaf76..0000000 --- a/GUIInputHandler.cc +++ /dev/null @@ -1,599 +0,0 @@ -#include -#include -#include - -#include -#include - -#include "GUIInputHandler.h" -#include "KKeymap.h" -#include "Editor.h" - - -static bool -map_key(const SDL_Keycode key, - const SDL_Keymod mod, - bool &k_prefix, - bool &esc_meta, - 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. - // Prefer KEYDOWN when we can derive a printable ASCII; otherwise defer to TEXTINPUT. - if (esc_meta) { - int ascii_key = 0; - if (key == SDLK_BACKSPACE) { - // ESC BACKSPACE: map to DeleteWordPrev using ncurses KEY_BACKSPACE constant - ascii_key = KEY_BACKSPACE; - } else if (key >= SDLK_a && key <= SDLK_z) { - ascii_key = static_cast('a' + (key - SDLK_a)); - } else if (key == SDLK_COMMA) { - ascii_key = '<'; - } else if (key == SDLK_PERIOD) { - ascii_key = '>'; - } else if (key == SDLK_LESS) { - ascii_key = '<'; - } else if (key == SDLK_GREATER) { - 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)) { - out = {true, id, "", 0}; - return true; - } - // Known printable but unmapped ESC sequence: report invalid - out = {true, CommandId::UnknownEscCommand, "", 0}; - return true; - } - // No usable ASCII from KEYDOWN → keep esc_meta set and let TEXTINPUT handle it - out.hasCommand = false; - return true; - } - - // Movement and basic keys - // These keys exit k-prefix mode if active (user pressed C-k then a special key). - switch (key) { - case SDLK_LEFT: - k_prefix = false; - k_ctrl_pending = false; - out = {true, CommandId::MoveLeft, "", 0}; - return true; - case SDLK_RIGHT: - k_prefix = false; - k_ctrl_pending = false; - out = {true, CommandId::MoveRight, "", 0}; - return true; - case SDLK_UP: - k_prefix = false; - k_ctrl_pending = false; - out = {true, CommandId::MoveUp, "", 0}; - return true; - case SDLK_DOWN: - k_prefix = false; - k_ctrl_pending = false; - out = {true, CommandId::MoveDown, "", 0}; - return true; - case SDLK_HOME: - k_prefix = false; - k_ctrl_pending = false; - out = {true, CommandId::MoveHome, "", 0}; - return true; - case SDLK_END: - k_prefix = false; - k_ctrl_pending = false; - out = {true, CommandId::MoveEnd, "", 0}; - return true; - case SDLK_PAGEUP: - k_prefix = false; - k_ctrl_pending = false; - out = {true, CommandId::PageUp, "", 0}; - return true; - case SDLK_PAGEDOWN: - k_prefix = false; - k_ctrl_pending = false; - out = {true, CommandId::PageDown, "", 0}; - return true; - case SDLK_DELETE: - k_prefix = false; - k_ctrl_pending = false; - out = {true, CommandId::DeleteChar, "", 0}; - return true; - case SDLK_BACKSPACE: - k_prefix = false; - 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. - // If k-prefix is active, let the k-prefix handler below consume the key - // (so Tab doesn't leave k-prefix stuck). - if (!k_prefix) { - out = {true, CommandId::InsertText, std::string("\t"), 0}; - return true; - } - break; // fall through so k-prefix handler can process - case SDLK_RETURN: - case SDLK_KP_ENTER: - 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; - default: - break; - } - - // If we are in k-prefix, interpret the very next key via the C-k keymap immediately. - if (k_prefix) { - esc_meta = false; - // Normalize to ASCII; preserve case for letters using Shift - int ascii_key = 0; - if (key >= SDLK_a && key <= SDLK_z) { - ascii_key = static_cast('a' + (key - SDLK_a)); - if (mod & KMOD_SHIFT) - ascii_key = ascii_key - 'a' + 'A'; - } else if (key == SDLK_COMMA) { - ascii_key = '<'; - } else if (key == SDLK_PERIOD) { - ascii_key = '>'; - } else if (key == SDLK_LESS) { - ascii_key = '<'; - } else if (key == SDLK_GREATER) { - ascii_key = '>'; - } else if (key >= SDLK_SPACE && key <= SDLK_z) { - ascii_key = static_cast(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 || k_ctrl_pending) && ctrl_suffix_supported; - k_ctrl_pending = false; - CommandId id; - bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id); - // Diagnostics for u/U - if (lower == 'u') { - char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e) - ? static_cast(ascii_key) - : '?'; - std::fprintf(stderr, - "[kge] k-prefix suffix: sym=%d mods=0x%x ascii=%d '%c' ctrl2=%d pass_ctrl=%d mapped=%d id=%d\n", - static_cast(key), static_cast(mod), ascii_key, disp, - ctrl2 ? 1 : 0, pass_ctrl ? 1 : 0, mapped ? 1 : 0, - mapped ? static_cast(id) : -1); - std::fflush(stderr); - } - 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(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; - } - // 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 (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) { - 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; - out = {true, CommandId::KPrefix, "", 0}; - return true; - } - // Map other control chords via shared keymap - if (key >= SDLK_a && key <= SDLK_z) { - int ascii_key = static_cast('a' + (key - SDLK_a)); - CommandId id; - if (KLookupCtrlCommand(ascii_key, id)) { - out = {true, id, "", 0}; - return true; - } - } - } - - // Alt/Meta bindings (ESC f/b equivalent) - if (is_alt) { - int ascii_key = 0; - if (key == SDLK_BACKSPACE) { - // Alt BACKSPACE: map to DeleteWordPrev using ncurses KEY_BACKSPACE constant - ascii_key = KEY_BACKSPACE; - } else if (key >= SDLK_a && key <= SDLK_z) { - ascii_key = static_cast('a' + (key - SDLK_a)); - } else if (key == SDLK_COMMA) { - ascii_key = '<'; - } else if (key == SDLK_PERIOD) { - ascii_key = '>'; - } else if (key == SDLK_LESS) { - ascii_key = '<'; - } else if (key == SDLK_GREATER) { - ascii_key = '>'; - } - if (ascii_key != 0) { - CommandId id; - if (KLookupEscCommand(ascii_key, id)) { - out = {true, id, "", 0}; - return true; - } - } - } - - // 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(key - SDLK_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; - } - } - - // k_prefix handled earlier - - return false; -} - - -bool -GUIInputHandler::ProcessSDLEvent(const SDL_Event &e) -{ - MappedInput mi; - bool produced = false; - switch (e.type) { - case SDL_MOUSEWHEEL: { - // Let ImGui handle mouse wheel when it wants to capture the mouse - // (e.g., when hovering the editor child window with scrollbars). - // This enables native vertical and horizontal scrolling behavior in GUI. - if (ImGui::GetIO().WantCaptureMouse) - return false; - // Otherwise, fallback to mapping vertical wheel to editor scroll commands. - int dy = e.wheel.y; -#ifdef SDL_MOUSEWHEEL_FLIPPED - if (e.wheel.direction == SDL_MOUSEWHEEL_FLIPPED) - dy = -dy; -#endif - if (dy != 0) { - int repeat = dy > 0 ? dy : -dy; - CommandId id = dy > 0 ? CommandId::ScrollUp : CommandId::ScrollDown; - std::lock_guard lk(mu_); - for (int i = 0; i < repeat; ++i) { - q_.push(MappedInput{true, id, std::string(), 0}); - } - return true; // consumed - } - return false; - } - case SDL_KEYDOWN: { - // Remember state before mapping; used for TEXTINPUT suppression heuristics - const bool was_k_prefix = k_prefix_; - const bool was_esc_meta = esc_meta_; - SDL_Keymod mods = SDL_Keymod(e.key.keysym.mod); - const SDL_Keycode key = e.key.keysym.sym; - - // Handle Paste: Ctrl+V (Windows/Linux) or Cmd+V (macOS) - // Note: SDL defines letter keycodes in lowercase only (e.g., SDLK_v). Shift does not change keycode. - if ((mods & (KMOD_CTRL | KMOD_GUI)) && (key == SDLK_v)) { - char *clip = SDL_GetClipboardText(); - if (clip) { - std::string text(clip); - SDL_free(clip); - // Split on '\n' and enqueue as InsertText/Newline commands - std::lock_guard lk(mu_); - std::size_t start = 0; - while (start <= text.size()) { - std::size_t pos = text.find('\n', start); - std::string_view segment; - bool has_nl = (pos != std::string::npos); - if (has_nl) { - segment = std::string_view(text).substr(start, pos - start); - } else { - segment = std::string_view(text).substr(start); - } - if (!segment.empty()) { - MappedInput ins{ - true, CommandId::InsertText, std::string(segment), 0 - }; - q_.push(ins); - } - if (has_nl) { - MappedInput nl{true, CommandId::Newline, std::string(), 0}; - q_.push(nl); - start = pos + 1; - } else { - break; - } - } - // Suppress the corresponding TEXTINPUT that may follow - suppress_text_input_once_ = true; - return true; // consumed - } - } - - { - 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; - const bool is_printable_letter = (key >= SDLK_SPACE && key <= SDLK_z); - const bool is_non_text_key = - key == SDLK_TAB || key == SDLK_RETURN || key == SDLK_KP_ENTER || - key == SDLK_BACKSPACE || key == SDLK_DELETE || key == SDLK_ESCAPE || - key == SDLK_LEFT || key == SDLK_RIGHT || key == SDLK_UP || key == SDLK_DOWN || - key == SDLK_HOME || key == SDLK_END || key == SDLK_PAGEUP || key == SDLK_PAGEDOWN; - - bool should_suppress = false; - if (!is_non_text_key) { - // Any k-prefix suffix that is printable should suppress TEXTINPUT, even if no - // command mapped (we report unknown via status instead of inserting text). - if (was_k_prefix && is_printable_letter) { - should_suppress = true; - } - // 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); - if (is_alt && ((key >= SDLK_a && key <= SDLK_z) || is_meta_symbol)) { - should_suppress = true; - } - // ESC-as-meta followed by printable - if (was_esc_meta && (is_printable_letter || is_meta_symbol)) { - should_suppress = true; - } - } - if (should_suppress) { - suppress_text_input_once_ = true; - } - break; - } - case SDL_TEXTINPUT: { - // If the previous KEYDOWN requested suppression of this TEXTINPUT (e.g., - // we already handled a uarg digit/minus or a k-prefix printable), do it - // immediately before any other handling to avoid duplicates. - if (suppress_text_input_once_) { - suppress_text_input_once_ = false; // consume suppression - produced = true; // consumed event - break; - } - - // 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(txt[0]); - if (c0 >= '0' && c0 <= '9') { - int d = c0 - '0'; - ed_->UArgDigit(d); - produced = true; // consumed to update status - break; - } - } - // Non-digit ends collection; allow processing normally below - } - - // If we are still in k-prefix and KEYDOWN path didn't handle the suffix, - // use TEXTINPUT's actual character (handles Shifted letters on macOS) to map the k-command. - if (k_prefix_) { - k_prefix_ = false; - esc_meta_ = false; - const char *txt = e.text.text; - if (txt && *txt) { - unsigned char c0 = static_cast(txt[0]); - int ascii_key = 0; - if (c0 < 0x80) { - ascii_key = static_cast(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 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(ascii_key) - : '?'; - std::fprintf(stderr, - "[kge] k-prefix TEXTINPUT suffix: ascii=%d '%c' mapped=%d id=%d\n", - ascii_key, disp, mapped ? 1 : 0, - mapped ? static_cast(id) : -1); - std::fflush(stderr); - if (mapped) { - mi = {true, id, "", 0}; - if (ed_) - ed_->SetStatus(""); // clear "C-k _" hint after suffix - produced = true; - break; // handled; do not insert text - } else { - // Unknown k-command via TEXTINPUT path - int shown = KLowerAscii(ascii_key); - char c = (shown >= 0x20 && shown <= 0x7e) - ? static_cast(shown) - : '?'; - std::string arg(1, c); - mi = {true, CommandId::UnknownKCommand, arg, 0}; - if (ed_) - ed_->SetStatus(""); - produced = true; - break; - } - } - } - // 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; - } - // (suppression is handled at the top of this case) - // Handle ESC-as-meta fallback on TEXTINPUT: some platforms emit only TEXTINPUT - // for the printable part after ESC. If esc_meta_ is set, translate first char. - if (esc_meta_) { - esc_meta_ = false; // consume meta prefix - const char *txt = e.text.text; - if (txt && *txt) { - // Parse first UTF-8 codepoint (we care only about common ASCII cases) - unsigned char c0 = static_cast(txt[0]); - // Map a few common symbols/letters used in our ESC map - int ascii_key = 0; - if (c0 < 0x80) { - // ASCII path - ascii_key = static_cast(c0); - ascii_key = KLowerAscii(ascii_key); - } else { - // Basic handling for macOS Option combos that might produce ≤/≥ - // Compare the UTF-8 prefix for these two symbols - std::string s(txt); - if (s.rfind("\xE2\x89\xA4", 0) == 0) { - // U+2264 '≤' - ascii_key = '<'; - } else if (s.rfind("\xE2\x89\xA5", 0) == 0) { - // U+2265 '≥' - ascii_key = '>'; - } - } - if (ascii_key != 0) { - CommandId id; - if (KLookupEscCommand(ascii_key, id)) { - mi = {true, id, "", 0}; - produced = true; - break; - } - } - } - // If we get here, unmapped ESC sequence via TEXTINPUT: report invalid - mi = {true, CommandId::UnknownEscCommand, "", 0}; - produced = true; - break; - } - if (!k_prefix_ && e.text.text[0] != '\0') { - // Ensure InsertText never carries a newline; those must originate from KEYDOWN - std::string text(e.text.text); - // Strip any CR/LF that might slip through from certain platforms/IME behaviors - text.erase(std::remove(text.begin(), text.end(), '\n'), text.end()); - text.erase(std::remove(text.begin(), text.end(), '\r'), text.end()); - if (!text.empty()) { - mi.hasCommand = true; - mi.id = CommandId::InsertText; - mi.arg = std::move(text); - mi.count = 0; - produced = true; - } else { - // Nothing to insert after filtering; consume the event - produced = true; - } - } else { - produced = true; // consumed while k-prefix is active - } - break; - } - default: - break; - } - - if (produced && mi.hasCommand) { - std::lock_guard lk(mu_); - q_.push(mi); - } - return produced; -} - - -bool -GUIInputHandler::Poll(MappedInput &out) -{ - std::lock_guard lk(mu_); - if (q_.empty()) - return false; - out = q_.front(); - q_.pop(); - return true; -} \ No newline at end of file diff --git a/GUIRenderer.h b/GUIRenderer.h deleted file mode 100644 index b83a773..0000000 --- a/GUIRenderer.h +++ /dev/null @@ -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; -}; \ No newline at end of file diff --git a/GUITheme.h b/GUITheme.h index 4a48bbb..040b333 100644 --- a/GUITheme.h +++ b/GUITheme.h @@ -1,11 +1,307 @@ -// GUITheme.h — ImGui theming helpers and background mode +// GUITheme.h — theming helpers and background mode #pragma once +#include +#include +#include +#include + +#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((rgb >> 16) & 0xFF) / 255.0f; + const float g = static_cast((rgb >> 8) & 0xFF) / 255.0f; + const float b = static_cast(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 #include #include #include -#include #include #include @@ -644,4 +940,6 @@ SyntaxInk(const TokenKind k) return def; } } -} // namespace kte \ No newline at end of file +} // namespace kte + +#endif // KTE_USE_QT \ No newline at end of file diff --git a/GUIFrontend.cc b/ImGuiFrontend.cc similarity index 97% rename from GUIFrontend.cc rename to ImGuiFrontend.cc index eeb386f..958c4b4 100644 --- a/GUIFrontend.cc +++ b/ImGuiFrontend.cc @@ -11,7 +11,7 @@ #include #include -#include "GUIFrontend.h" +#include "ImGuiFrontend.h" #include "Command.h" #include "Editor.h" #include "GUIConfig.h" @@ -224,17 +224,17 @@ GUIFrontend::Step(Editor &ed, bool &running) while (SDL_PollEvent(&e)) { ImGui_ImplSDL2_ProcessEvent(&e); switch (e.type) { - case SDL_QUIT: - running = false; - break; - case SDL_WINDOWEVENT: - if (e.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) { - width_ = e.window.data1; - height_ = e.window.data2; - } - break; - default: - break; + case SDL_QUIT: + running = false; + break; + case SDL_WINDOWEVENT: + if (e.window.event == SDL_WINDOWEVENT_SIZE_CHANGED) { + width_ = e.window.data1; + height_ = e.window.data2; + } + break; + default: + break; } // Map input to commands input_.ProcessSDLEvent(e); diff --git a/GUIFrontend.h b/ImGuiFrontend.h similarity index 73% rename from GUIFrontend.h rename to ImGuiFrontend.h index 10644a3..54c15aa 100644 --- a/GUIFrontend.h +++ b/ImGuiFrontend.h @@ -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; diff --git a/ImGuiInputHandler.cc b/ImGuiInputHandler.cc new file mode 100644 index 0000000..8abd7f3 --- /dev/null +++ b/ImGuiInputHandler.cc @@ -0,0 +1,601 @@ +#include +#include +#include + +#include +#include + +#include "ImGuiInputHandler.h" +#include "KKeymap.h" +#include "Editor.h" + + +static bool +map_key(const SDL_Keycode key, + const SDL_Keymod mod, + bool &k_prefix, + bool &esc_meta, + 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. + // Prefer KEYDOWN when we can derive a printable ASCII; otherwise defer to TEXTINPUT. + if (esc_meta) { + int ascii_key = 0; + if (key == SDLK_BACKSPACE) { + // ESC BACKSPACE: map to DeleteWordPrev using ncurses KEY_BACKSPACE constant + ascii_key = KEY_BACKSPACE; + } else if (key >= SDLK_a && key <= SDLK_z) { + ascii_key = static_cast('a' + (key - SDLK_a)); + } else if (key == SDLK_COMMA) { + ascii_key = '<'; + } else if (key == SDLK_PERIOD) { + ascii_key = '>'; + } else if (key == SDLK_LESS) { + ascii_key = '<'; + } else if (key == SDLK_GREATER) { + 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)) { + out = {true, id, "", 0}; + return true; + } + // Known printable but unmapped ESC sequence: report invalid + out = {true, CommandId::UnknownEscCommand, "", 0}; + return true; + } + // No usable ASCII from KEYDOWN → keep esc_meta set and let TEXTINPUT handle it + out.hasCommand = false; + return true; + } + + // Movement and basic keys + // These keys exit k-prefix mode if active (user pressed C-k then a special key). + switch (key) { + case SDLK_LEFT: + k_prefix = false; + k_ctrl_pending = false; + out = {true, CommandId::MoveLeft, "", 0}; + return true; + case SDLK_RIGHT: + k_prefix = false; + k_ctrl_pending = false; + out = {true, CommandId::MoveRight, "", 0}; + return true; + case SDLK_UP: + k_prefix = false; + k_ctrl_pending = false; + out = {true, CommandId::MoveUp, "", 0}; + return true; + case SDLK_DOWN: + k_prefix = false; + k_ctrl_pending = false; + out = {true, CommandId::MoveDown, "", 0}; + return true; + case SDLK_HOME: + k_prefix = false; + k_ctrl_pending = false; + out = {true, CommandId::MoveHome, "", 0}; + return true; + case SDLK_END: + k_prefix = false; + k_ctrl_pending = false; + out = {true, CommandId::MoveEnd, "", 0}; + return true; + case SDLK_PAGEUP: + k_prefix = false; + k_ctrl_pending = false; + out = {true, CommandId::PageUp, "", 0}; + return true; + case SDLK_PAGEDOWN: + k_prefix = false; + k_ctrl_pending = false; + out = {true, CommandId::PageDown, "", 0}; + return true; + case SDLK_DELETE: + k_prefix = false; + k_ctrl_pending = false; + out = {true, CommandId::DeleteChar, "", 0}; + return true; + case SDLK_BACKSPACE: + k_prefix = false; + 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. + // If k-prefix is active, let the k-prefix handler below consume the key + // (so Tab doesn't leave k-prefix stuck). + if (!k_prefix) { + out = {true, CommandId::InsertText, std::string("\t"), 0}; + return true; + } + break; // fall through so k-prefix handler can process + case SDLK_RETURN: + case SDLK_KP_ENTER: + 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; + default: + break; + } + + // If we are in k-prefix, interpret the very next key via the C-k keymap immediately. + if (k_prefix) { + esc_meta = false; + // Normalize to ASCII; preserve case for letters using Shift + int ascii_key = 0; + if (key >= SDLK_a && key <= SDLK_z) { + ascii_key = static_cast('a' + (key - SDLK_a)); + if (mod & KMOD_SHIFT) + ascii_key = ascii_key - 'a' + 'A'; + } else if (key == SDLK_COMMA) { + ascii_key = '<'; + } else if (key == SDLK_PERIOD) { + ascii_key = '>'; + } else if (key == SDLK_LESS) { + ascii_key = '<'; + } else if (key == SDLK_GREATER) { + ascii_key = '>'; + } else if (key >= SDLK_SPACE && key <= SDLK_z) { + ascii_key = static_cast(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 || k_ctrl_pending) && ctrl_suffix_supported; + k_ctrl_pending = false; + CommandId id; + bool mapped = KLookupKCommand(ascii_key, pass_ctrl, id); + // Diagnostics for u/U + if (lower == 'u') { + char disp = (ascii_key >= 0x20 && ascii_key <= 0x7e) + ? static_cast(ascii_key) + : '?'; + std::fprintf(stderr, + "[kge] k-prefix suffix: sym=%d mods=0x%x ascii=%d '%c' ctrl2=%d pass_ctrl=%d mapped=%d id=%d\n", + static_cast(key), static_cast(mod), ascii_key, disp, + ctrl2 ? 1 : 0, pass_ctrl ? 1 : 0, mapped ? 1 : 0, + mapped ? static_cast(id) : -1); + std::fflush(stderr); + } + 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(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; + } + // 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 (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) { + 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; + out = {true, CommandId::KPrefix, "", 0}; + return true; + } + // Map other control chords via shared keymap + if (key >= SDLK_a && key <= SDLK_z) { + int ascii_key = static_cast('a' + (key - SDLK_a)); + CommandId id; + if (KLookupCtrlCommand(ascii_key, id)) { + out = {true, id, "", 0}; + return true; + } + } + } + + // Alt/Meta bindings (ESC f/b equivalent) + if (is_alt) { + int ascii_key = 0; + if (key == SDLK_BACKSPACE) { + // Alt BACKSPACE: map to DeleteWordPrev using ncurses KEY_BACKSPACE constant + ascii_key = KEY_BACKSPACE; + } else if (key >= SDLK_a && key <= SDLK_z) { + ascii_key = static_cast('a' + (key - SDLK_a)); + } else if (key == SDLK_COMMA) { + ascii_key = '<'; + } else if (key == SDLK_PERIOD) { + ascii_key = '>'; + } else if (key == SDLK_LESS) { + ascii_key = '<'; + } else if (key == SDLK_GREATER) { + ascii_key = '>'; + } + if (ascii_key != 0) { + CommandId id; + if (KLookupEscCommand(ascii_key, id)) { + out = {true, id, "", 0}; + return true; + } + } + } + + // 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(key - SDLK_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; + } + } + + // k_prefix handled earlier + + return false; +} + + +bool +ImGuiInputHandler::ProcessSDLEvent(const SDL_Event &e) +{ + MappedInput mi; + bool produced = false; + switch (e.type) { + case SDL_MOUSEWHEEL: { + // Let ImGui handle mouse wheel when it wants to capture the mouse + // (e.g., when hovering the editor child window with scrollbars). + // This enables native vertical and horizontal scrolling behavior in GUI. + if (ImGui::GetIO().WantCaptureMouse) + return false; + // Otherwise, fallback to mapping vertical wheel to editor scroll commands. + int dy = e.wheel.y; +#ifdef SDL_MOUSEWHEEL_FLIPPED + if (e.wheel.direction == SDL_MOUSEWHEEL_FLIPPED) + dy = -dy; +#endif + if (dy != 0) { + int repeat = dy > 0 ? dy : -dy; + CommandId id = dy > 0 ? CommandId::ScrollUp : CommandId::ScrollDown; + std::lock_guard lk(mu_); + for (int i = 0; i < repeat; ++i) { + q_.push(MappedInput{true, id, std::string(), 0}); + } + return true; // consumed + } + return false; + } + case SDL_KEYDOWN: { + // Remember state before mapping; used for TEXTINPUT suppression heuristics + const bool was_k_prefix = k_prefix_; + const bool was_esc_meta = esc_meta_; + SDL_Keymod mods = SDL_Keymod(e.key.keysym.mod); + const SDL_Keycode key = e.key.keysym.sym; + + // Handle Paste: Ctrl+V (Windows/Linux) or Cmd+V (macOS) + // Note: SDL defines letter keycodes in lowercase only (e.g., SDLK_v). Shift does not change keycode. + if ((mods & (KMOD_CTRL | KMOD_GUI)) && (key == SDLK_v)) { + char *clip = SDL_GetClipboardText(); + if (clip) { + std::string text(clip); + SDL_free(clip); + // Split on '\n' and enqueue as InsertText/Newline commands + std::lock_guard lk(mu_); + std::size_t start = 0; + while (start <= text.size()) { + std::size_t pos = text.find('\n', start); + std::string_view segment; + bool has_nl = (pos != std::string::npos); + if (has_nl) { + segment = std::string_view(text).substr(start, pos - start); + } else { + segment = std::string_view(text).substr(start); + } + if (!segment.empty()) { + MappedInput ins{ + true, CommandId::InsertText, std::string(segment), 0 + }; + q_.push(ins); + } + if (has_nl) { + MappedInput nl{true, CommandId::Newline, std::string(), 0}; + q_.push(nl); + start = pos + 1; + } else { + break; + } + } + // Suppress the corresponding TEXTINPUT that may follow + suppress_text_input_once_ = true; + return true; // consumed + } + } + + { + 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; + const bool is_printable_letter = (key >= SDLK_SPACE && key <= SDLK_z); + const bool is_non_text_key = + key == SDLK_TAB || key == SDLK_RETURN || key == SDLK_KP_ENTER || + key == SDLK_BACKSPACE || key == SDLK_DELETE || key == SDLK_ESCAPE || + key == SDLK_LEFT || key == SDLK_RIGHT || key == SDLK_UP || key == SDLK_DOWN || + key == SDLK_HOME || key == SDLK_END || key == SDLK_PAGEUP || key == SDLK_PAGEDOWN; + + bool should_suppress = false; + if (!is_non_text_key) { + // Any k-prefix suffix that is printable should suppress TEXTINPUT, even if no + // command mapped (we report unknown via status instead of inserting text). + if (was_k_prefix && is_printable_letter) { + should_suppress = true; + } + // 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); + if (is_alt && ((key >= SDLK_a && key <= SDLK_z) || is_meta_symbol)) { + should_suppress = true; + } + // ESC-as-meta followed by printable + if (was_esc_meta && (is_printable_letter || is_meta_symbol)) { + should_suppress = true; + } + } + if (should_suppress) { + suppress_text_input_once_ = true; + } + break; + } + case SDL_TEXTINPUT: { + // If the previous KEYDOWN requested suppression of this TEXTINPUT (e.g., + // we already handled a uarg digit/minus or a k-prefix printable), do it + // immediately before any other handling to avoid duplicates. + if (suppress_text_input_once_) { + suppress_text_input_once_ = false; // consume suppression + produced = true; // consumed event + break; + } + + // 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(txt[0]); + if (c0 >= '0' && c0 <= '9') { + int d = c0 - '0'; + ed_->UArgDigit(d); + produced = true; // consumed to update status + break; + } + } + // Non-digit ends collection; allow processing normally below + } + + // If we are still in k-prefix and KEYDOWN path didn't handle the suffix, + // use TEXTINPUT's actual character (handles Shifted letters on macOS) to map the k-command. + if (k_prefix_) { + k_prefix_ = false; + esc_meta_ = false; + const char *txt = e.text.text; + if (txt && *txt) { + unsigned char c0 = static_cast(txt[0]); + int ascii_key = 0; + if (c0 < 0x80) { + ascii_key = static_cast(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 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(ascii_key) + : '?'; + std::fprintf(stderr, + "[kge] k-prefix TEXTINPUT suffix: ascii=%d '%c' mapped=%d id=%d\n", + ascii_key, disp, mapped ? 1 : 0, + mapped ? static_cast(id) : -1); + std::fflush(stderr); + if (mapped) { + mi = {true, id, "", 0}; + if (ed_) + ed_->SetStatus(""); // clear "C-k _" hint after suffix + produced = true; + break; // handled; do not insert text + } else { + // Unknown k-command via TEXTINPUT path + int shown = KLowerAscii(ascii_key); + char c = (shown >= 0x20 && shown <= 0x7e) + ? static_cast(shown) + : '?'; + std::string arg(1, c); + mi = {true, CommandId::UnknownKCommand, arg, 0}; + if (ed_) + ed_->SetStatus(""); + produced = true; + break; + } + } + } + // 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; + } + // (suppression is handled at the top of this case) + // Handle ESC-as-meta fallback on TEXTINPUT: some platforms emit only TEXTINPUT + // for the printable part after ESC. If esc_meta_ is set, translate first char. + if (esc_meta_) { + esc_meta_ = false; // consume meta prefix + const char *txt = e.text.text; + if (txt && *txt) { + // Parse first UTF-8 codepoint (we care only about common ASCII cases) + unsigned char c0 = static_cast(txt[0]); + // Map a few common symbols/letters used in our ESC map + int ascii_key = 0; + if (c0 < 0x80) { + // ASCII path + ascii_key = static_cast(c0); + ascii_key = KLowerAscii(ascii_key); + } else { + // Basic handling for macOS Option combos that might produce ≤/≥ + // Compare the UTF-8 prefix for these two symbols + std::string s(txt); + if (s.rfind("\xE2\x89\xA4", 0) == 0) { + // U+2264 '≤' + ascii_key = '<'; + } else if (s.rfind("\xE2\x89\xA5", 0) == 0) { + // U+2265 '≥' + ascii_key = '>'; + } + } + if (ascii_key != 0) { + CommandId id; + if (KLookupEscCommand(ascii_key, id)) { + mi = {true, id, "", 0}; + produced = true; + break; + } + } + } + // If we get here, unmapped ESC sequence via TEXTINPUT: report invalid + mi = {true, CommandId::UnknownEscCommand, "", 0}; + produced = true; + break; + } + if (!k_prefix_ && e.text.text[0] != '\0') { + // Ensure InsertText never carries a newline; those must originate from KEYDOWN + std::string text(e.text.text); + // Strip any CR/LF that might slip through from certain platforms/IME behaviors + text.erase(std::remove(text.begin(), text.end(), '\n'), text.end()); + text.erase(std::remove(text.begin(), text.end(), '\r'), text.end()); + if (!text.empty()) { + mi.hasCommand = true; + mi.id = CommandId::InsertText; + mi.arg = std::move(text); + mi.count = 0; + produced = true; + } else { + // Nothing to insert after filtering; consume the event + produced = true; + } + } else { + produced = true; // consumed while k-prefix is active + } + break; + } + default: + break; + } + + if (produced && mi.hasCommand) { + std::lock_guard lk(mu_); + q_.push(mi); + } + return produced; +} + + +bool +ImGuiInputHandler::Poll(MappedInput &out) +{ + std::lock_guard lk(mu_); + if (q_.empty()) + return false; + out = q_.front(); + q_.pop(); + return true; +} \ No newline at end of file diff --git a/GUIInputHandler.h b/ImGuiInputHandler.h similarity index 84% rename from GUIInputHandler.h rename to ImGuiInputHandler.h index 084e3fd..7237bc1 100644 --- a/GUIInputHandler.h +++ b/ImGuiInputHandler.h @@ -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 @@ -10,11 +10,11 @@ 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; - ~GUIInputHandler() override = default; + ~ImGuiInputHandler() override = default; void Attach(Editor *ed) override diff --git a/GUIRenderer.cc b/ImGuiRenderer.cc similarity index 99% rename from GUIRenderer.cc rename to ImGuiRenderer.cc index 2d8fa91..e6c90f2 100644 --- a/GUIRenderer.cc +++ b/ImGuiRenderer.cc @@ -9,7 +9,7 @@ #include #include -#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(); @@ -461,10 +461,10 @@ GUIRenderer::Draw(Editor &ed) 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); + 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); + expanded.c_str() + end); cursor_px = sz.x; } ImVec2 p0 = ImVec2(line_pos.x + cursor_px, line_pos.y); diff --git a/ImGuiRenderer.h b/ImGuiRenderer.h new file mode 100644 index 0000000..4deb834 --- /dev/null +++ b/ImGuiRenderer.h @@ -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; +}; \ No newline at end of file diff --git a/QtFrontend.cc b/QtFrontend.cc new file mode 100644 index 0000000..b2ea9a0 --- /dev/null +++ b/QtFrontend.cc @@ -0,0 +1,988 @@ +#include "QtFrontend.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#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( + 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(lines[i]); + const int y = viewport.y() + static_cast(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 > 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(m. + position()); + std::size_t ex = + sx + static_cast(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(( + (rx_s > coloffs ? rx_s - coloffs : 0) + * ch_w)); + int vx1 = viewport.x() + static_cast(( + (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((rx_s > coloffs + ? rx_s - coloffs + : 0) * ch_w); + int vx1 = viewport.x() + static_cast( + (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(i), buf->Version()); + struct SSpan { + std::size_t s; + std::size_t e; + kte::TokenKind k; + }; + std::vector 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::max( + 0, std::min(s_raw, (int) line_len))); + std::size_t e = static_cast(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(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( + 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(coloffs) < expanded.size()) { + const char *start = + expanded.c_str() + static_cast(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( + 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(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(idx1)); + left += QStringLiteral("/"); + left += QString::number(static_cast(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 " L" + unsigned long lcount = static_cast(buf->Rows().size()); + left += QStringLiteral(" "); + left += QString::number(static_cast(lcount)); + left += QStringLiteral("L"); + } + + // Build right segment: cursor and mark + QString right; + if (buf) { + int row1 = static_cast(buf->Cury()) + 1; + int col1 = static_cast(buf->Curx()) + 1; + bool have_mark = buf->MarkSet(); + int mrow1 = have_mark ? static_cast(buf->MarkCury()) + 1 : 0; + int mcol1 = have_mark ? static_cast(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(1, (avail_h / line_h)); + std::size_t cols = std::max(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(pixel.y()) / std::max(1, line_h); + h_cols_delta = -static_cast(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(angle.y()) / 120.0 * 3.0; + // For horizontal wheels, each notch scrolls 8 columns + h_cols_delta = -static_cast(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(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(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(new_rowoffs) + d_rows; + if (nr < 0) + nr = 0; + const auto nrows = static_cast(buf->Rows().size()); + if (nr > std::max(0L, nrows - 1)) + nr = std::max(0L, nrows - 1); + new_rowoffs = static_cast(nr); + } + if (d_cols != 0) { + long nc = static_cast(new_coloffs) + d_cols; + if (nc < 0) + nc = 0; + new_coloffs = static_cast(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(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(window_)) { + mw->SetFontFamilyAndSize(family, px_size); + } + // Track current font in globals for command/status queries + kte::gCurrentFontFamily = family.toStdString(); + kte::gCurrentFontSize = static_cast(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(1, (avail_h / line_h) + 1); // + status + std::size_t cols = std::max(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(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(window_)) { + mw->SetFontFamilyAndSize(target_family, target_px); + } + // Update globals + kte::gCurrentFontFamily = target_family.toStdString(); + kte::gCurrentFontSize = static_cast(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(1, (avail_h / line_h) + 1); // + status + std::size_t cols = std::max(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(window_)) { + if (mw->WasClosed()) { + running = false; + } + } +} + + +void +GUIFrontend::Shutdown() +{ + if (window_) { + window_->close(); + delete window_; + window_ = nullptr; + } + if (app_) { + delete app_; + app_ = nullptr; + } +} \ No newline at end of file diff --git a/QtFrontend.h b/QtFrontend.h new file mode 100644 index 0000000..ca1fed0 --- /dev/null +++ b/QtFrontend.h @@ -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; +}; \ No newline at end of file diff --git a/QtInputHandler.cc b/QtInputHandler.cc new file mode 100644 index 0000000..0d0c85f --- /dev/null +++ b/QtInputHandler.cc @@ -0,0 +1,538 @@ +// Refactor: route all key processing through command subsystem, mirroring ImGuiInputHandler + +#include "QtInputHandler.h" + +#include + +#include + +#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 +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 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 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 lk(mu_); + q_.push(MappedInput{true, id, std::string(), 0}); + } else { + // Unknown k-command: notify + std::string a; + a.push_back(static_cast(ascii_key)); + LOGF("[QtIH] KPrefix: unknown command for '%c'\n", (char)ascii_key); + std::lock_guard 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 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 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 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 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 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 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 lock(mu_); + if (q_.empty()) + return false; + out = q_.front(); + q_.pop(); + return true; +} \ No newline at end of file diff --git a/QtInputHandler.h b/QtInputHandler.h new file mode 100644 index 0000000..9228229 --- /dev/null +++ b/QtInputHandler.h @@ -0,0 +1,40 @@ +/* + * QtInputHandler - Qt-based input mapping for GUI mode + */ +#pragma once + +#include +#include + +#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 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; +}; \ No newline at end of file diff --git a/QtRenderer.cc b/QtRenderer.cc new file mode 100644 index 0000000..5121a23 --- /dev/null +++ b/QtRenderer.cc @@ -0,0 +1,76 @@ +#include "QtRenderer.h" + +#include +#include +#include +#include +#include + +#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(widget_)) { + ew->SetEditor(&ed); + } + // Request a repaint + widget_->update(); +} \ No newline at end of file diff --git a/QtRenderer.h b/QtRenderer.h new file mode 100644 index 0000000..1f83210 --- /dev/null +++ b/QtRenderer.h @@ -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 +}; \ No newline at end of file diff --git a/default.nix b/default.nix index 680ee11..b726470 100644 --- a/default.nix +++ b/default.nix @@ -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" ]; diff --git a/docs/plans/qt-frontend.md b/docs/plans/qt-frontend.md index f076fc5..af4184b 100644 --- a/docs/plans/qt-frontend.md +++ b/docs/plans/qt-frontend.md @@ -113,4 +113,12 @@ C++ projects than GTK (which is C-based, though `gtkmm` exists). 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. \ No newline at end of file + 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). \ No newline at end of file diff --git a/flake.nix b/flake.nix index 7ff2cc3..176c5bf 100644 --- a/flake.nix +++ b/flake.nix @@ -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; } }); }; } diff --git a/main.cc b/main.cc index ee3775d..c23e635 100644 --- a/main.cc +++ b/main.cc @@ -21,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 @@ -131,33 +135,33 @@ main(int argc, const char *argv[]) unsigned stress_seconds = 0; while ((opt = getopt_long(argc, const_cast(argv), "gthV", long_opts, &long_index)) != -1) { switch (opt) { - case 'g': - req_gui = true; - break; - case 't': - req_term = true; - break; - case 'h': - show_help = true; - break; - case 'V': - show_version = true; - break; - case 1000: { - stress_seconds = 5; // default - if (optarg && *optarg) { - try { - unsigned v = static_cast(std::stoul(optarg)); - if (v > 0 && v < 36000) - stress_seconds = v; - } catch (...) {} - } - break; + case 'g': + req_gui = true; + break; + case 't': + req_term = true; + break; + case 'h': + show_help = true; + break; + case 'V': + show_version = true; + break; + case 1000: { + stress_seconds = 5; // default + if (optarg && *optarg) { + try { + unsigned v = static_cast(std::stoul(optarg)); + if (v > 0 && v < 36000) + stress_seconds = v; + } catch (...) {} } - case '?': - default: - PrintUsage(argv[0]); - return 2; + break; + } + case '?': + default: + PrintUsage(argv[0]); + return 2; } }