[dev] [PATCH] st: alternative scrollback using ring buffer and view offset

From: Milos Nikic <nikic.milos_AT_gmail.com>
Date: Fri, 9 Jan 2026 13:12:25 -0800

Implement scrollback as a fixed-size ring buffer and render history
by offsetting the view instead of copying screen contents.

Tradeoffs / differences:
- Scrollback history is lost on resize
- Scrollback is disabled on the alternate screen
- Simpler model than the existing scrollback patch set
- Mouse wheel scrolling enabled by default

Note:
When using vim, mouse movement will no longer move the cursor.

Reminder:
If applying this patch on top of others, ensure any changes to
config.def.h are merged into config.h.
---
 config.def.h |   5 ++
 st.c         | 243 +++++++++++++++++++++++++++++++++++++++++++++++++--
 st.h         |   5 ++
 x.c          |  17 ++++
 4 files changed, 264 insertions(+), 6 deletions(-)
diff --git a/config.def.h b/config.def.h
index 2cd740a..a2d5182 100644
--- a/config.def.h
+++ b/config.def.h
_AT_@ -472,3 +472,8 @@ static char ascii_printable[] =
 	" !\"#$%&'()*+,-./0123456789:;<=>?"
 	"_AT_ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_"
 	"`abcdefghijklmnopqrstuvwxyz{|}~";
+
+/*
+ * The amount of lines scrollback can hold before it wraps around.
+ */
+int scrollback_lines = 5000;
diff --git a/st.c b/st.c
index e55e7b3..034d4b1 100644
--- a/st.c
+++ b/st.c
_AT_@ -43,6 +43,7 @@
 #define ISCONTROL(c)		(ISCONTROLC0(c) || ISCONTROLC1(c))
 #define ISDELIM(u)		(u && wcschr(worddelimiters, u))
 
