sites

public wiki contents of suckless.org
git clone git://git.suckless.org/sites
Log | Files | Refs

st-scrollback-reflow-standalone-extended-0.9.3.diff (26049B)


      1 From 55e224cf4d767db7d9184e70a0f3838935679a53 Mon Sep 17 00:00:00 2001
      2 From: Milos Nikic <nikic.milos@gmail.com>
      3 Date: Thu, 15 Jan 2026 16:08:59 -0800
      4 Subject: [PATCH] st: alternative scrollback using ring buffer and view offset
      5 
      6 Implement scrollback as a fixed-size ring buffer and render history
      7 by offsetting the view instead of copying screen contents.
      8 Implement reflow of history and screen content on resize if it is needed.
      9 
     10 Tradeoffs / differences:
     11   - Scrollback is disabled on the alternate screen
     12   - Simpler model than the existing scrollback patch set
     13   - Mouse wheel scrolling enabled by default
     14   - Shift + page up/down and shift + end/home work as well.
     15   - When using vim, mouse movement will no longer move the cursor.
     16   - There can be visual artifacts if width of the window is shrank to the
     17     size smaller than the shell promp.
     18   - Mouse selection is persistent even if it goes off screen but it get
     19     reset on resize.
     20 ---
     21  config.def.h |   9 +
     22  st.c         | 727 ++++++++++++++++++++++++++++++++++++++++++++-------
     23  st.h         |   5 +
     24  x.c          |  17 ++
     25  4 files changed, 659 insertions(+), 99 deletions(-)
     26 
     27 diff --git a/config.def.h b/config.def.h
     28 index 2cd740a..135a0b1 100644
     29 --- a/config.def.h
     30 +++ b/config.def.h
     31 @@ -192,6 +192,10 @@ static Shortcut shortcuts[] = {
     32  	{ XK_ANY_MOD,           XK_Break,       sendbreak,      {.i =  0} },
     33  	{ ControlMask,          XK_Print,       toggleprinter,  {.i =  0} },
     34  	{ ShiftMask,            XK_Print,       printscreen,    {.i =  0} },
     35 +	{ ShiftMask,            XK_Page_Up,     kscrollup,      {.i = -1} },
     36 +	{ ShiftMask,            XK_Page_Down,   kscrolldown,    {.i = -1} },
     37 +	{ ShiftMask,            XK_Home,        kscrollup,      {.i = 1000000} },
     38 +	{ ShiftMask,            XK_End,         kscrolldown,    {.i = 1000000} },
     39  	{ XK_ANY_MOD,           XK_Print,       printsel,       {.i =  0} },
     40  	{ TERMMOD,              XK_Prior,       zoom,           {.f = +1} },
     41  	{ TERMMOD,              XK_Next,        zoom,           {.f = -1} },
     42 @@ -472,3 +476,8 @@ static char ascii_printable[] =
     43  	" !\"#$%&'()*+,-./0123456789:;<=>?"
     44  	"@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_"
     45  	"`abcdefghijklmnopqrstuvwxyz{|}~";
     46 +
     47 +/*
     48 + * The amount of lines scrollback can hold before it wraps around.
     49 + */
     50 +unsigned int scrollback_lines = 5000;
     51 diff --git a/st.c b/st.c
     52 index e55e7b3..9565003 100644
     53 --- a/st.c
     54 +++ b/st.c
     55 @@ -5,6 +5,7 @@
     56  #include <limits.h>
     57  #include <pwd.h>
     58  #include <stdarg.h>
     59 +#include <stdint.h>
     60  #include <stdio.h>
     61  #include <stdlib.h>
     62  #include <string.h>
     63 @@ -178,7 +179,7 @@ static void tdeletechar(int);
     64  static void tdeleteline(int);
     65  static void tinsertblank(int);
     66  static void tinsertblankline(int);
     67 -static int tlinelen(int);
     68 +static int tlinelen(Line);
     69  static void tmoveto(int, int);
     70  static void tmoveato(int, int);
     71  static void tnewline(int);
     72 @@ -232,6 +233,376 @@ static const uchar utfmask[UTF_SIZ + 1] = {0xC0, 0x80, 0xE0, 0xF0, 0xF8};
     73  static const Rune utfmin[UTF_SIZ + 1] = {       0,    0,  0x80,  0x800,  0x10000};
     74  static const Rune utfmax[UTF_SIZ + 1] = {0x10FFFF, 0x7F, 0x7FF, 0xFFFF, 0x10FFFF};
     75  
     76 +typedef struct
     77 +{
     78 +	Line *buf;       /* ring of Line pointers */
     79 +	int cap;         /* max number of lines */
     80 +	int len;         /* current number of valid lines (<= cap) */
     81 +	int head;        /* physical index of logical oldest (valid when len>0) */
     82 +	uint64_t base;   /* Can overflow in the extreme */
     83 +	/*
     84 +	 * max_width tracks the widest line ever pushed to scrollback.
     85 +	 * It may be conservative (stale) if that line has since been
     86 +	 * evicted from the ring buffer, which is acceptable - it just
     87 +	 * means we might reflow when not strictly necessary, which is
     88 +	 * better than skipping a needed reflow.
     89 +	 */
     90 +	int max_width;
     91 +	int view_offset; /* 0 means live screen */
     92 +} Scrollback;
     93 +
     94 +static Scrollback sb;
     95 +
     96 +static int
     97 +sb_phys_index(int logical_idx)
     98 +{
     99 +	/* logical_idx: 0..sb.len-1 (0 = oldest) */
    100 +	return (sb.head + logical_idx) % sb.cap;
    101 +}
    102 +
    103 +static Line
    104 +lineclone(Line src)
    105 +{
    106 +	Line dst;
    107 +
    108 +	if (!src)
    109 +		return NULL;
    110 +
    111 +	dst = xmalloc(term.col * sizeof(Glyph));
    112 +	memcpy(dst, src, term.col * sizeof(Glyph));
    113 +	return dst;
    114 +}
    115 +
    116 +static void
    117 +sb_init(int lines)
    118 +{
    119 +	int i;
    120 +
    121 +	sb.buf  = xmalloc(sizeof(Line) * lines);
    122 +	sb.cap  = lines;
    123 +	sb.len  = 0;
    124 +	sb.head = 0;
    125 +	sb.base = 0;
    126 +	for (i = 0; i < sb.cap; i++)
    127 +		sb.buf[i] = NULL;
    128 +
    129 +	sb.view_offset = 0;
    130 +	sb.max_width = 0;
    131 +}
    132 +
    133 +/* Push one screen line into scrollback.
    134 + * Overwrites oldest when full (ring buffer).
    135 + */
    136 +static void
    137 +sb_push(Line line)
    138 +{
    139 +	Line copy;
    140 +	int tail;
    141 +	int width;
    142 +
    143 +	if (sb.cap <= 0)
    144 +		return;
    145 +
    146 +	copy = lineclone(line);
    147 +
    148 +	if (sb.len < sb.cap) {
    149 +		tail = sb_phys_index(sb.len);
    150 +		sb.buf[tail] = copy;
    151 +		sb.len++;
    152 +	} else {
    153 +		/* We might've just evicted the widest line... */
    154 +		free(sb.buf[sb.head]);
    155 +		sb.buf[sb.head] = copy;
    156 +		sb.head = (sb.head + 1) % sb.cap;
    157 +		sb.base++;
    158 +	}
    159 +	width = tlinelen(copy);
    160 +	/* ...so max_width might be stale. */
    161 +	if (width > sb.max_width)
    162 +		sb.max_width = width;
    163 +}
    164 +
    165 +static Line
    166 +sb_get(int idx)
    167 +{
    168 +	/* idx is logical: 0..sb.len-1 */
    169 +	if (idx < 0 || idx >= sb.len)
    170 +		return NULL;
    171 +	return sb.buf[sb_phys_index(idx)];
    172 +}
    173 +
    174 +static void
    175 +sb_clear(void)
    176 +{
    177 +	int i;
    178 +	int p;
    179 +
    180 +	if (!sb.buf)
    181 +		return;
    182 +
    183 +	for (i = 0; i < sb.len; i++) {
    184 +		p = sb_phys_index(i);
    185 +		if (sb.buf[p]) {
    186 +			free(sb.buf[p]);
    187 +			sb.buf[p] = NULL;
    188 +		}
    189 +	}
    190 +
    191 +	sb.len = 0;
    192 +	sb.head = 0;
    193 +	sb.base = 0;
    194 +	sb.view_offset = 0;
    195 +	sb.max_width = 0;
    196 +}
    197 +
    198 +/*
    199 + * Reflows the scrollback buffer to fit a new terminal width.
    200 + *
    201 + * The algorithm works in three steps:
    202 + * 1) Unwrap: It iterates through the existing history, joining physical lines
    203 + * marked with ATTR_WRAP into a single continuous 'logical' line.
    204 + * 2) Reflow: It slices this logical line into new chunks of size 'col'.
    205 + * - New wrap flags are applied where the text exceeds the new width.
    206 + * - Trailing spaces are trimmed to prevent ghost padding.
    207 + * 3) Rebuild: The new lines are pushed into a fresh ring buffer.
    208 + * - Uses O(1) ring insertion (updating head/tail) to avoid expensive
    209 + * memmoves during resize, but it is still O(N) where N is the existing
    210 + * history.
    211 + *
    212 + * Note: During reflow we reset sb to match the rebuilt buffer
    213 + * (head, base and len might change).
    214 + */
    215 +static void
    216 +sb_resize(int col)
    217 +{
    218 +	Line *new_buf;
    219 +	int i, j;
    220 +	int new_len, logical_cap, logical_len, is_wrapped, cursor;
    221 +	int copy_width, tail, current_width;
    222 +	Line logical, line, nl;
    223 +	uint64_t new_base = 0;
    224 +	int new_head = 0;
    225 +	int new_max_width = 0;
    226 +	Glyph *g;
    227 +
    228 +	new_len = 0;
    229 +
    230 +	if (sb.len == 0)
    231 +		return;
    232 +
    233 +	new_buf = xmalloc(sizeof(Line) * sb.cap);
    234 +	for (i = 0; i < sb.cap; i++)
    235 +		new_buf[i] = NULL;
    236 +
    237 +	logical_cap = term.col * 2;
    238 +	logical = xmalloc(logical_cap * sizeof(Glyph));
    239 +	logical_len = 0;
    240 +
    241 +	for (i = 0; i < sb.len; i++) {
    242 +		/* Unwrap: Accumulate physical lines into one logical line. */
    243 +		line = sb_get(i);
    244 +		is_wrapped = (line[term.col - 1].mode & ATTR_WRAP);
    245 +		if (logical_len + term.col > logical_cap) {
    246 +			logical_cap *= 2;
    247 +			logical = xrealloc(logical, logical_cap * sizeof(Glyph));
    248 +		}
    249 +
    250 +		memcpy(logical + logical_len, line, term.col * sizeof(Glyph));
    251 +		for (j = 0; j < term.col; j++) {
    252 +			logical[logical_len + j].mode &= ~ATTR_WRAP;
    253 +		}
    254 +		logical_len += term.col;
    255 +		/* If the line was wrapped, continue accumulating before reflowing. */
    256 +		if (is_wrapped) {
    257 +			continue;
    258 +		}
    259 +		/* Trim trailing spaces from the fully unwrapped line. */
    260 +		while (logical_len > 0) {
    261 +			g = &logical[logical_len - 1];
    262 +			if (g->u == ' ' && g->bg == defaultbg
    263 +					&& (g->mode & ATTR_BOLD) == 0) {
    264 +				logical_len--;
    265 +			} else {
    266 +				break;
    267 +			}
    268 +		}
    269 +		if (logical_len == 0)
    270 +			logical_len = 1;
    271 +
    272 +		/* Reflow: Split the logical line into new chunks. */
    273 +		cursor = 0;
    274 +		while (cursor < logical_len) {
    275 +			nl = xmalloc(col * sizeof(Glyph));
    276 +			for (j = 0; j < col; j++) {
    277 +				nl[j].fg = defaultfg;
    278 +				nl[j].bg = defaultbg;
    279 +				nl[j].mode = 0;
    280 +				nl[j].u = ' ';
    281 +			}
    282 +
    283 +			copy_width = logical_len - cursor;
    284 +			if (copy_width > col)
    285 +				copy_width = col;
    286 +
    287 +			memcpy(nl, logical + cursor, copy_width * sizeof(Glyph));
    288 +
    289 +			for (j = 0; j < copy_width; j++) {
    290 +				nl[j].mode &= ~ATTR_WRAP;
    291 +			}
    292 +
    293 +			if (cursor + copy_width < logical_len) {
    294 +				nl[col - 1].mode |= ATTR_WRAP;
    295 +			} else {
    296 +				nl[col - 1].mode &= ~ATTR_WRAP;
    297 +			}
    298 +
    299 +			/* Rebuild: Push new lines into the ring buffer. */
    300 +			if (new_len < sb.cap) {
    301 +				tail = (new_head + new_len) % sb.cap;
    302 +				new_buf[tail] = nl;
    303 +				new_len++;
    304 +			} else {
    305 +				free(new_buf[new_head]);
    306 +				new_buf[new_head] = nl;
    307 +				new_head = (new_head + 1) % sb.cap;
    308 +				new_base++;
    309 +			}
    310 +			current_width = (cursor + copy_width < logical_len) ? col : copy_width;
    311 +			if (current_width > new_max_width)
    312 +				new_max_width = current_width;
    313 +			cursor += copy_width;
    314 +		}
    315 +		logical_len = 0;
    316 +	}
    317 +	free(logical);
    318 +	sb_clear();
    319 +	free(sb.buf);
    320 +	sb.buf = new_buf;
    321 +	sb.len = new_len;
    322 +	sb.head = new_head;
    323 +	sb.base = new_base;
    324 +	sb.view_offset = 0;
    325 +	sb.max_width = new_max_width;
    326 +}
    327 +
    328 +static void
    329 +sb_pop_screen(int loaded, int new_cols)
    330 +{
    331 +	int i, p;
    332 +	int start_logical;
    333 +	Line line;
    334 +
    335 +	loaded = MIN(loaded, sb.len);
    336 +	start_logical = sb.len - loaded;
    337 +	new_cols = MIN(new_cols, term.col);
    338 +	for (i = 0; i < loaded; i++) {
    339 +		p = sb_phys_index(start_logical + i);
    340 +		line = sb.buf[p];
    341 +
    342 +		memcpy(term.line[i], line, new_cols * sizeof(Glyph));
    343 +
    344 +		free(line);
    345 +		sb.buf[p] = NULL;
    346 +	}
    347 +
    348 +	sb.len -= loaded;
    349 +}
    350 +
    351 +static uint64_t
    352 +sb_view_start(void)
    353 +{
    354 +	return sb.base + sb.len - sb.view_offset;
    355 +}
    356 +
    357 +static void
    358 +sb_view_changed(void)
    359 +{
    360 +	if (!term.dirty || term.row <= 0)
    361 +		return;
    362 +	tfulldirt();
    363 +}
    364 +
    365 +static void
    366 +selscrollback(int delta)
    367 +{
    368 +	if (delta == 0)
    369 +		return;
    370 +
    371 +	if (sel.ob.x == -1 || sel.mode == SEL_EMPTY)
    372 +		return;
    373 +
    374 +	if (sel.alt != IS_SET(MODE_ALTSCREEN))
    375 +		return;
    376 +
    377 +	sel.nb.y += delta;
    378 +	sel.ne.y += delta;
    379 +	sel.ob.y += delta;
    380 +	sel.oe.y += delta;
    381 +
    382 +	sb_view_changed();
    383 +}
    384 +
    385 +static Line
    386 +emptyline(void)
    387 +{
    388 +	static Line empty;
    389 +	static int empty_cols;
    390 +	int i = 0;
    391 +
    392 +	if (empty_cols != term.col) {
    393 +		free(empty);
    394 +		empty = xmalloc(term.col * sizeof(Glyph));
    395 +		empty_cols = term.col;
    396 +	}
    397 +
    398 +	for (i = 0; i < term.col; i++) {
    399 +		empty[i] = term.c.attr;
    400 +		empty[i].u = ' ';
    401 +		empty[i].mode = 0;
    402 +	}
    403 +	return empty;
    404 +}
    405 +
    406 +static Line
    407 +renderline(int y)
    408 +{
    409 +	int start, v;
    410 +
    411 +	if (sb.view_offset <= 0)
    412 +		return term.line[y];
    413 +
    414 +	start = sb.len - sb.view_offset; /* can be negative */
    415 +	v = start + y;
    416 +
    417 +	if (v < 0)
    418 +		return emptyline();
    419 +
    420 +	if (v < sb.len)
    421 +		return sb_get(v);
    422 +
    423 +	/* past scrollback -> into current screen */
    424 +	v -= sb.len;
    425 +	if (v >= 0 && v < term.row)
    426 +		return term.line[v];
    427 +
    428 +	return emptyline();
    429 +}
    430 +
    431 +static void
    432 +sb_reset_on_clear(void)
    433 +{
    434 +	sb_clear();
    435 +	sb_view_changed();
    436 +	if (sel.ob.x != -1 && term.row > 0)
    437 +		selclear();
    438 +}
    439 +
    440 +int
    441 +tisaltscreen(void)
    442 +{
    443 +	return IS_SET(MODE_ALTSCREEN);
    444 +}
    445 +
    446  ssize_t
    447  xwrite(int fd, const char *s, size_t len)
    448  {
    449 @@ -404,20 +775,23 @@ selinit(void)
    450  	sel.ob.x = -1;
    451  }
    452  
    453 -int
    454 -tlinelen(int y)
    455 +static int
    456 +tlinelen(Line line)
    457  {
    458  	int i = term.col;
    459 -
    460 -	if (term.line[y][i - 1].mode & ATTR_WRAP)
    461 +	if (line[i - 1].mode & ATTR_WRAP)
    462  		return i;
    463 -
    464 -	while (i > 0 && term.line[y][i - 1].u == ' ')
    465 +	while (i > 0 && line[i - 1].u == ' ')
    466  		--i;
    467 -
    468  	return i;
    469  }
    470  
    471 +static int
    472 +tlinelen_render(int y)
    473 +{
    474 +	return tlinelen(renderline(y));
    475 +}
    476 +
    477  void
    478  selstart(int col, int row, int snap)
    479  {
    480 @@ -485,10 +859,10 @@ selnormalize(void)
    481  	/* expand selection over line breaks */
    482  	if (sel.type == SEL_RECTANGULAR)
    483  		return;
    484 -	i = tlinelen(sel.nb.y);
    485 +	i = tlinelen_render(sel.nb.y);
    486  	if (i < sel.nb.x)
    487  		sel.nb.x = i;
    488 -	if (tlinelen(sel.ne.y) <= sel.ne.x)
    489 +	if (tlinelen_render(sel.ne.y) <= sel.ne.x)
    490  		sel.ne.x = term.col - 1;
    491  }
    492  
    493 @@ -514,6 +888,7 @@ selsnap(int *x, int *y, int direction)
    494  	int newx, newy, xt, yt;
    495  	int delim, prevdelim;
    496  	const Glyph *gp, *prevgp;
    497 +	Line line;
    498  
    499  	switch (sel.snap) {
    500  	case SNAP_WORD:
    501 @@ -521,7 +896,7 @@ selsnap(int *x, int *y, int direction)
    502  		 * Snap around if the word wraps around at the end or
    503  		 * beginning of a line.
    504  		 */
    505 -		prevgp = &term.line[*y][*x];
    506 +		prevgp = &renderline(*y)[*x];
    507  		prevdelim = ISDELIM(prevgp->u);
    508  		for (;;) {
    509  			newx = *x + direction;
    510 @@ -536,14 +911,15 @@ selsnap(int *x, int *y, int direction)
    511  					yt = *y, xt = *x;
    512  				else
    513  					yt = newy, xt = newx;
    514 -				if (!(term.line[yt][xt].mode & ATTR_WRAP))
    515 +				line = renderline(yt);
    516 +				if (!(line[xt].mode & ATTR_WRAP))
    517  					break;
    518  			}
    519  
    520 -			if (newx >= tlinelen(newy))
    521 +			if (newx >= tlinelen_render(newy))
    522  				break;
    523  
    524 -			gp = &term.line[newy][newx];
    525 +			gp = &renderline(newy)[newx];
    526  			delim = ISDELIM(gp->u);
    527  			if (!(gp->mode & ATTR_WDUMMY) && (delim != prevdelim
    528  					|| (delim && gp->u != prevgp->u)))
    529 @@ -564,14 +940,14 @@ selsnap(int *x, int *y, int direction)
    530  		*x = (direction < 0) ? 0 : term.col - 1;
    531  		if (direction < 0) {
    532  			for (; *y > 0; *y += direction) {
    533 -				if (!(term.line[*y-1][term.col-1].mode
    534 +				if (!(renderline(*y-1)[term.col-1].mode
    535  						& ATTR_WRAP)) {
    536  					break;
    537  				}
    538  			}
    539  		} else if (direction > 0) {
    540  			for (; *y < term.row-1; *y += direction) {
    541 -				if (!(term.line[*y][term.col-1].mode
    542 +				if (!(renderline(*y)[term.col-1].mode
    543  						& ATTR_WRAP)) {
    544  					break;
    545  				}
    546 @@ -585,8 +961,9 @@ char *
    547  getsel(void)
    548  {
    549  	char *str, *ptr;
    550 -	int y, bufsize, lastx, linelen;
    551 +	int y, bufsize, lastx, linelen, end_idx, insert_newline, is_wrapped;
    552  	const Glyph *gp, *last;
    553 +	Line line;
    554  
    555  	if (sel.ob.x == -1)
    556  		return NULL;
    557 @@ -596,29 +973,33 @@ getsel(void)
    558  
    559  	/* append every set & selected glyph to the selection */
    560  	for (y = sel.nb.y; y <= sel.ne.y; y++) {
    561 -		if ((linelen = tlinelen(y)) == 0) {
    562 +		line = renderline(y);
    563 +		linelen = tlinelen_render(y);
    564 +
    565 +		if (linelen == 0) {
    566  			*ptr++ = '\n';
    567  			continue;
    568  		}
    569  
    570  		if (sel.type == SEL_RECTANGULAR) {
    571 -			gp = &term.line[y][sel.nb.x];
    572 +			gp = &line[sel.nb.x];
    573  			lastx = sel.ne.x;
    574  		} else {
    575 -			gp = &term.line[y][sel.nb.y == y ? sel.nb.x : 0];
    576 +			gp = &line[sel.nb.y == y ? sel.nb.x : 0];
    577  			lastx = (sel.ne.y == y) ? sel.ne.x : term.col-1;
    578  		}
    579 -		last = &term.line[y][MIN(lastx, linelen-1)];
    580 -		while (last >= gp && last->u == ' ')
    581 +		end_idx = MIN(lastx, linelen-1);
    582 +		is_wrapped = (line[end_idx].mode & ATTR_WRAP) != 0;
    583 +		last = &line[end_idx];
    584 +		while (last >= gp && last->u == ' ') {
    585  			--last;
    586 +		}
    587  
    588  		for ( ; gp <= last; ++gp) {
    589  			if (gp->mode & ATTR_WDUMMY)
    590  				continue;
    591 -
    592  			ptr += utf8encode(gp->u, ptr);
    593  		}
    594 -
    595  		/*
    596  		 * Copy and pasting of line endings is inconsistent
    597  		 * in the inconsistent terminal and GUI world.
    598 @@ -628,8 +1009,13 @@ getsel(void)
    599  		 * st.
    600  		 * FIXME: Fix the computer world.
    601  		 */
    602 +		insert_newline = 0;
    603  		if ((y < sel.ne.y || lastx >= linelen) &&
    604 -		    (!(last->mode & ATTR_WRAP) || sel.type == SEL_RECTANGULAR))
    605 +			(!is_wrapped || sel.type == SEL_RECTANGULAR)) {
    606 +			insert_newline = 1;
    607 +		}
    608 +
    609 +		if (insert_newline)
    610  			*ptr++ = '\n';
    611  	}
    612  	*ptr = 0;
    613 @@ -845,6 +1231,12 @@ ttywrite(const char *s, size_t n, int may_echo)
    614  {
    615  	const char *next;
    616  
    617 +	if (sb.view_offset > 0) {
    618 +		selclear();
    619 +		sb.view_offset = 0;
    620 +		sb_view_changed();
    621 +	}
    622 +
    623  	if (may_echo && IS_SET(MODE_ECHO))
    624  		twrite(s, n, 1);
    625  
    626 @@ -965,6 +1357,8 @@ tsetdirt(int top, int bot)
    627  {
    628  	int i;
    629  
    630 +	if (term.row < 1)
    631 +		return;
    632  	LIMIT(top, 0, term.row-1);
    633  	LIMIT(bot, 0, term.row-1);
    634  
    635 @@ -1030,15 +1424,21 @@ treset(void)
    636  	for (i = 0; i < 2; i++) {
    637  		tmoveto(0, 0);
    638  		tcursor(CURSOR_SAVE);
    639 -		tclearregion(0, 0, term.col-1, term.row-1);
    640 +		if (term.col > 0 && term.row > 0 && term.line > 0)
    641 +			tclearregion(0, 0, term.col-1, term.row-1);
    642  		tswapscreen();
    643  	}
    644 +	sb_clear();
    645 +	if (sel.ob.x != -1 && term.row > 0)
    646 +		selclear();
    647  }
    648  
    649 +
    650  void
    651  tnew(int col, int row)
    652  {
    653  	term = (Term){ .c = { .attr = { .fg = defaultfg, .bg = defaultbg } } };
    654 +	sb_init(scrollback_lines);
    655  	tresize(col, row);
    656  	treset();
    657  }
    658 @@ -1078,10 +1478,37 @@ void
    659  tscrollup(int orig, int n)
    660  {
    661  	int i;
    662 +	uint64_t newstart;
    663 +	uint64_t oldstart;
    664 +
    665 +	int attop;
    666  	Line temp;
    667  
    668 +	oldstart = sb_view_start();
    669  	LIMIT(n, 0, term.bot-orig+1);
    670  
    671 +	if (!IS_SET(MODE_ALTSCREEN) && orig == term.top) {
    672 +		/* At top of history only if history exists */
    673 +		attop = (sb.len != 0 && sb.view_offset == sb.len);
    674 +
    675 +		if (sb.view_offset > 0 && !attop)
    676 +			sb.view_offset += n;
    677 +
    678 +		for (i = 0; i < n; i++)
    679 +			sb_push(term.line[orig + i]);
    680 +
    681 +		/* if at the top, keep me there */
    682 +		if (attop)
    683 +			sb.view_offset = sb.len;
    684 +		/* otherwise clamp me */
    685 +		else if (sb.view_offset > sb.len)
    686 +			sb.view_offset = sb.len;
    687 +	}
    688 +
    689 +	newstart = sb_view_start();
    690 +	if (sb.view_offset > 0)
    691 +		selscrollback(oldstart - newstart);
    692 +
    693  	tclearregion(0, orig, term.col-1, orig+n-1);
    694  	tsetdirt(orig+n, term.bot);
    695  
    696 @@ -1097,6 +1524,8 @@ tscrollup(int orig, int n)
    697  void
    698  selscroll(int orig, int n)
    699  {
    700 +	if (sb.view_offset != 0)
    701 +		return;
    702  	if (sel.ob.x == -1 || sel.alt != IS_SET(MODE_ALTSCREEN))
    703  		return;
    704  
    705 @@ -1105,12 +1534,7 @@ selscroll(int orig, int n)
    706  	} else if (BETWEEN(sel.nb.y, orig, term.bot)) {
    707  		sel.ob.y += n;
    708  		sel.oe.y += n;
    709 -		if (sel.ob.y < term.top || sel.ob.y > term.bot ||
    710 -		    sel.oe.y < term.top || sel.oe.y > term.bot) {
    711 -			selclear();
    712 -		} else {
    713 -			selnormalize();
    714 -		}
    715 +		selnormalize();
    716  	}
    717  }
    718  
    719 @@ -1717,6 +2141,12 @@ csihandle(void)
    720  			break;
    721  		case 2: /* all */
    722  			tclearregion(0, 0, term.col-1, term.row-1);
    723 +			if (!IS_SET(MODE_ALTSCREEN))
    724 +				sb_reset_on_clear();
    725 +			break;
    726 +		case 3:
    727 +			if (!IS_SET(MODE_ALTSCREEN))
    728 +				sb_reset_on_clear();
    729  			break;
    730  		default:
    731  			goto unknown;
    732 @@ -2106,7 +2536,7 @@ tdumpline(int n)
    733  	const Glyph *bp, *end;
    734  
    735  	bp = &term.line[n][0];
    736 -	end = &bp[MIN(tlinelen(n), term.col) - 1];
    737 +	end = &bp[MIN(tlinelen_render(n), term.col) - 1];
    738  	if (bp != end || bp->u != ' ') {
    739  		for ( ; bp <= end; ++bp)
    740  			tprinter(buf, utf8encode(bp->u, buf));
    741 @@ -2163,6 +2593,46 @@ tdeftran(char ascii)
    742  	}
    743  }
    744  
    745 +static void
    746 +kscroll(const Arg *arg)
    747 +{
    748 +	uint64_t oldstart;
    749 +	uint64_t newstart;
    750 +
    751 +	oldstart = sb_view_start();
    752 +	sb.view_offset += arg->i;
    753 +	LIMIT(sb.view_offset, 0, sb.len);
    754 +	newstart = sb_view_start();
    755 +	selscrollback(oldstart - newstart);
    756 +	redraw();
    757 +}
    758 +
    759 +void
    760 +kscrolldown(const Arg *arg)
    761 +{
    762 +	Arg a;
    763 +
    764 +	if (arg->i < 0)
    765 +		a.i = -term.row;
    766 +	else
    767 +		a.i = -arg->i;
    768 +
    769 +	kscroll(&a);
    770 +}
    771 +
    772 +void
    773 +kscrollup(const Arg *arg)
    774 +{
    775 +	Arg a;
    776 +
    777 +	if (arg->i < 0)
    778 +		a.i = term.row;
    779 +	else
    780 +		a.i = arg->i;
    781 +
    782 +	kscroll(&a);
    783 +}
    784 +
    785  void
    786  tdectest(char c)
    787  {
    788 @@ -2569,83 +3039,139 @@ twrite(const char *buf, int buflen, int show_ctrl)
    789  void
    790  tresize(int col, int row)
    791  {
    792 -	int i;
    793 +	int i, j;
    794 +	int min_limit;
    795  	int minrow = MIN(row, term.row);
    796 -	int mincol = MIN(col, term.col);
    797 -	int *bp;
    798 -	TCursor c;
    799 +	int old_row = term.row;
    800 +	int old_col = term.col;
    801 +	int save_end = 0; /* Track effective pushed height */
    802 +	int loaded = 0;
    803 +	int pop_width = 0;
    804 +	int needs_reflow = 0;
    805 +	int is_alt = IS_SET(MODE_ALTSCREEN);
    806 +	Line *tmp;
    807  
    808  	if (col < 1 || row < 1) {
    809  		fprintf(stderr,
    810 -		        "tresize: error resizing to %dx%d\n", col, row);
    811 +			"tresize: error resizing to %dx%d\n", col, row);
    812  		return;
    813  	}
    814  
    815 -	/*
    816 -	 * slide screen to keep cursor where we expect it -
    817 -	 * tscrollup would work here, but we can optimize to
    818 -	 * memmove because we're freeing the earlier lines
    819 -	 */
    820 -	for (i = 0; i <= term.c.y - row; i++) {
    821 -		free(term.line[i]);
    822 -		free(term.alt[i]);
    823 -	}
    824 -	/* ensure that both src and dst are not NULL */
    825 -	if (i > 0) {
    826 -		memmove(term.line, term.line + i, row * sizeof(Line));
    827 -		memmove(term.alt, term.alt + i, row * sizeof(Line));
    828 -	}
    829 -	for (i += row; i < term.row; i++) {
    830 -		free(term.line[i]);
    831 -		free(term.alt[i]);
    832 +	if (sel.ob.x != -1)
    833 +		selclear();
    834 +
    835 +	/* Operate on the currently visible screen buffer. */
    836 +	if (is_alt) {
    837 +		tmp = term.line;
    838 +		term.line = term.alt;
    839 +		term.alt = tmp;
    840  	}
    841  
    842 -	/* resize to new height */
    843 -	term.line = xrealloc(term.line, row * sizeof(Line));
    844 -	term.alt  = xrealloc(term.alt,  row * sizeof(Line));
    845 -	term.dirty = xrealloc(term.dirty, row * sizeof(*term.dirty));
    846 -	term.tabs = xrealloc(term.tabs, col * sizeof(*term.tabs));
    847 +	save_end = term.row;
    848 +	if (term.row != 0 && term.col != 0) {
    849 +		if (!is_alt && term.c.y > 0 && term.c.y < term.row) {
    850 +			term.line[term.c.y - 1][term.col - 1].mode &= ~ATTR_WRAP;
    851 +		}
    852 +		min_limit = is_alt ? 0 : term.c.y;
    853  
    854 -	/* resize each row to new width, zero-pad if needed */
    855 -	for (i = 0; i < minrow; i++) {
    856 -		term.line[i] = xrealloc(term.line[i], col * sizeof(Glyph));
    857 -		term.alt[i]  = xrealloc(term.alt[i],  col * sizeof(Glyph));
    858 -	}
    859 +		for (i = term.row - 1; i > min_limit; i--) {
    860 +			if (tlinelen(term.line[i]) > 0)
    861 +				break;
    862 +		}
    863 +		save_end = i + 1;
    864  
    865 -	/* allocate any new rows */
    866 -	for (/* i = minrow */; i < row; i++) {
    867 -		term.line[i] = xmalloc(col * sizeof(Glyph));
    868 -		term.alt[i] = xmalloc(col * sizeof(Glyph));
    869 +		for (i = 0; i < save_end; i++) {
    870 +			sb_push(term.line[i]);
    871 +		}
    872 +		/* Optimization: Only reflow if content doesn't fit in new width.
    873 +		 * This avoids expensive reflow operations when resizing doesn't
    874 +		 * affect line wrapping (e.g., when terminal is wide enough). */
    875 +		if (col > term.col) {
    876 +			/* Growing: Only reflow if history was wrapped at old width */
    877 +			needs_reflow = sb.max_width >= term.col;
    878 +		} else if (col < term.col) {
    879 +			/* Shrinking: Only reflow if content is wider than new width. */
    880 +			if (sb.max_width > col)
    881 +				needs_reflow = 1;
    882 +		}
    883 +		if (needs_reflow) {
    884 +			sb_resize(col);
    885 +		} else {
    886 +			/* If we don't reflow, we still need to reset the view 
    887 +			 * because sb_pop_screen() might change the history length. */
    888 +			sb.view_offset = 0;
    889 +		}
    890  	}
    891 -	if (col > term.col) {
    892 -		bp = term.tabs + term.col;
    893  
    894 -		memset(bp, 0, sizeof(*term.tabs) * (col - term.col));
    895 -		while (--bp > term.tabs && !*bp)
    896 -			/* nothing */ ;
    897 -		for (bp += tabspaces; bp < term.tabs + col; bp += tabspaces)
    898 -			*bp = 1;
    899 -	}
    900 -	/* update terminal size */
    901 +		if (term.line) {
    902 +			for (i = 0; i < term.row; i++) {
    903 +				free(term.line[i]);
    904 +				free(term.alt[i]);
    905 +			}
    906 +			free(term.line);
    907 +			free(term.alt);
    908 +			free(term.dirty);
    909 +			free(term.tabs);
    910 +		}
    911 +
    912  	term.col = col;
    913  	term.row = row;
    914 -	/* reset scrolling region */
    915 -	tsetscroll(0, row-1);
    916 -	/* make use of the LIMIT in tmoveto */
    917 -	tmoveto(term.c.x, term.c.y);
    918 -	/* Clearing both screens (it makes dirty all lines) */
    919 -	c = term.c;
    920 -	for (i = 0; i < 2; i++) {
    921 -		if (mincol < col && 0 < minrow) {
    922 -			tclearregion(mincol, 0, col - 1, minrow - 1);
    923 -		}
    924 -		if (0 < col && minrow < row) {
    925 -			tclearregion(0, minrow, col - 1, row - 1);
    926 +
    927 +	term.line  = xmalloc(term.row * sizeof(Line));
    928 +	term.alt   = xmalloc(term.row * sizeof(Line));
    929 +	term.dirty = xmalloc(term.row * sizeof(int));
    930 +	term.tabs  = xmalloc(term.col * sizeof(*term.tabs));
    931 +
    932 +	for (i = 0; i < term.row; i++) {
    933 +		term.line[i] = xmalloc(term.col * sizeof(Glyph));
    934 +		term.alt[i]  = xmalloc(term.col * sizeof(Glyph));
    935 +		term.dirty[i] = 1;
    936 +
    937 +		for (j = 0; j < term.col; j++) {
    938 +			term.line[i][j] = term.c.attr;
    939 +			term.line[i][j].u = ' ';
    940 +			term.line[i][j].mode = 0;
    941 +
    942 +			term.alt[i][j] = term.c.attr;
    943 +			term.alt[i][j].u = ' ';
    944 +			term.alt[i][j].mode = 0;
    945  		}
    946 -		tswapscreen();
    947 -		tcursor(CURSOR_LOAD);
    948  	}
    949 -	term.c = c;
    950 +
    951 +	memset(term.tabs, 0, term.col * sizeof(*term.tabs));
    952 +	for (i = 8; i < term.col; i += 8)
    953 +		term.tabs[i] = 1;
    954 +
    955 +	tsetscroll(0, term.row - 1);
    956 +
    957 +	if (minrow > 0) {
    958 +		loaded = MIN(sb.len, term.row);
    959 +		pop_width = needs_reflow ? col : MIN(col, old_col);
    960 +		sb_pop_screen(loaded, pop_width);
    961 +	}
    962 +	if (is_alt) {
    963 +		tmp = term.line;
    964 +		term.line = term.alt;
    965 +		term.alt = tmp;
    966 +	}
    967 +	if (!is_alt && old_row > 0) {
    968 +		term.c.y += (loaded - save_end);
    969 +	}
    970 +	if (term.c.y >= term.row) {
    971 +		term.c.y = term.row - 1;
    972 +	}
    973 +	if (term.c.x >= term.col) {
    974 +		term.c.x = term.col - 1;
    975 +	}
    976 +	if (term.c.y < 0) {
    977 +		term.c.y = 0;
    978 +	}
    979 +	if (term.c.x < 0) {
    980 +		term.c.x = 0;
    981 +	}
    982 +
    983 +	tfulldirt();
    984 +	sb_view_changed();
    985  }
    986  
    987  void
    988 @@ -2659,12 +3185,13 @@ drawregion(int x1, int y1, int x2, int y2)
    989  {
    990  	int y;
    991  
    992 +	Line line;
    993  	for (y = y1; y < y2; y++) {
    994  		if (!term.dirty[y])
    995  			continue;
    996 -
    997  		term.dirty[y] = 0;
    998 -		xdrawline(term.line[y], x1, y, x2);
    999 +		line = renderline(y);
   1000 +		xdrawline(line, x1, y, x2);
   1001  	}
   1002  }
   1003  
   1004 @@ -2685,10 +3212,12 @@ draw(void)
   1005  		cx--;
   1006  
   1007  	drawregion(0, 0, term.col, term.row);
   1008 -	xdrawcursor(cx, term.c.y, term.line[term.c.y][cx],
   1009 -			term.ocx, term.ocy, term.line[term.ocy][term.ocx]);
   1010 -	term.ocx = cx;
   1011 -	term.ocy = term.c.y;
   1012 +	if (sb.view_offset == 0) {
   1013 +		xdrawcursor(cx, term.c.y, term.line[term.c.y][cx],
   1014 +		            term.ocx, term.ocy, term.line[term.ocy][term.ocx]);
   1015 +		term.ocx = cx;
   1016 +		term.ocy = term.c.y;
   1017 +	}
   1018  	xfinishdraw();
   1019  	if (ocx != term.ocx || ocy != term.ocy)
   1020  		xximspot(term.ocx, term.ocy);
   1021 diff --git a/st.h b/st.h
   1022 index fd3b0d8..151d0c6 100644
   1023 --- a/st.h
   1024 +++ b/st.h
   1025 @@ -86,6 +86,7 @@ void printsel(const Arg *);
   1026  void sendbreak(const Arg *);
   1027  void toggleprinter(const Arg *);
   1028  
   1029 +int tisaltscreen(void);
   1030  int tattrset(int);
   1031  void tnew(int, int);
   1032  void tresize(int, int);
   1033 @@ -111,6 +112,9 @@ void *xmalloc(size_t);
   1034  void *xrealloc(void *, size_t);
   1035  char *xstrdup(const char *);
   1036  
   1037 +void kscrollup(const Arg *arg);
   1038 +void kscrolldown(const Arg *arg);
   1039 +
   1040  /* config.h globals */
   1041  extern char *utmp;
   1042  extern char *scroll;
   1043 @@ -124,3 +128,4 @@ extern unsigned int tabspaces;
   1044  extern unsigned int defaultfg;
   1045  extern unsigned int defaultbg;
   1046  extern unsigned int defaultcs;
   1047 +extern unsigned int scrollback_lines;
   1048 diff --git a/x.c b/x.c
   1049 index d73152b..75f3db1 100644
   1050 --- a/x.c
   1051 +++ b/x.c
   1052 @@ -472,6 +472,23 @@ bpress(XEvent *e)
   1053  	struct timespec now;
   1054  	int snap;
   1055  
   1056 +	if (btn == Button4 || btn == Button5) {
   1057 +		Arg a;
   1058 +		if (IS_SET(MODE_MOUSE) && !(e->xbutton.state & forcemousemod)) {
   1059 +			mousereport(e);
   1060 +			return;
   1061 +		}
   1062 +		if (!tisaltscreen()) {
   1063 +			a.i = 1;
   1064 +			if (btn == Button4) {
   1065 +				kscrollup(&a);
   1066 +			} else {
   1067 +				kscrolldown(&a);
   1068 +			}
   1069 +		}
   1070 +		return;
   1071 +	}
   1072 +
   1073  	if (1 <= btn && btn <= 11)
   1074  		buttons |= 1 << (btn-1);
   1075  
   1076 -- 
   1077 2.52.0
   1078