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 @@
-
+
+
+
+
+
+
+
+
+
+
@@ -128,7 +137,7 @@
-
+
@@ -162,7 +171,7 @@
1764457173148
-
+
@@ -220,7 +229,15 @@
1764501532446
-
+
+
+ 1764502480274
+
+
+
+ 1764502480274
+
+
@@ -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)) {