+
 enum term_mode {
 	MODE_WRAP        = 1 << 0,
 	MODE_INSERT      = 1 << 1,
_AT_@ -232,6 +233,183 @@ static const uchar utfmask[UTF_SIZ + 1] = {0xC0, 0x80, 0xE0, 0xF0, 0xF8};
 static const Rune utfmin[UTF_SIZ + 1] = {       0,    0,  0x80,  0x800,  0x10000};
 static const Rune utfmax[UTF_SIZ + 1] = {0x10FFFF, 0x7F, 0x7FF, 0xFFFF, 0x10FFFF};
 
+typedef struct {
+	Line *buf;  /* ring of Line pointers */
+	int cap;    /* max number of lines */
+	int len;    /* current number of valid lines (<= cap) */
+	int head;   /* physical index of logical oldest (valid when len>0) */
+} Scrollback;
+
+static Scrollback sb;
+static int view_offset;
+
+static inline int
+sb_phys_index(int logical_idx)
+{
+	/* logical_idx: 0..sb.len-1 (0 = oldest) */
+	return (sb.head + logical_idx) % sb.cap;
+}
+
+Line
+lineclone(Line src)
+{
+	Line dst = xmalloc(term.col * sizeof(Glyph));
+	memcpy(dst, src, term.col * sizeof(Glyph));
+	return dst;
+}
+
+void
+sb_init(int lines)
+{
+	sb.buf  = xmalloc(sizeof(Line) * lines);
+	sb.cap  = lines;
+	sb.len  = 0;
+	sb.head = 0;
+
+	for (int i = 0; i < sb.cap; i++)
+		sb.buf[i] = NULL;
+
+	view_offset = 0;
+}
+
+/* Push one screen line into scrollback.
+ * Overwrites oldest when full (ring buffer).
+ */
+void
+sb_push(Line line)
+{
+	if (sb.cap <= 0)
+		return;
+	int was_at_top = (view_offset == sb.len);
+	Line copy = lineclone(line);
+
+	if (sb.len < sb.cap) {
+		int tail = sb_phys_index(sb.len);
+		sb.buf[tail] = copy;
+		sb.len++;
+	} else {
+		if (sb.buf[sb.head])
+			free(sb.buf[sb.head]);
+		sb.buf[sb.head] = copy;
+		sb.head = (sb.head + 1) % sb.cap;
+	}
+	if (was_at_top) {
+		view_offset = sb.len;
+	} else {
+		if (view_offset > sb.len)
+			view_offset = sb.len;
+	}
+}
+
+Line
+sb_get(int idx)
+{
+	/* idx is logical: 0..sb.len-1 */
+	if (idx < 0 || idx >= sb.len)
+		return NULL;
+	return sb.buf[sb_phys_index(idx)];
+}
+
+int
+sb_len(void)
+{
+	return sb.len;
+}
+
+void
+sb_clear(void)
+{
+	if (!sb.buf)
+		return;
+
+	for (int i = 0; i < sb.len; i++) {
+		int p = sb_phys_index(i);
+		if (sb.buf[p]) {
+			free(sb.buf[p]);
+			sb.buf[p] = NULL;
+		}
+	}
+
+	sb.len = 0;
+	sb.head = 0;
+	view_offset = 0;
+}
+
+void
+sb_view_changed(void)
+{
+	if (!term.dirty || term.row < 0)
+		return;
+	tfulldirt();
+}
+
+static Line
+emptyline(void)
+{
+	static Line empty;
+  static int empty_cols;
+	int i = 0;
+
+	if (empty_cols != term.col) {
+			free(empty);
+			empty = xmalloc(term.col * sizeof(Glyph));
+			empty_cols = term.col;
+	}
+
+	for (i = 0; i < term.col; i++) {
+			empty[i] = term.c.attr;
+			empty[i].u = ' ';
+			empty[i].mode = 0;
+	}
+	return empty;
+}
+
+/* Render line selection with scrollback.
+ *
+ * When view_offset == 0: show live screen (term.line).
+ * When view_offset > 0: show a window that ends "view_offset" lines above the bottom.
+ *
+ * We treat the visible window as:
+ *   start = (sb.len + term.row) - view_offset - term.row = sb.len - view_offset
+ *   visible indices are [start .. start + term.row - 1] in the concatenation:
+ *      [ scrollback (0..sb.len-1) ][ screen (0..term.row-1) ]
+ */
+Line
+getlineforrender(int y)
+{
+	if (view_offset <= 0)
+		return term.line[y];
+
+	int start = sb.len - view_offset; /* can be negative */
+	int v = start + y;
+
+	if (v < 0)
+		return emptyline();
+
+	if (v < sb.len)
+		return sb_get(v);
+
+	/* past scrollback -> into current screen */
+	v -= sb.len;
+	if (v >= 0 && v < term.row)
+		return term.line[v];
+
+	return NULL;
+}
+
+static void
+sb_reset_on_clear(void)
+{
+	sb_clear();
+	sb_view_changed();
+}
+
+int
+tisaltscreen(void)
+{
+	return IS_SET(MODE_ALTSCREEN);
+}
+
 ssize_t
 xwrite(int fd, const char *s, size_t len)
 {
_AT_@ -843,6 +1021,11 @@ ttyread(void)
 void
 ttywrite(const char *s, size_t n, int may_echo)
 {
+	if (view_offset > 0) {
+		view_offset = 0;
+		sb_view_changed();
+	}
+
 	const char *next;
 
 	if (may_echo && IS_SET(MODE_ECHO))
_AT_@ -1033,12 +1216,14 @@ treset(void)
 		tclearregion(0, 0, term.col-1, term.row-1);
 		tswapscreen();
 	}
+	sb_clear();
 }
 
 void
 tnew(int col, int row)
 {
 	term = (Term){ .c = { .attr = { .fg = defaultfg, .bg = defaultbg } } };
+	sb_init(scrollback_lines);
 	tresize(col, row);
 	treset();
 }
_AT_@ -1082,6 +1267,20 @@ tscrollup(int orig, int n)
 
 	LIMIT(n, 0, term.bot-orig+1);
 
+	if (!IS_SET(MODE_ALTSCREEN) && orig == term.top) {
+		if (view_offset > 0) {
+			view_offset += n;
+			if (view_offset > sb.len)
+				view_offset = sb.len;
+		}
+
+		for (i = 0; i < n; i++)
+			sb_push(term.line[orig + i]);
+
+		if (view_offset > sb.len)
+			view_offset = sb.len;
+	}
+
 	tclearregion(0, orig, term.col-1, orig+n-1);
 	tsetdirt(orig+n, term.bot);
 
_AT_@ -1717,7 +1916,13 @@ csihandle(void)
 			break;
 		case 2: /* all */
 			tclearregion(0, 0, term.col-1, term.row-1);
+			if (!IS_SET(MODE_ALTSCREEN))
+				sb_reset_on_clear();
 			break;
+		case 3:
+			if (!IS_SET(MODE_ALTSCREEN))
+				sb_reset_on_clear();
+      break;
 		default:
 			goto unknown;
 		}
