sites

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

st-scrollback-reflow-standalone-0.9.3.diff (25684B)


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