aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorsl0p <sl0p@sl0p.foo>2026-08-13 23:50:24 +0200
committersl0p <sl0p@sl0p.foo>2026-08-13 23:50:24 +0200
commitb514f68d9535e0ee882eecbbac217486f999f538 (patch)
tree1175f74690a61080a8b2d9339335f8b42ef52f05 /src
parentsl0ptty: focus follows the mouse, with the guards that make it liveable (diff)
downloadslosh-b514f68d9535e0ee882eecbbac217486f999f538.tar.gz
slosh-b514f68d9535e0ee882eecbbac217486f999f538.tar.xz
slosh-b514f68d9535e0ee882eecbbac217486f999f538.zip
sl0ptty: scrollback — wheel, keyboard, and a clickable indicator
libghostty-vt already keeps the scrollback, so the work was deciding whose wheel it is: - a program tracking the mouse gets the wheel events themselves; - a full-screen program without mouse tracking (vim, less) has no scrollback of ours to show, so the wheel becomes arrow keys, which is what it means to that program; - anything else scrolls our viewport. All three are tested against real programs rather than asserted in principle. C-a PageUp/PageDown/Home/End, and scroll_lines in the config for the wheel. Typing snaps back to the live view, because reading scrollback and then typing into a screen you are not looking at is a trap. New output does not yank the viewport back — it just grows the gap, which the indicator shows. The indicator is compact, right-aligned, budgeted after the split button and before the title (a title is the thing you can most afford to lose), and clicking it returns to the bottom. Three test failures on the way, all mine and all instructive: the top of the scrollback is the shell's first prompt rather than the first line of output; typing without a newline types rather than runs; and two side-by-side panes share a screen row, so asserting on line(y) reads both frames at once. 341 checks green.
Diffstat (limited to 'src')
-rw-r--r--src/app.c52
-rw-r--r--src/config.c11
-rw-r--r--src/config.h7
-rw-r--r--src/pane.c48
-rw-r--r--src/sl0ptty.h7
5 files changed, 125 insertions, 0 deletions
diff --git a/src/app.c b/src/app.c
index 00fe474..cd2e899 100644
--- a/src/app.c
+++ b/src/app.c
@@ -1095,6 +1095,25 @@ static void draw_frame(app_t *a, screen_t *s, node_t *leaf) {
draw_pane_status(s, leaf, fg, focused);
+ /* Scroll position, when there is one: compact, right-aligned, and clickable
+ * to get back to the bottom. Budgeted after the button and before the title,
+ * because a title is the thing you can most afford to lose. */
+ if (pane_scrolled(leaf->pane) && avail >= 8) {
+ uint32_t above = 0, total = 0;
+ pane_scroll_pos(leaf->pane, &above, &total);
+ char ind[24];
+ int n = snprintf(ind, sizeof ind, " \u25b2%u ", total > above ? total - above : 0);
+ uint16_t iw = (uint16_t)(n > 0 ? n : 0);
+ if (iw + 2 < avail) {
+ uint16_t ix = (uint16_t)((has_btn ? btn_x : x1) - iw);
+ screen_text(s, ix, r.y, ind, BTN_FG, BTN_BG, ATTR_BOLD);
+ char action[48];
+ snprintf(action, sizeof action, "scrollbottom:%u", leaf->id);
+ hit_add(&s->hits, ix, r.y, iw, 1, action);
+ avail = (uint16_t)(avail - iw);
+ }
+ }
+
const char *title = pane_title(leaf->pane);
if (title && *title && avail >= 3) {
char buf[256];
@@ -1531,6 +1550,8 @@ static void do_action(app_t *a, const char *action, const input_event_t *ev) {
split_focus(a, SPLIT_COLS);
} else if (strncmp(action, "close:", 6) == 0) {
close_leaf(a, n);
+ } else if (strncmp(action, "scrollbottom:", 13) == 0) {
+ if (ev->maction == MOUSE_PRESS) pane_scroll_edge(n->pane, false);
} else if (strncmp(action, "pane:", 5) == 0) {
/* A press always focuses; a hover only when it is allowed to. The event
* is forwarded either way, so a pane that tracks the mouse still sees the
@@ -1540,6 +1561,27 @@ static void do_action(app_t *a, const char *action, const input_event_t *ev) {
input_event_t local = *ev;
local.mx = (uint16_t)(ev->mx - n->content.x);
local.my = (uint16_t)(ev->my - n->content.y);
+
+ /* The wheel belongs to whoever can use it:
+ * - a program tracking the mouse gets the wheel events;
+ * - a full-screen program without mouse tracking (vim, less) has no
+ * scrollback of ours to show, so the wheel becomes arrow keys, which
+ * is what it means to that program;
+ * - anything else scrolls our scrollback. */
+ bool wheel = ev->maction == MOUSE_PRESS &&
+ (ev->button == MBTN_FOUR || ev->button == MBTN_FIVE);
+ if (wheel && !pane_wants_mouse(n->pane)) {
+ int lines = CFG.scroll_lines;
+ bool up = ev->button == MBTN_FOUR;
+ if (pane_alt_screen(n->pane)) {
+ input_event_t key = {.kind = EV_KEY, .action = KEY_PRESS};
+ key.key = up ? GHOSTTY_KEY_ARROW_UP : GHOSTTY_KEY_ARROW_DOWN;
+ for (int i = 0; i < lines; i++) pane_send_key(n->pane, &key);
+ } else {
+ pane_scroll(n->pane, up ? -lines : lines);
+ }
+ return;
+ }
pane_send_mouse(n->pane, &local);
} else if (strncmp(action, "focus:", 6) == 0) {
cur(a)->focus = n;
@@ -1570,6 +1612,16 @@ static bool prefix_command(app_t *a, const input_event_t *ev) {
case ACT_RESIZE_RIGHT: resize_focus(a, 1, 0); return true;
case ACT_RESIZE_UP: resize_focus(a, 0, -1); return true;
case ACT_RESIZE_DOWN: resize_focus(a, 0, 1); return true;
+ case ACT_SCROLL_UP: pane_scroll(cur(a)->focus->pane, -CFG.scroll_lines); return true;
+ case ACT_SCROLL_DOWN: pane_scroll(cur(a)->focus->pane, CFG.scroll_lines); return true;
+ case ACT_SCROLL_PAGE_UP:
+ pane_scroll(cur(a)->focus->pane, -(int)cur(a)->focus->content.h);
+ return true;
+ case ACT_SCROLL_PAGE_DOWN:
+ pane_scroll(cur(a)->focus->pane, (int)cur(a)->focus->content.h);
+ return true;
+ case ACT_SCROLL_TOP: pane_scroll_edge(cur(a)->focus->pane, true); return true;
+ case ACT_SCROLL_BOTTOM: pane_scroll_edge(cur(a)->focus->pane, false); return true;
case ACT_FINDER:
a->finder = true;
a->query[0] = 0;
diff --git a/src/config.c b/src/config.c
index b5ae284..24f93e7 100644
--- a/src/config.c
+++ b/src/config.c
@@ -19,6 +19,10 @@ static const struct {
{"focus-down", ACT_FOCUS_DOWN}, {"focus-next", ACT_FOCUS_NEXT},
{"resize-left", ACT_RESIZE_LEFT}, {"resize-right", ACT_RESIZE_RIGHT},
{"resize-up", ACT_RESIZE_UP}, {"resize-down", ACT_RESIZE_DOWN},
+ {"scroll-up", ACT_SCROLL_UP}, {"scroll-down", ACT_SCROLL_DOWN},
+ {"scroll-page-up", ACT_SCROLL_PAGE_UP},
+ {"scroll-page-down", ACT_SCROLL_PAGE_DOWN},
+ {"scroll-top", ACT_SCROLL_TOP}, {"scroll-bottom", ACT_SCROLL_BOTTOM},
{"new-tab", ACT_NEW_TAB}, {"next-tab", ACT_NEXT_TAB},
{"prev-tab", ACT_PREV_TAB}, {"finder", ACT_FINDER},
{"detach", ACT_DETACH}, {"quit", ACT_QUIT},
@@ -121,6 +125,7 @@ void config_defaults(config_t *c) {
c->title_align = ALIGN_CENTER;
c->min_pane_cols = 24;
c->min_pane_rows = 6;
+ c->scroll_lines = 3;
c->status_bar = true;
c->focus_follows_mouse = true;
@@ -158,6 +163,10 @@ void config_defaults(config_t *c) {
bind_add(c, GHOSTTY_KEY_N, 0, ACT_NEXT_TAB);
bind_add(c, GHOSTTY_KEY_P, 0, ACT_PREV_TAB);
bind_add(c, GHOSTTY_KEY_F, 0, ACT_FINDER);
+ bind_add(c, GHOSTTY_KEY_PAGE_UP, 0, ACT_SCROLL_PAGE_UP);
+ bind_add(c, GHOSTTY_KEY_PAGE_DOWN, 0, ACT_SCROLL_PAGE_DOWN);
+ bind_add(c, GHOSTTY_KEY_HOME, 0, ACT_SCROLL_TOP);
+ bind_add(c, GHOSTTY_KEY_END, 0, ACT_SCROLL_BOTTOM);
bind_add(c, GHOSTTY_KEY_D, 0, ACT_DETACH);
bind_add(c, GHOSTTY_KEY_Q, 0, ACT_QUIT);
for (int i = 0; i < 9; i++)
@@ -232,6 +241,8 @@ bool config_load(config_t *c, const char *path, char *err, size_t errcap) {
c->min_pane_cols = (uint16_t)kdl_prop_int(minp, "cols", c->min_pane_cols);
c->min_pane_rows = (uint16_t)kdl_prop_int(minp, "rows", c->min_pane_rows);
}
+ c->scroll_lines =
+ (uint16_t)kdl_arg_int(kdl_child(root, "scroll_lines"), 0, c->scroll_lines);
const char *sh = kdl_arg(kdl_child(root, "shell"), 0, NULL);
if (sh) {
diff --git a/src/config.h b/src/config.h
index 246b03a..a53d7c9 100644
--- a/src/config.h
+++ b/src/config.h
@@ -27,6 +27,12 @@ typedef enum {
ACT_NEXT_TAB,
ACT_PREV_TAB,
ACT_FINDER,
+ ACT_SCROLL_UP,
+ ACT_SCROLL_DOWN,
+ ACT_SCROLL_PAGE_UP,
+ ACT_SCROLL_PAGE_DOWN,
+ ACT_SCROLL_TOP,
+ ACT_SCROLL_BOTTOM,
ACT_RESIZE_LEFT,
ACT_RESIZE_RIGHT,
ACT_RESIZE_UP,
@@ -51,6 +57,7 @@ typedef struct {
bool rounded;
align_t title_align;
uint16_t min_pane_cols, min_pane_rows;
+ uint16_t scroll_lines; /* rows per wheel notch */
bool status_bar;
bool focus_follows_mouse;
diff --git a/src/pane.c b/src/pane.c
index 38b8f41..6d3bc4b 100644
--- a/src/pane.c
+++ b/src/pane.c
@@ -308,6 +308,9 @@ void pane_write(pane_t *p, const void *buf, size_t len) {
* actually negotiated (kitty flags, cursor-key application mode, alt-esc
* prefix, modifyOtherKeys). This is the reason we decode at all. */
void pane_send_key(pane_t *p, const input_event_t *ev) {
+ /* Typing snaps back to the live view: reading scrollback and then typing
+ * into a screen that is not the one you are looking at is a trap. */
+ if (pane_scrolled(p)) pane_scroll_edge(p, false);
ghostty_key_encoder_setopt_from_terminal(p->kenc, p->term);
ghostty_key_event_set_key(p->kev, (GhosttyKey)ev->key);
ghostty_key_event_set_mods(p->kev, (GhosttyMods)ev->mods);
@@ -370,6 +373,51 @@ void pane_send_paste(pane_t *p, const char *text, size_t len) {
free(buf);
}
+/* ---- scrollback --------------------------------------------------------- */
+
+void pane_scroll(pane_t *p, int delta) {
+ GhosttyTerminalScrollViewport b = {.tag = GHOSTTY_SCROLL_VIEWPORT_DELTA};
+ b.value.delta = delta;
+ ghostty_terminal_scroll_viewport(p->term, b);
+ p->dirty = true;
+}
+
+void pane_scroll_edge(pane_t *p, bool top) {
+ GhosttyTerminalScrollViewport b = {
+ .tag = top ? GHOSTTY_SCROLL_VIEWPORT_TOP : GHOSTTY_SCROLL_VIEWPORT_BOTTOM};
+ ghostty_terminal_scroll_viewport(p->term, b);
+ p->dirty = true;
+}
+
+bool pane_scrolled(const pane_t *p) {
+ bool active = true; /* "the viewport is on the active area", i.e. the bottom */
+ ghostty_terminal_get(p->term, GHOSTTY_TERMINAL_DATA_VIEWPORT_ACTIVE, &active);
+ return !active;
+}
+
+/* Rows hidden above the viewport, and how many exist in total. */
+void pane_scroll_pos(const pane_t *p, uint32_t *above, uint32_t *total) {
+ GhosttyTerminalScrollbar sb = {0};
+ *above = *total = 0;
+ if (ghostty_terminal_get(p->term, GHOSTTY_TERMINAL_DATA_SCROLLBAR, &sb) !=
+ GHOSTTY_SUCCESS)
+ return;
+ *above = (uint32_t)sb.offset;
+ *total = (uint32_t)(sb.total > sb.len ? sb.total - sb.len : 0);
+}
+
+bool pane_alt_screen(const pane_t *p) {
+ GhosttyTerminalScreen screen = GHOSTTY_TERMINAL_SCREEN_PRIMARY;
+ ghostty_terminal_get(p->term, GHOSTTY_TERMINAL_DATA_ACTIVE_SCREEN, &screen);
+ return screen == GHOSTTY_TERMINAL_SCREEN_ALTERNATE;
+}
+
+bool pane_wants_mouse(const pane_t *p) {
+ GhosttyMouseTrackingMode mode = GHOSTTY_MOUSE_TRACKING_NONE;
+ ghostty_terminal_get(p->term, GHOSTTY_TERMINAL_DATA_MOUSE_TRACKING, &mode);
+ return mode != GHOSTTY_MOUSE_TRACKING_NONE;
+}
+
void pane_resize(pane_t *p, uint16_t cols, uint16_t rows) {
if (cols == p->cols && rows == p->rows_n) return;
p->cols = cols;
diff --git a/src/sl0ptty.h b/src/sl0ptty.h
index eab209c..71ba72e 100644
--- a/src/sl0ptty.h
+++ b/src/sl0ptty.h
@@ -143,6 +143,13 @@ void pane_send_mouse(pane_t *p, const input_event_t *ev);
void pane_send_paste(pane_t *p, const char *text, size_t len);
void pane_resize(pane_t *p, uint16_t cols, uint16_t rows);
bool pane_dirty(pane_t *p);
+/* Scrollback. Negative delta scrolls up (towards older output). */
+void pane_scroll(pane_t *p, int delta);
+void pane_scroll_edge(pane_t *p, bool top);
+bool pane_scrolled(const pane_t *p);
+void pane_scroll_pos(const pane_t *p, uint32_t *above, uint32_t *total);
+bool pane_alt_screen(const pane_t *p);
+bool pane_wants_mouse(const pane_t *p);
const char *pane_title(const pane_t *p);
const char *pane_status(const pane_t *p);
size_t pane_buttons(const pane_t *p, const pane_button_t **out);