diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 9a6308e..14a36c6 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -33,8 +33,17 @@ - + + + + + + + + + + - + @@ -241,7 +258,8 @@ - diff --git a/CMakeLists.txt b/CMakeLists.txt index d0e588b..f5c481a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -4,7 +4,7 @@ project(kte) include(GNUInstallDirs) set(CMAKE_CXX_STANDARD 17) -set(KTE_VERSION "0.1.0") +set(KTE_VERSION "1.0.0") # Default to terminal-only build to avoid SDL/OpenGL dependency by default. # Enable with -DBUILD_GUI=ON when SDL2/OpenGL/Freetype are available. @@ -167,11 +167,11 @@ if (${BUILD_GUI}) install(TARGETS kge BUNDLE DESTINATION . ) - else() + else () install(TARGETS kge RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} ) - endif() + endif () # Install kge man page only when GUI is built install(FILES docs/kge.1 DESTINATION ${CMAKE_INSTALL_MANDIR}/man1) endif () diff --git a/Command.cc b/Command.cc index 593c208..bd3b35e 100644 --- a/Command.cc +++ b/Command.cc @@ -646,6 +646,16 @@ cmd_open_file_start(CommandContext &ctx) } +static bool +cmd_jump_to_line_start(CommandContext &ctx) +{ + // Start a prompt to read a 1-based line number and jump there (clamped) + ctx.editor.StartPrompt(Editor::PromptKind::GotoLine, "Goto", ""); + ctx.editor.SetStatus("Goto line: "); + return true; +} + + // --- Buffers: switch/next/prev/close --- static bool cmd_buffer_switch_start(CommandContext &ctx) @@ -990,6 +1000,37 @@ cmd_newline(CommandContext &ctx) } else { ctx.editor.SetStatus("Nothing to confirm"); } + } else if (kind == Editor::PromptKind::GotoLine) { + Buffer *buf = ctx.editor.CurrentBuffer(); + if (!buf) { + ctx.editor.SetStatus("No buffer"); + return true; + } + std::size_t nrows = buf->Nrows(); + if (nrows == 0) { + buf->SetCursor(0, 0); + ensure_cursor_visible(ctx.editor, *buf); + ctx.editor.SetStatus("Empty buffer"); + return true; + } + // Parse 1-based line number; on failure, keep cursor and show status + std::size_t line1 = 0; + try { + if (!value.empty()) + line1 = static_cast(std::stoull(value)); + } catch (...) { + line1 = 0; + } + if (line1 == 0) { + ctx.editor.SetStatus("Goto canceled (invalid line)"); + return true; + } + std::size_t y = line1 - 1; // convert to 0-based + if (y >= nrows) + y = nrows - 1; // clamp to last line + buf->SetCursor(0, y); + ensure_cursor_visible(ctx.editor, *buf); + ctx.editor.SetStatus("Goto line " + std::to_string(line1)); } return true; } @@ -2372,6 +2413,10 @@ InstallDefaultCommands() CommandRegistry::Register({ CommandId::MoveCursorTo, "move-cursor-to", "Move cursor to y:x", cmd_move_cursor_to }); + // Direct navigation by line number + CommandRegistry::Register({ + CommandId::JumpToLine, "goto-line", "Prompt for line and jump", cmd_jump_to_line_start + }); // Undo/Redo CommandRegistry::Register({CommandId::Undo, "undo", "Undo last edit", cmd_undo}); CommandRegistry::Register({CommandId::Redo, "redo", "Redo edit", cmd_redo}); diff --git a/Command.h b/Command.h index f7721fc..c428070 100644 --- a/Command.h +++ b/Command.h @@ -72,6 +72,8 @@ enum class CommandId { // Buffer operations ReloadBuffer, // reload buffer from disk (C-k l) MarkAllAndJumpEnd, // set mark at beginning, jump to end (C-k a) + // Direct navigation by line number + JumpToLine, // prompt for line and jump (C-k g) // Meta UnknownKCommand, // arg: single character that was not recognized after C-k }; diff --git a/Editor.h b/Editor.h index 2b593c2..4c42716 100644 --- a/Editor.h +++ b/Editor.h @@ -302,7 +302,7 @@ public: // --- Generic Prompt subsystem (for search, open-file, save-as, etc.) --- - enum class PromptKind { None = 0, Search, OpenFile, SaveAs, Confirm, BufferSwitch }; + enum class PromptKind { None = 0, Search, OpenFile, SaveAs, Confirm, BufferSwitch, GotoLine }; void StartPrompt(PromptKind kind, const std::string &label, const std::string &initial) diff --git a/GUIInputHandler.cc b/GUIInputHandler.cc index 39df226..d0462de 100644 --- a/GUIInputHandler.cc +++ b/GUIInputHandler.cc @@ -279,6 +279,24 @@ GUIInputHandler::ProcessSDLEvent(const SDL_Event &e) MappedInput mi; bool produced = false; switch (e.type) { + case SDL_MOUSEWHEEL: { + // Map vertical wheel to line-wise cursor movement (MoveUp/MoveDown) + 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::MoveUp : CommandId::MoveDown; + 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_; diff --git a/GUIRenderer.cc b/GUIRenderer.cc index a4dcf86..8678290 100644 --- a/GUIRenderer.cc +++ b/GUIRenderer.cc @@ -45,7 +45,7 @@ GUIRenderer::Draw(Editor &ed) const auto &lines = buf->Rows(); // Reserve space for status bar at bottom ImGui::BeginChild("scroll", ImVec2(0, -ImGui::GetFrameHeightWithSpacing()), false, - ImGuiWindowFlags_HorizontalScrollbar); + ImGuiWindowFlags_HorizontalScrollbar | ImGuiWindowFlags_NoScrollWithMouse); // Detect click-to-move inside this scroll region ImVec2 list_origin = ImGui::GetCursorScreenPos(); float scroll_y = ImGui::GetScrollY(); @@ -56,24 +56,42 @@ GUIRenderer::Draw(Editor &ed) const float line_h = ImGui::GetTextLineHeight(); const float row_h = ImGui::GetTextLineHeightWithSpacing(); const float space_w = ImGui::CalcTextSize(" ").x; - // If the command layer requested a specific top-of-screen (via Buffer::Rowoffs), - // force the ImGui scroll to match so paging aligns the first visible row. + // Two-way sync between Buffer::Rowoffs and ImGui scroll position: + // - If command layer changed Buffer::Rowoffs since last frame, drive ImGui scroll from it. + // - Otherwise, propagate ImGui scroll to Buffer::Rowoffs so command layer has an up-to-date view. + // This prevents clicks/wheel from being immediately overridden by stale offsets. bool forced_scroll = false; { - std::size_t desired_top = buf->Rowoffs(); - long current_top = static_cast(scroll_y / row_h); - if (static_cast(desired_top) != current_top) { - ImGui::SetScrollY(static_cast(desired_top) * row_h); + static long prev_buf_rowoffs = -1; // previous frame's Buffer::Rowoffs + static float prev_scroll_y = -1.0f; // previous frame's ImGui scroll Y in pixels + + const long buf_rowoffs = static_cast(buf->Rowoffs()); + const long scroll_top = static_cast(scroll_y / row_h); + + // Detect programmatic change (e.g., keyboard navigation ensured visibility) + if (prev_buf_rowoffs >= 0 && buf_rowoffs != prev_buf_rowoffs) { + ImGui::SetScrollY(static_cast(buf_rowoffs) * row_h); scroll_y = ImGui::GetScrollY(); forced_scroll = true; + } else { + // If user scrolled (scroll_y changed), update buffer row offset accordingly + if (prev_scroll_y >= 0.0f && scroll_y != prev_scroll_y) { + if (Buffer *mbuf = const_cast(buf)) { + // Keep horizontal offset owned by GUI; only update vertical offset here + mbuf->SetOffsets(static_cast(std::max(0L, scroll_top)), + mbuf->Coloffs()); + } + } } + + // Update trackers for next frame + prev_buf_rowoffs = static_cast(buf->Rowoffs()); + prev_scroll_y = ImGui::GetScrollY(); } // Synchronize cursor and scrolling. - // A) When the user scrolls and the cursor goes off-screen, move the cursor to the nearest visible row. - // B) When the cursor moves (via keyboard commands), scroll it back into view. + // Ensure the cursor is visible even on the first frame or when it didn't move, + // unless we already forced scrolling from Buffer::Rowoffs this frame. { - static float prev_scroll_y = -1.0f; - static long prev_cursor_y = -1; // Compute visible row range using the child window height float child_h = ImGui::GetWindowHeight(); long first_row = static_cast(scroll_y / row_h); @@ -82,37 +100,7 @@ GUIRenderer::Draw(Editor &ed) vis_rows = 1; long last_row = first_row + vis_rows - 1; - // A) If user scrolled (scroll_y changed), and cursor outside, move cursor to nearest visible row - // Skip this when we just forced a scroll alignment this frame (programmatic change). - if (!forced_scroll && prev_scroll_y >= 0.0f && scroll_y != prev_scroll_y) { - long cyr = static_cast(cy); - if (cyr < first_row || cyr > last_row) { - long new_row = (cyr < first_row) ? first_row : last_row; - if (new_row < 0) - new_row = 0; - if (new_row >= static_cast(lines.size())) - new_row = static_cast(lines.empty() ? 0 : (lines.size() - 1)); - // Clamp column to line length - std::size_t new_col = 0; - if (!lines.empty()) { - const std::string &l = lines[static_cast(new_row)]; - new_col = std::min(cx, l.size()); - } - char tmp2[64]; - std::snprintf(tmp2, sizeof(tmp2), "%ld:%zu", new_row, new_col); - Execute(ed, CommandId::MoveCursorTo, std::string(tmp2)); - cy = buf->Cury(); - cx = buf->Curx(); - cyr = static_cast(cy); - // Update visible range again in case content changed - first_row = static_cast(ImGui::GetScrollY() / row_h); - last_row = first_row + vis_rows - 1; - } - } - - // B) If cursor moved since last frame and is outside the visible region, scroll to reveal it - // Skip this when we just forced a top-of-screen alignment this frame. - if (!forced_scroll && prev_cursor_y >= 0 && static_cast(cy) != prev_cursor_y) { + if (!forced_scroll) { long cyr = static_cast(cy); if (cyr < first_row || cyr > last_row) { float target = (static_cast(cyr) - std::max(0L, vis_rows / 2)) * row_h; @@ -128,9 +116,6 @@ GUIRenderer::Draw(Editor &ed) last_row = first_row + vis_rows - 1; } } - - prev_scroll_y = ImGui::GetScrollY(); - prev_cursor_y = static_cast(cy); } // Handle mouse click before rendering to avoid dependent on drawn items if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { @@ -245,8 +230,16 @@ GUIRenderer::Draw(Editor &ed) std::snprintf(rbuf, sizeof(rbuf), "%d,%d | M: not set", row1, col1); std::string right = rbuf; - // Middle message - const std::string &msg = ed.Status(); + // Middle message: if a prompt is active, show "Label: text"; otherwise show status + std::string msg; + if (ed.PromptActive()) { + msg = ed.PromptLabel(); + if (!msg.empty()) + msg += ": "; + msg += ed.PromptText(); + } else { + msg = ed.Status(); + } // Measurements ImVec2 left_sz = ImGui::CalcTextSize(left.c_str()); diff --git a/KKeymap.cc b/KKeymap.cc index 6f93037..08ea384 100644 --- a/KKeymap.cc +++ b/KKeymap.cc @@ -86,6 +86,9 @@ KLookupKCommand(const int ascii_key, const bool ctrl, CommandId &out) -> bool case 'a': out = CommandId::MarkAllAndJumpEnd; return true; // C-k a (mark all and jump to end) + case 'g': + out = CommandId::JumpToLine; + return true; // C-k g (goto line) default: break; } diff --git a/TerminalInputHandler.cc b/TerminalInputHandler.cc index b4a683a..7fb6fb7 100644 --- a/TerminalInputHandler.cc +++ b/TerminalInputHandler.cc @@ -35,6 +35,19 @@ map_key_to_command(const int ch, case KEY_MOUSE: { MEVENT ev{}; if (getmouse(&ev) == OK) { + // Mouse wheel → map to MoveUp/MoveDown one line per wheel notch +#ifdef BUTTON4_PRESSED + if (ev.bstate & (BUTTON4_PRESSED | BUTTON4_RELEASED | BUTTON4_CLICKED)) { + out = {true, CommandId::MoveUp, "", 0}; + return true; + } +#endif +#ifdef BUTTON5_PRESSED + if (ev.bstate & (BUTTON5_PRESSED | BUTTON5_RELEASED | BUTTON5_CLICKED)) { + out = {true, CommandId::MoveDown, "", 0}; + return true; + } +#endif // React to left button click/press if (ev.bstate & (BUTTON1_CLICKED | BUTTON1_PRESSED | BUTTON1_RELEASED)) { char buf[64]; diff --git a/main.cc b/main.cc index 855a1b7..4a9fe7c 100644 --- a/main.cc +++ b/main.cc @@ -1,7 +1,12 @@ #include +#include #include +#include #include #include +#include +#include +#include #include "Editor.h" #include "Command.h" @@ -28,6 +33,109 @@ PrintUsage(const char *prog) } +#if defined(KTE_BUILD_GUI) +// Detach the process from the controlling terminal when running the GUI so the +// launching terminal can be closed. This mirrors typical GUI app behavior on +// POSIX systems. +static void +DetachFromTerminalIfGUI(bool use_gui) +{ +#if defined(__APPLE__) || defined(__linux__) || defined(__unix__) + if (!use_gui) + return; + + // Ignore SIGHUP so closing the terminal won't terminate us. + signal(SIGHUP, SIG_IGN); + + // Helper: redirect stdio to /dev/null and optionally close extra FDs. + auto redirect_stdio_and_close = []() { + // Reset file mode creation mask and working directory to a safe default + umask(0); + chdir("/"); + + FILE *fnull_r = fopen("/dev/null", "r"); + if (fnull_r) { + dup2(fileno(fnull_r), STDIN_FILENO); + } + FILE *fnull_w = fopen("/dev/null", "w"); + if (fnull_w) { + dup2(fileno(fnull_w), STDOUT_FILENO); + dup2(fileno(fnull_w), STDERR_FILENO); + } + + // Close any other inherited FDs to avoid keeping terminal/pty or pipes open + long max_fd = sysconf(_SC_OPEN_MAX); + if (max_fd < 0) + max_fd = 256; // conservative fallback + for (long fd = 3; fd < max_fd; ++fd) { + close(static_cast(fd)); + } + }; + +#if defined(__APPLE__) + // macOS: daemon(3) is deprecated and treated as an error with -Werror. + // Use double-fork + setsid and redirect stdio to /dev/null. + pid_t pid = fork(); + if (pid < 0) { + return; + } + + if (pid > 0) { + _exit(0); + } + + if (setsid() < 0) { + return; + } + + pid_t pid2 = fork(); + if (pid2 < 0) { + return; + } + + if (pid2 > 0) { + _exit(0); + } + + redirect_stdio_and_close(); +#else + // Prefer daemon(3) on non-Apple POSIX; fall back to manual detach if it fails. + if (daemon(0, 0) == 0) { + redirect_stdio_and_close(); + return; + } + + pid_t pid = fork(); + if (pid < 0) { + return; + } + + if (pid > 0) { + _exit(0); + } + + if (setsid() < 0) { + // bogus check + } + + pid_t pid2 = fork(); + if (pid2 < 0) { + return; + } + + if (pid2 > 0) { + _exit(0); + } + + redirect_stdio_and_close(); +#endif +#else + (void) use_gui; +#endif +} +#endif + + int main(int argc, const char *argv[]) { @@ -104,18 +212,67 @@ main(int argc, const char *argv[]) use_gui = false; #endif } + // If using GUI, detach from the controlling terminal so the terminal can be closed. + DetachFromTerminalIfGUI(use_gui); #endif - // Open files passed on the CLI; if none, create an empty buffer + // Open files passed on the CLI; support +N to jump to line N in the next file. + // If no files are provided, create an empty buffer. if (optind < argc) { + std::size_t pending_line = 0; // 0 = no pending line for (int i = optind; i < argc; ++i) { + const char *arg = argv[i]; + if (arg && arg[0] == '+') { + // Parse + + const char *p = arg + 1; + if (*p != '\0') { + bool all_digits = true; + for (const char *q = p; *q; ++q) { + if (!std::isdigit(static_cast(*q))) { + all_digits = false; + break; + } + } + if (all_digits) { + // Clamp to >=1 later; 0 disables. + try { + unsigned long v = std::stoul(p); + pending_line = static_cast(v); + } catch (...) { + // Ignore malformed huge numbers + pending_line = 0; + } + continue; // look for the next file arg + } + } + // Fall through: not a +number, treat as filename starting with '+' + } + std::string err; - const std::string path = argv[i]; + const std::string path = arg; if (!editor.OpenFile(path, err)) { editor.SetStatus("open: " + err); std::cerr << "kte: " << err << "\n"; + } else if (pending_line > 0) { + // Apply pending +N to the just-opened (current) buffer + if (Buffer *b = editor.CurrentBuffer()) { + std::size_t nrows = b->Nrows(); + std::size_t line = pending_line > 0 ? pending_line - 1 : 0; + // 1-based to 0-based + if (nrows > 0) { + if (line >= nrows) + line = nrows - 1; + } else { + line = 0; + } + b->SetCursor(0, line); + // Do not force viewport offsets here; the frontend/renderer + // will establish dimensions and normalize visibility on first draw. + } + pending_line = 0; // consumed } } + // If we ended with a pending +N but no subsequent file, ignore it. } else { // Create a single empty buffer editor.AddBuffer(Buffer()); @@ -129,11 +286,11 @@ main(int argc, const char *argv[]) std::unique_ptr fe; #if defined(KTE_BUILD_GUI) if (use_gui) { - fe.reset(new GUIFrontend()); + fe = std::make_unique(); } else #endif { - fe.reset(new TerminalFrontend()); + fe = std::make_unique(); } if (!fe->Init(editor)) {