_AT_@ -2163,6 +2368,24 @@ tdeftran(char ascii)
 	}
 }
 
+void
+kscrollup(const Arg *arg)
+{
+	view_offset += arg->i;
+	if(view_offset > sb.len)
+		view_offset = sb.len;
+	redraw ();
+}
+
+void
+kscrolldown(const Arg *arg)
+{
+	view_offset -= arg->i;
+	if(view_offset < 0)
+		view_offset = 0;
+	redraw ();
+}
+
 void
 tdectest(char c)
 {
_AT_@ -2575,6 +2798,9 @@ tresize(int col, int row)
 	int *bp;
 	TCursor c;
 
+	sb_clear();
+	sb_view_changed ();
+
 	if (col < 1 || row < 1) {
 		fprintf(stderr,
 		        "tresize: error resizing to %dx%d\n", col, row);
_AT_@ -2662,9 +2888,12 @@ drawregion(int x1, int y1, int x2, int y2)
 	for (y = y1; y < y2; y++) {
 		if (!term.dirty[y])
 			continue;
-
+		
 		term.dirty[y] = 0;
-		xdrawline(term.line[y], x1, y, x2);
+		Line line = getlineforrender(y);
+		if (!line)
+			continue;
+		xdrawline(line, x1, y, x2);
 	}
 }
 
_AT_@ -2685,10 +2914,12 @@ draw(void)
 		cx--;
 
 	drawregion(0, 0, term.col, term.row);
-	xdrawcursor(cx, term.c.y, term.line[term.c.y][cx],
-			term.ocx, term.ocy, term.line[term.ocy][term.ocx]);
-	term.ocx = cx;
-	term.ocy = term.c.y;
+	if (view_offset == 0) {
+		xdrawcursor(cx, term.c.y, term.line[term.c.y][cx],
+		            term.ocx, term.ocy, term.line[term.ocy][term.ocx]);
+		term.ocx = cx;
+		term.ocy = term.c.y;
+	}
 	xfinishdraw();
 	if (ocx != term.ocx || ocy != term.ocy)
 		xximspot(term.ocx, term.ocy);
diff --git a/st.h b/st.h
index fd3b0d8..db94fa1 100644
--- a/st.h
+++ b/st.h
_AT_@ -86,6 +86,7 @@ void printsel(const Arg *);
 void sendbreak(const Arg *);
 void toggleprinter(const Arg *);
 
+int tisaltscreen(void);
 int tattrset(int);
 void tnew(int, int);
 void tresize(int, int);
_AT_@ -111,6 +112,9 @@ void *xmalloc(size_t);
 void *xrealloc(void *, size_t);
 char *xstrdup(const char *);
 
+void kscrollup(const Arg *arg);
+void kscrolldown(const Arg *arg);
+
 /* config.h globals */
 extern char *utmp;
 extern char *scroll;
_AT_@ -124,3 +128,4 @@ extern unsigned int tabspaces;
 extern unsigned int defaultfg;
 extern unsigned int defaultbg;
 extern unsigned int defaultcs;
+extern int scrollback_lines;
diff --git a/x.c b/x.c
index d73152b..07ad9b1 100644
--- a/x.c
+++ b/x.c
_AT_@ -4,6 +4,7 @@
 #include <limits.h>
 #include <locale.h>
 #include <signal.h>
+#include <stdio.h>
 #include <sys/select.h>
 #include <time.h>
 #include <unistd.h>
_AT_@ -471,6 +472,22 @@ bpress(XEvent *e)
 	int btn = e->xbutton.button;
 	struct timespec now;
 	int snap;
+	if (btn == Button4 || btn == Button5) {
+			if (IS_SET(MODE_MOUSE) && !(e->xbutton.state & forcemousemod)) {
+					mousereport(e);
+					return;
+			}
+
+			if (!tisaltscreen()) {
+					Arg a = {.i = 1};
+					if (btn == Button4) {
+							kscrollup(&a);
+					} else {
+							kscrolldown(&a);
+					}
+			}
+			return;
+	}
 
 	if (1 <= btn && btn <= 11)
 		buttons |= 1 << (btn-1);
-- 
2.52.0
Received on Fri Jan 09 2026 - 22:12:25 CET

This archive was generated by hypermail 2.3.0 : Sun Jan 11 2026 - 15:24:08 CET