diff options
| author | sl0p <sl0p@sl0p.foo> | 2026-08-13 23:50:24 +0200 |
|---|---|---|
| committer | sl0p <sl0p@sl0p.foo> | 2026-08-13 23:50:24 +0200 |
| commit | b514f68d9535e0ee882eecbbac217486f999f538 (patch) | |
| tree | 1175f74690a61080a8b2d9339335f8b42ef52f05 | |
| parent | sl0ptty: focus follows the mouse, with the guards that make it liveable (diff) | |
| download | slosh-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.
| -rw-r--r-- | Makefile | 2 | ||||
| -rw-r--r-- | README.md | 1 | ||||
| -rw-r--r-- | config/config.kdl | 14 | ||||
| -rw-r--r-- | src/app.c | 52 | ||||
| -rw-r--r-- | src/config.c | 11 | ||||
| -rw-r--r-- | src/config.h | 7 | ||||
| -rw-r--r-- | src/pane.c | 48 | ||||
| -rw-r--r-- | src/sl0ptty.h | 7 | ||||
| -rw-r--r-- | tests/test_scroll.py | 200 |
9 files changed, 340 insertions, 2 deletions
@@ -58,7 +58,7 @@ test: $(BIN) $(TEST_BIN) $(KDL_TEST) ## unit + headless checks (fast) @echo cd tests && python3 test_screen.py && python3 test_layout.py && python3 test_tabs.py && python3 test_osc5577.py && \ python3 test_responsive.py && python3 test_finder.py && \ - python3 test_config.py && python3 test_layout_files.py && python3 test_drag.py && python3 test_hover.py + python3 test_config.py && python3 test_layout_files.py && python3 test_drag.py && python3 test_hover.py && python3 test_scroll.py test-live: $(BIN) ## the checks that need a real tty (slow) python3 tests/live_m0.py @@ -48,6 +48,7 @@ sl0ptty cmd '{"cmd":"panes"}' | `C-a c` / `C-a n` `C-a p` / `C-a 1..9` | new tab / cycle / select | | `C-a f` | find a pane by title, purpose or tab | | `C-a H J K L` or shift+arrows | move the boundary (the pane grows or shrinks accordingly) | +| `C-a PgUp` `C-a PgDn` `C-a Home` `C-a End` | scrollback (the wheel does it too) | | `C-a x` / `C-a d` / `C-a q` | close pane / detach / quit | | `C-a C-a` | send a literal `C-a` | diff --git a/config/config.kdl b/config/config.kdl index 82a8c77..ef9367f 100644 --- a/config/config.kdl +++ b/config/config.kdl @@ -38,6 +38,9 @@ focus_follows_mouse true // widening the terminal always restores exactly the layout you had. min_pane cols=24 rows=6 +// Rows per wheel notch, and per scroll-up/scroll-down. +scroll_lines 3 + // What a new pane runs. Unset means $SHELL. // shell "/bin/bash" @@ -61,7 +64,8 @@ theme { // // Actions: split-cols split-rows close-pane focus-left focus-right focus-up // focus-down focus-next resize-left resize-right resize-up resize-down -// new-tab next-tab prev-tab select-tab-1..9 finder detach quit +// scroll-up scroll-down scroll-page-up scroll-page-down scroll-top +// scroll-bottom new-tab next-tab prev-tab select-tab-1..9 finder detach quit // literal-prefix none keys { @@ -96,6 +100,14 @@ keys { bind "3" "select-tab-3" bind "f" "finder" + + // Scrollback. The wheel does this too, unless the program in the pane + // wants the mouse itself, or is a full-screen program with no scrollback + // of ours to show — then the wheel means arrow keys to it. + bind "pageup" "scroll-page-up" + bind "pagedown" "scroll-page-down" + bind "home" "scroll-top" + bind "end" "scroll-bottom" bind "d" "detach" bind "q" "quit" } @@ -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; @@ -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); diff --git a/tests/test_scroll.py b/tests/test_scroll.py new file mode 100644 index 0000000..da98040 --- /dev/null +++ b/tests/test_scroll.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +"""Scrollback: the wheel, the keyboard, the indicator, and who owns the wheel. + +The interesting part is not scrolling — libghostty-vt keeps the scrollback for +us — it is deciding whose wheel it is. A program tracking the mouse gets the +events; a full-screen program without mouse tracking has no scrollback of ours +to show, so the wheel means arrow keys to it; everything else scrolls us. +""" +import sys + +from harness import Session, check, report + +# a shell whose output we can generate on demand +SH = ["/bin/sh", "-c", "stty raw -echo; sh"] + + +def wheel(s, x, y, up=True): + s.send(rf"\e[<{64 if up else 65};{x + 1};{y + 1}M") + + +def top_row(s, p=None): + p = p or s.pane() + return s.snapshot().pane_line(p, 0) + + +def fill(s, n=60): + s.raw(f"seq 1 {n}\\n") + s.settle(150) + + +def test_wheel_scrolls(): + with Session(SH, cols=50, rows=12) as s: + s.settle() + fill(s) + p = s.pane() + check("the viewport is at the bottom", "60" in s.snapshot().pane_text(p), + repr(s.snapshot().pane_text(p))) + + wheel(s, p["content_x"] + 2, p["content_y"] + 2, up=True) + s.settle(80) + after = s.snapshot().pane_text(p) + check("the wheel scrolls back", "60" not in after, repr(after)) + + wheel(s, p["content_x"] + 2, p["content_y"] + 2, up=False) + s.settle(80) + check("and forward again", "60" in s.snapshot().pane_text(p), + repr(s.snapshot().pane_text(p))) + + +def test_indicator(): + with Session(SH, cols=50, rows=12) as s: + s.settle() + fill(s) + p = s.pane() + snap = s.snapshot() + check("no indicator when the viewport is live", + "▲" not in snap.line(p["y"]), repr(snap.line(p["y"]))) + + for _ in range(3): + wheel(s, p["content_x"] + 2, p["content_y"] + 2, up=True) + s.settle(80) + snap = s.snapshot() + row = snap.line(p["y"]) + check("scrolling shows how far down the live view is", "▲9" in row, + repr(row)) + + x = row.index("▲") + check("the indicator is clickable", + snap.hit_at(x, p["y"]) == f"scrollbottom:{p['id']}", + str(snap.hit_at(x, p["y"]))) + s.click(x, p["y"]) + s.settle(80) + snap = s.snapshot() + check("clicking it returns to the live view", + "▲" not in snap.line(p["y"]) and "60" in snap.pane_text(p), + repr(snap.line(p["y"]))) + + +def test_keyboard(): + with Session(SH, cols=50, rows=12) as s: + s.settle() + fill(s) + p = s.pane() + s.send(r"\x01\e[5~") # C-a PageUp + s.settle(80) + check("C-a PageUp scrolls a page", "60" not in s.snapshot().pane_text(p), + repr(s.snapshot().pane_text(p))) + + s.send(r"\x01\e[6~") # C-a PageDown + s.settle(80) + check("C-a PageDown comes back", "60" in s.snapshot().pane_text(p)) + + s.send(r"\x01\e[H") # C-a Home + s.settle(80) + text = s.snapshot().pane_text(p) + # the top of the scrollback is the session's very first line, which is + # the shell's first prompt — not the first line of `seq` + check("C-a Home goes to the top of the scrollback", + "sh-5.3$" in text and "60" not in text, repr(text[:120])) + + s.send(r"\x01\e[F") # C-a End + s.settle(80) + check("C-a End returns to the bottom", + "60" in s.snapshot().pane_text(p)) + + +def test_typing_returns_to_the_live_view(): + with Session(SH, cols=50, rows=12) as s: + s.settle() + fill(s) + p = s.pane() + s.send(r"\x01\e[5~") + s.settle(80) + check("scrolled away", "60" not in s.snapshot().pane_text(p)) + + s.send("echo back") # typed, not run: the snap-back is what we assert + s.settle(120) + text = s.snapshot().pane_text(p) + check("typing snaps back to the bottom", "60" in text, repr(text[-80:])) + check("and the indicator goes with it", + "▲" not in s.snapshot().line(p["y"]), + repr(s.snapshot().line(p["y"]))) + + +def test_scroll_position_is_per_pane(): + with Session(SH, cols=90, rows=14) as s: + s.settle() + fill(s, 40) + s.key("\\\\") + s.settle() + left, right = s.panes() + s.api("focus", id=left["id"]) + s.send(r"\x01\e[5~") + s.settle(80) + snap = s.snapshot() + # side-by-side panes share a screen row, so read each one's columns + def frame_of(pane): + return snap.line(pane["y"])[pane["x"]:pane["x"] + pane["w"]] + + check("one pane is scrolled", "▲" in frame_of(left), repr(frame_of(left))) + check("the other is not", "▲" not in frame_of(right), + repr(frame_of(right))) + + +def test_who_owns_the_wheel(): + # a full-screen program with no mouse tracking: the wheel means arrows + alt = ["/bin/sh", "-c", + 'stty raw -echo; printf "\\033[?1049h"; cat -v'] + with Session(alt, cols=60, rows=12) as s: + s.settle() + p = s.pane() + wheel(s, p["content_x"] + 2, p["content_y"] + 2, up=True) + s.settle(80) + out = s.snapshot().pane_text(p) + check("on the alternate screen the wheel becomes arrow keys", + out.count("^[[A") == 3, repr(out[:120])) + check("and does not scroll us instead", + "▲" not in s.snapshot().line(p["y"]), repr(s.snapshot().line(p["y"]))) + + # a program tracking the mouse gets the wheel events themselves + tracker = ["/bin/sh", "-c", + 'stty raw -echo; printf "\\033[?1000h\\033[?1006h"; cat -v'] + with Session(tracker, cols=60, rows=12) as s: + s.settle() + p = s.pane() + wheel(s, p["content_x"] + 2, p["content_y"] + 2, up=True) + s.settle(80) + out = s.snapshot().pane_text(p) + check("a mouse-tracking program gets the wheel", "^[[<64;3;3M" in out, + repr(out[:120])) + + +def test_output_while_scrolled(): + with Session(SH, cols=50, rows=12) as s: + s.settle() + fill(s, 60) + p = s.pane() + s.send(r"\x01\e[5~") + s.settle(80) + before = s.snapshot().pane_line(p, 0) + + s.raw("echo NEWLINE\\n") + s.settle(150) + after = s.snapshot() + check("new output does not yank the viewport back", + after.pane_line(p, 0) == before, + f"{before!r} -> {after.pane_line(p, 0)!r}") + check("but the indicator shows the gap growing", + "▲" in after.line(p["y"]), repr(after.line(p["y"]))) + + +if __name__ == "__main__": + test_wheel_scrolls() + test_indicator() + test_keyboard() + test_typing_returns_to_the_live_view() + test_scroll_position_is_per_pane() + test_who_owns_the_wheel() + test_output_while_scrolled() + sys.exit(report()) |
