st-scrollback-reflow-standalone-extended-0.9.3.diff (32374B)
1 From c7cf4aae956ea0db9253b6592749c578d1546ce5 Mon Sep 17 00:00:00 2001 2 From: Milos Nikic <nikic.milos@gmail.com> 3 Date: Thu, 8 Jan 2026 22:04:25 -0800 4 Subject: [PATCH] st: scrollback reflow and selection across scrollback 5 6 Reflow the scrollback buffer on resize instead of dropping it: wrapped 7 lines are joined into logical lines and re-split to the new width. 8 9 Keep the selection in logical rows spanning history and screen (0 is 10 the oldest scrollback line, sb.len + y is screen row y) instead of in 11 window coordinates. Lines keep their row when pushed into scrollback, 12 so the selection now survives scrolling the view and new output, and 13 can cover more than one screen. Only evicting the oldest line from the 14 ring shifts it. 15 16 - shift+click extends the current selection: the end nearest to the 17 click follows it, keeping word/line snapping. Disabled in MODE_MOUSE, 18 where shift forces selection instead. 19 - scrolling the wheel while dragging keeps the selection end under the 20 pointer. 21 - typing while scrolled back no longer clears the selection. 22 - the selection is cleared when the width changes (reflow) or the alt 23 screen is resized, and kept on height-only resizes. 24 --- 25 config.def.h | 5 + 26 st.c | 840 ++++++++++++++++++++++++++++++++++++++++++++------- 27 st.h | 6 + 28 x.c | 46 ++- 29 4 files changed, 780 insertions(+), 117 deletions(-) 30 31 diff --git a/config.def.h b/config.def.h 32 index fd2bd87..0e885f1 100644 33 --- a/config.def.h 34 +++ b/config.def.h 35 @@ -472,3 +472,8 @@ static char ascii_printable[] = 36 " !\"#$%&'()*+,-./0123456789:;<=>?" 37 "@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_" 38 "`abcdefghijklmnopqrstuvwxyz{|}~"; 39 + 40 +/* 41 + * The amount of lines scrollback can hold before it wraps around. 42 + */ 43 +unsigned int scrollback_lines = 5000; 44 diff --git a/st.c b/st.c 45 index c289072..4d79f20 100644 46 --- a/st.c 47 +++ b/st.c 48 @@ -5,6 +5,7 @@ 49 #include <limits.h> 50 #include <pwd.h> 51 #include <stdarg.h> 52 +#include <stdint.h> 53 #include <stdio.h> 54 #include <stdlib.h> 55 #include <string.h> 56 @@ -178,7 +179,7 @@ static void tdeletechar(int); 57 static void tdeleteline(int); 58 static void tinsertblank(int); 59 static void tinsertblankline(int); 60 -static int tlinelen(int); 61 +static int tlinelen(Line); 62 static void tmoveto(int, int); 63 static void tmoveato(int, int); 64 static void tnewline(int); 65 @@ -207,6 +208,9 @@ static void drawregion(int, int, int, int); 66 static void selnormalize(void); 67 static void selscroll(int, int); 68 static void selsnap(int *, int *, int); 69 +static void selevict(void); 70 +static void seldirt(int, int); 71 +static int isselected(int, int); 72 73 static size_t utf8decode(const char *, Rune *, size_t); 74 static Rune utf8decodebyte(char, size_t *); 75 @@ -232,6 +236,366 @@ static const uchar utfmask[UTF_SIZ + 1] = {0xC0, 0x80, 0xE0, 0xF0, 0xF8}; 76 static const Rune utfmin[UTF_SIZ + 1] = { 0, 0, 0x80, 0x800, 0x10000}; 77 static const Rune utfmax[UTF_SIZ + 1] = {0x10FFFF, 0x7F, 0x7FF, 0xFFFF, 0x10FFFF}; 78 79 +typedef struct 80 +{ 81 + Line *buf; /* ring of Line pointers */ 82 + int cap; /* max number of lines */ 83 + int len; /* current number of valid lines (<= cap) */ 84 + int head; /* physical index of logical oldest (valid when len>0) */ 85 + uint64_t base; /* Can overflow in the extreme */ 86 + /* 87 + * max_width tracks the widest line ever pushed to scrollback. 88 + * It may be conservative (stale) if that line has since been 89 + * evicted from the ring buffer, which is acceptable - it just 90 + * means we might reflow when not strictly necessary, which is 91 + * better than skipping a needed reflow. 92 + */ 93 + int max_width; 94 + int view_offset; /* 0 means live screen */ 95 +} Scrollback; 96 + 97 +static Scrollback sb; 98 + 99 +static int 100 +sb_phys_index(int logical_idx) 101 +{ 102 + /* logical_idx: 0..sb.len-1 (0 = oldest) */ 103 + return (sb.head + logical_idx) % sb.cap; 104 +} 105 + 106 +static Line 107 +lineclone(Line src) 108 +{ 109 + Line dst; 110 + 111 + if (!src) 112 + return NULL; 113 + 114 + dst = xmalloc(term.col * sizeof(Glyph)); 115 + memcpy(dst, src, term.col * sizeof(Glyph)); 116 + return dst; 117 +} 118 + 119 +static void 120 +sb_init(int lines) 121 +{ 122 + int i; 123 + 124 + sb.buf = xmalloc(sizeof(Line) * lines); 125 + sb.cap = lines; 126 + sb.len = 0; 127 + sb.head = 0; 128 + sb.base = 0; 129 + for (i = 0; i < sb.cap; i++) 130 + sb.buf[i] = NULL; 131 + 132 + sb.view_offset = 0; 133 + sb.max_width = 0; 134 +} 135 + 136 +/* Push one screen line into scrollback. 137 + * Overwrites oldest when full (ring buffer). 138 + */ 139 +static void 140 +sb_push(Line line) 141 +{ 142 + Line copy; 143 + int tail; 144 + int width; 145 + 146 + if (sb.cap <= 0) 147 + return; 148 + 149 + copy = lineclone(line); 150 + 151 + if (sb.len < sb.cap) { 152 + tail = sb_phys_index(sb.len); 153 + sb.buf[tail] = copy; 154 + sb.len++; 155 + } else { 156 + /* We might've just evicted the widest line... */ 157 + free(sb.buf[sb.head]); 158 + sb.buf[sb.head] = copy; 159 + sb.head = (sb.head + 1) % sb.cap; 160 + sb.base++; 161 + selevict(); 162 + } 163 + width = tlinelen(copy); 164 + /* ...so max_width might be stale. */ 165 + if (width > sb.max_width) 166 + sb.max_width = width; 167 +} 168 + 169 +static Line 170 +sb_get(int idx) 171 +{ 172 + /* idx is logical: 0..sb.len-1 */ 173 + if (idx < 0 || idx >= sb.len) 174 + return NULL; 175 + return sb.buf[sb_phys_index(idx)]; 176 +} 177 + 178 +static void 179 +sb_clear(void) 180 +{ 181 + int i; 182 + int p; 183 + 184 + if (!sb.buf) 185 + return; 186 + 187 + for (i = 0; i < sb.len; i++) { 188 + p = sb_phys_index(i); 189 + if (sb.buf[p]) { 190 + free(sb.buf[p]); 191 + sb.buf[p] = NULL; 192 + } 193 + } 194 + 195 + sb.len = 0; 196 + sb.head = 0; 197 + sb.base = 0; 198 + sb.view_offset = 0; 199 + sb.max_width = 0; 200 +} 201 + 202 +/* 203 + * Reflows the scrollback buffer to fit a new terminal width. 204 + * 205 + * The algorithm works in three steps: 206 + * 1) Unwrap: It iterates through the existing history, joining physical lines 207 + * marked with ATTR_WRAP into a single continuous 'logical' line. 208 + * 2) Reflow: It slices this logical line into new chunks of size 'col'. 209 + * - New wrap flags are applied where the text exceeds the new width. 210 + * - Trailing spaces are trimmed to prevent ghost padding. 211 + * 3) Rebuild: The new lines are pushed into a fresh ring buffer. 212 + * - Uses O(1) ring insertion (updating head/tail) to avoid expensive 213 + * memmoves during resize, but it is still O(N) where N is the existing 214 + * history. 215 + * 216 + * Note: During reflow we reset sb to match the rebuilt buffer 217 + * (head, base and len might change). 218 + */ 219 +static void 220 +sb_resize(int col) 221 +{ 222 + Line *new_buf; 223 + int i, j; 224 + int new_len, logical_cap, logical_len, is_wrapped, cursor; 225 + int copy_width, tail, current_width; 226 + Line logical, line, nl; 227 + uint64_t new_base = 0; 228 + int new_head = 0; 229 + int new_max_width = 0; 230 + Glyph *g; 231 + 232 + new_len = 0; 233 + 234 + if (sb.len == 0) 235 + return; 236 + 237 + new_buf = xmalloc(sizeof(Line) * sb.cap); 238 + for (i = 0; i < sb.cap; i++) 239 + new_buf[i] = NULL; 240 + 241 + logical_cap = term.col * 2; 242 + logical = xmalloc(logical_cap * sizeof(Glyph)); 243 + logical_len = 0; 244 + 245 + for (i = 0; i < sb.len; i++) { 246 + /* Unwrap: Accumulate physical lines into one logical line. */ 247 + line = sb_get(i); 248 + is_wrapped = (line[term.col - 1].mode & ATTR_WRAP); 249 + if (logical_len + term.col > logical_cap) { 250 + logical_cap *= 2; 251 + logical = xrealloc(logical, logical_cap * sizeof(Glyph)); 252 + } 253 + 254 + memcpy(logical + logical_len, line, term.col * sizeof(Glyph)); 255 + for (j = 0; j < term.col; j++) { 256 + logical[logical_len + j].mode &= ~ATTR_WRAP; 257 + } 258 + logical_len += term.col; 259 + /* If the line was wrapped, continue accumulating before reflowing. */ 260 + if (is_wrapped) { 261 + continue; 262 + } 263 + /* Trim trailing spaces from the fully unwrapped line. */ 264 + while (logical_len > 0) { 265 + g = &logical[logical_len - 1]; 266 + if (g->u == ' ' && g->bg == defaultbg 267 + && (g->mode & ATTR_BOLD) == 0) { 268 + logical_len--; 269 + } else { 270 + break; 271 + } 272 + } 273 + if (logical_len == 0) 274 + logical_len = 1; 275 + 276 + /* Reflow: Split the logical line into new chunks. */ 277 + cursor = 0; 278 + while (cursor < logical_len) { 279 + nl = xmalloc(col * sizeof(Glyph)); 280 + for (j = 0; j < col; j++) { 281 + nl[j].fg = defaultfg; 282 + nl[j].bg = defaultbg; 283 + nl[j].mode = 0; 284 + nl[j].u = ' '; 285 + } 286 + 287 + copy_width = logical_len - cursor; 288 + if (copy_width > col) 289 + copy_width = col; 290 + 291 + memcpy(nl, logical + cursor, copy_width * sizeof(Glyph)); 292 + 293 + for (j = 0; j < copy_width; j++) { 294 + nl[j].mode &= ~ATTR_WRAP; 295 + } 296 + 297 + if (cursor + copy_width < logical_len) { 298 + nl[col - 1].mode |= ATTR_WRAP; 299 + } else { 300 + nl[col - 1].mode &= ~ATTR_WRAP; 301 + } 302 + 303 + /* Rebuild: Push new lines into the ring buffer. */ 304 + if (new_len < sb.cap) { 305 + tail = (new_head + new_len) % sb.cap; 306 + new_buf[tail] = nl; 307 + new_len++; 308 + } else { 309 + free(new_buf[new_head]); 310 + new_buf[new_head] = nl; 311 + new_head = (new_head + 1) % sb.cap; 312 + new_base++; 313 + } 314 + current_width = (cursor + copy_width < logical_len) ? col : copy_width; 315 + if (current_width > new_max_width) 316 + new_max_width = current_width; 317 + cursor += copy_width; 318 + } 319 + logical_len = 0; 320 + } 321 + free(logical); 322 + sb_clear(); 323 + free(sb.buf); 324 + sb.buf = new_buf; 325 + sb.len = new_len; 326 + sb.head = new_head; 327 + sb.base = new_base; 328 + sb.view_offset = 0; 329 + sb.max_width = new_max_width; 330 +} 331 + 332 +static void 333 +sb_pop_screen(int loaded, int new_cols) 334 +{ 335 + int i, p; 336 + int start_logical; 337 + Line line; 338 + 339 + loaded = MIN(loaded, sb.len); 340 + start_logical = sb.len - loaded; 341 + new_cols = MIN(new_cols, term.col); 342 + for (i = 0; i < loaded; i++) { 343 + p = sb_phys_index(start_logical + i); 344 + line = sb.buf[p]; 345 + 346 + memcpy(term.line[i], line, new_cols * sizeof(Glyph)); 347 + 348 + free(line); 349 + sb.buf[p] = NULL; 350 + } 351 + 352 + sb.len -= loaded; 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 Line 364 +emptyline(void) 365 +{ 366 + static Line empty; 367 + static int empty_cols; 368 + int i = 0; 369 + 370 + if (empty_cols != term.col) { 371 + free(empty); 372 + empty = xmalloc(term.col * sizeof(Glyph)); 373 + empty_cols = term.col; 374 + } 375 + 376 + for (i = 0; i < term.col; i++) { 377 + empty[i] = term.c.attr; 378 + empty[i].u = ' '; 379 + empty[i].mode = 0; 380 + } 381 + return empty; 382 +} 383 + 384 +/* 385 + * Logical rows address history and screen as one continuous space: 386 + * 0..sb.len-1 are scrollback lines (0 = oldest) and sb.len + y is 387 + * screen row y. A line keeps its logical row when it is pushed into 388 + * scrollback; only evicting the oldest line shifts everything by one. 389 + * The selection lives in this space, so it survives scrolling. 390 + */ 391 +static Line 392 +logicalline(int v) 393 +{ 394 + if (v < 0) 395 + return emptyline(); 396 + 397 + if (v < sb.len) 398 + return sb_get(v); 399 + 400 + /* past scrollback -> into current screen */ 401 + v -= sb.len; 402 + if (v < term.row) 403 + return term.line[v]; 404 + 405 + return emptyline(); 406 +} 407 + 408 +/* first logical row shown at the top of the window */ 409 +static int 410 +viewtop(void) 411 +{ 412 + return sb.len - sb.view_offset; /* can be negative */ 413 +} 414 + 415 +static Line 416 +renderline(int y) 417 +{ 418 + if (sb.view_offset <= 0) 419 + return term.line[y]; 420 + 421 + return logicalline(viewtop() + y); 422 +} 423 + 424 +static void 425 +sb_reset_on_clear(void) 426 +{ 427 + sb_clear(); 428 + sb_view_changed(); 429 + if (sel.ob.x != -1 && term.row > 0) 430 + selclear(); 431 +} 432 + 433 +int 434 +tisaltscreen(void) 435 +{ 436 + return IS_SET(MODE_ALTSCREEN); 437 +} 438 + 439 ssize_t 440 xwrite(int fd, const char *s, size_t len) 441 { 442 @@ -404,20 +768,68 @@ selinit(void) 443 sel.ob.x = -1; 444 } 445 446 -int 447 -tlinelen(int y) 448 +static int 449 +tlinelen(Line line) 450 { 451 int i = term.col; 452 - 453 - if (term.line[y][i - 1].mode & ATTR_WRAP) 454 + if (line[i - 1].mode & ATTR_WRAP) 455 return i; 456 - 457 - while (i > 0 && term.line[y][i - 1].u == ' ') 458 + while (i > 0 && line[i - 1].u == ' ') 459 --i; 460 - 461 return i; 462 } 463 464 +/* range of logical rows a selection can cover */ 465 +static int 466 +selminrow(void) 467 +{ 468 + /* the alternate screen has no history */ 469 + return sel.alt ? sb.len : 0; 470 +} 471 + 472 +static int 473 +selmaxrow(void) 474 +{ 475 + return sb.len + term.row - 1; 476 +} 477 + 478 +/* mark the visible part of logical rows a..b dirty */ 479 +static void 480 +seldirt(int a, int b) 481 +{ 482 + int top = viewtop(); 483 + 484 + a -= top; 485 + b -= top; 486 + if (b < 0 || a >= term.row) 487 + return; 488 + tsetdirt(MAX(a, 0), MIN(b, term.row - 1)); 489 +} 490 + 491 +/* the oldest scrollback line was dropped: shift the selection with it */ 492 +static void 493 +selevict(void) 494 +{ 495 + if (sel.ob.x == -1 || sel.alt) 496 + return; 497 + 498 + sel.ob.y--; 499 + sel.oe.y--; 500 + sel.nb.y--; 501 + sel.ne.y--; 502 + 503 + if (sel.ne.y < 0) { 504 + selclear(); 505 + return; 506 + } 507 + if (sel.nb.y < 0) 508 + sel.nb.x = sel.nb.y = 0; 509 + if (sel.ob.y < 0) 510 + sel.ob.x = sel.ob.y = 0; 511 + if (sel.oe.y < 0) 512 + sel.oe.x = sel.oe.y = 0; 513 +} 514 + 515 void 516 selstart(int col, int row, int snap) 517 { 518 @@ -427,12 +839,12 @@ selstart(int col, int row, int snap) 519 sel.alt = IS_SET(MODE_ALTSCREEN); 520 sel.snap = snap; 521 sel.oe.x = sel.ob.x = col; 522 - sel.oe.y = sel.ob.y = row; 523 + sel.oe.y = sel.ob.y = viewtop() + row; 524 selnormalize(); 525 526 if (sel.snap != 0) 527 sel.mode = SEL_READY; 528 - tsetdirt(sel.nb.y, sel.ne.y); 529 + seldirt(sel.nb.y, sel.ne.y); 530 } 531 532 void 533 @@ -454,16 +866,48 @@ selextend(int col, int row, int type, int done) 534 oldtype = sel.type; 535 536 sel.oe.x = col; 537 - sel.oe.y = row; 538 + sel.oe.y = viewtop() + row; 539 selnormalize(); 540 sel.type = type; 541 542 if (oldey != sel.oe.y || oldex != sel.oe.x || oldtype != sel.type || sel.mode == SEL_EMPTY) 543 - tsetdirt(MIN(sel.nb.y, oldsby), MAX(sel.ne.y, oldsey)); 544 + seldirt(MIN(sel.nb.y, oldsby), MAX(sel.ne.y, oldsey)); 545 546 sel.mode = done ? SEL_IDLE : SEL_READY; 547 } 548 549 +/* 550 + * Extend the existing selection to (col, row): the end closest to the 551 + * click follows it, the other end stays put. The selection can then be 552 + * dragged further like a fresh one. Returns 0 if there is nothing to 553 + * extend. 554 + */ 555 +int 556 +selextendto(int col, int row, int type) 557 +{ 558 + long pos, dbeg, dend; 559 + 560 + if (sel.ob.x == -1 || sel.mode != SEL_IDLE || 561 + sel.alt != IS_SET(MODE_ALTSCREEN)) 562 + return 0; 563 + 564 + row += viewtop(); 565 + pos = (long)row * term.col + col; 566 + dbeg = labs(pos - ((long)sel.nb.y * term.col + sel.nb.x)); 567 + dend = labs(pos - ((long)sel.ne.y * term.col + sel.ne.x)); 568 + 569 + seldirt(sel.nb.y, sel.ne.y); 570 + sel.ob = (dbeg < dend) ? sel.ne : sel.nb; 571 + sel.oe.x = col; 572 + sel.oe.y = row; 573 + sel.type = type; 574 + selnormalize(); 575 + seldirt(sel.nb.y, sel.ne.y); 576 + sel.mode = SEL_READY; 577 + 578 + return 1; 579 +} 580 + 581 void 582 selnormalize(void) 583 { 584 @@ -485,15 +929,16 @@ selnormalize(void) 585 /* expand selection over line breaks */ 586 if (sel.type == SEL_RECTANGULAR) 587 return; 588 - i = tlinelen(sel.nb.y); 589 + i = tlinelen(logicalline(sel.nb.y)); 590 if (i < sel.nb.x) 591 sel.nb.x = i; 592 - if (tlinelen(sel.ne.y) <= sel.ne.x) 593 + if (tlinelen(logicalline(sel.ne.y)) <= sel.ne.x) 594 sel.ne.x = term.col - 1; 595 } 596 597 -int 598 -selected(int x, int y) 599 +/* x, y in logical rows */ 600 +static int 601 +isselected(int x, int y) 602 { 603 if (sel.mode == SEL_EMPTY || sel.ob.x == -1 || 604 sel.alt != IS_SET(MODE_ALTSCREEN)) 605 @@ -508,12 +953,21 @@ selected(int x, int y) 606 && (y != sel.ne.y || x <= sel.ne.x); 607 } 608 609 +/* x, y in window coordinates */ 610 +int 611 +selected(int x, int y) 612 +{ 613 + return isselected(x, viewtop() + y); 614 +} 615 + 616 void 617 selsnap(int *x, int *y, int direction) 618 { 619 int newx, newy, xt, yt; 620 int delim, prevdelim; 621 + int miny = selminrow(), maxy = selmaxrow(); 622 const Glyph *gp, *prevgp; 623 + Line line; 624 625 switch (sel.snap) { 626 case SNAP_WORD: 627 @@ -521,7 +975,7 @@ selsnap(int *x, int *y, int direction) 628 * Snap around if the word wraps around at the end or 629 * beginning of a line. 630 */ 631 - prevgp = &term.line[*y][*x]; 632 + prevgp = &logicalline(*y)[*x]; 633 prevdelim = ISDELIM(prevgp->u); 634 for (;;) { 635 newx = *x + direction; 636 @@ -529,21 +983,23 @@ selsnap(int *x, int *y, int direction) 637 if (!BETWEEN(newx, 0, term.col - 1)) { 638 newy += direction; 639 newx = (newx + term.col) % term.col; 640 - if (!BETWEEN(newy, 0, term.row - 1)) 641 + if (!BETWEEN(newy, miny, maxy)) 642 break; 643 644 if (direction > 0) 645 yt = *y, xt = *x; 646 else 647 yt = newy, xt = newx; 648 - if (!(term.line[yt][xt].mode & ATTR_WRAP)) 649 + line = logicalline(yt); 650 + if (!(line[xt].mode & ATTR_WRAP)) 651 break; 652 } 653 654 - if (newx >= tlinelen(newy)) 655 + line = logicalline(newy); 656 + if (newx >= tlinelen(line)) 657 break; 658 659 - gp = &term.line[newy][newx]; 660 + gp = &line[newx]; 661 delim = ISDELIM(gp->u); 662 if (!(gp->mode & ATTR_WDUMMY) && (delim != prevdelim 663 || (delim && gp->u != prevgp->u))) 664 @@ -563,15 +1019,15 @@ selsnap(int *x, int *y, int direction) 665 */ 666 *x = (direction < 0) ? 0 : term.col - 1; 667 if (direction < 0) { 668 - for (; *y > 0; *y += direction) { 669 - if (!(term.line[*y-1][term.col-1].mode 670 + for (; *y > miny; *y += direction) { 671 + if (!(logicalline(*y-1)[term.col-1].mode 672 & ATTR_WRAP)) { 673 break; 674 } 675 } 676 } else if (direction > 0) { 677 - for (; *y < term.row-1; *y += direction) { 678 - if (!(term.line[*y][term.col-1].mode 679 + for (; *y < maxy; *y += direction) { 680 + if (!(logicalline(*y)[term.col-1].mode 681 & ATTR_WRAP)) { 682 break; 683 } 684 @@ -585,8 +1041,9 @@ char * 685 getsel(void) 686 { 687 char *str, *ptr; 688 - int y, bufsize, lastx, linelen; 689 + int y, bufsize, lastx, linelen, end_idx, insert_newline, is_wrapped; 690 const Glyph *gp, *last; 691 + Line line; 692 693 if (sel.ob.x == -1) 694 return NULL; 695 @@ -596,29 +1053,33 @@ getsel(void) 696 697 /* append every set & selected glyph to the selection */ 698 for (y = sel.nb.y; y <= sel.ne.y; y++) { 699 - if ((linelen = tlinelen(y)) == 0) { 700 + line = logicalline(y); 701 + linelen = tlinelen(line); 702 + 703 + if (linelen == 0) { 704 *ptr++ = '\n'; 705 continue; 706 } 707 708 if (sel.type == SEL_RECTANGULAR) { 709 - gp = &term.line[y][sel.nb.x]; 710 + gp = &line[sel.nb.x]; 711 lastx = sel.ne.x; 712 } else { 713 - gp = &term.line[y][sel.nb.y == y ? sel.nb.x : 0]; 714 + gp = &line[sel.nb.y == y ? sel.nb.x : 0]; 715 lastx = (sel.ne.y == y) ? sel.ne.x : term.col-1; 716 } 717 - last = &term.line[y][MIN(lastx, linelen-1)]; 718 - while (last >= gp && last->u == ' ') 719 + end_idx = MIN(lastx, linelen-1); 720 + is_wrapped = (line[end_idx].mode & ATTR_WRAP) != 0; 721 + last = &line[end_idx]; 722 + while (last >= gp && last->u == ' ') { 723 --last; 724 + } 725 726 for ( ; gp <= last; ++gp) { 727 if (gp->mode & ATTR_WDUMMY) 728 continue; 729 - 730 ptr += utf8encode(gp->u, ptr); 731 } 732 - 733 /* 734 * Copy and pasting of line endings is inconsistent 735 * in the inconsistent terminal and GUI world. 736 @@ -628,8 +1089,13 @@ getsel(void) 737 * st. 738 * FIXME: Fix the computer world. 739 */ 740 + insert_newline = 0; 741 if ((y < sel.ne.y || lastx >= linelen) && 742 - (!(last->mode & ATTR_WRAP) || sel.type == SEL_RECTANGULAR)) 743 + (!is_wrapped || sel.type == SEL_RECTANGULAR)) { 744 + insert_newline = 1; 745 + } 746 + 747 + if (insert_newline) 748 *ptr++ = '\n'; 749 } 750 *ptr = 0; 751 @@ -643,7 +1109,7 @@ selclear(void) 752 return; 753 sel.mode = SEL_IDLE; 754 sel.ob.x = -1; 755 - tsetdirt(sel.nb.y, sel.ne.y); 756 + seldirt(sel.nb.y, sel.ne.y); 757 } 758 759 void 760 @@ -850,6 +1316,11 @@ ttywrite(const char *s, size_t n, int may_echo) 761 { 762 const char *next; 763 764 + if (sb.view_offset > 0) { 765 + sb.view_offset = 0; 766 + sb_view_changed(); 767 + } 768 + 769 if (may_echo && IS_SET(MODE_ECHO)) 770 twrite(s, n, 1); 771 772 @@ -970,9 +1441,8 @@ tsetdirt(int top, int bot) 773 { 774 int i; 775 776 - if (term.row <= 0) 777 + if (term.row < 1) 778 return; 779 - 780 LIMIT(top, 0, term.row-1); 781 LIMIT(bot, 0, term.row-1); 782 783 @@ -1038,15 +1508,21 @@ treset(void) 784 for (i = 0; i < 2; i++) { 785 tmoveto(0, 0); 786 tcursor(CURSOR_SAVE); 787 - tclearregion(0, 0, term.col-1, term.row-1); 788 + if (term.col > 0 && term.row > 0 && term.line) 789 + tclearregion(0, 0, term.col-1, term.row-1); 790 tswapscreen(); 791 } 792 + sb_clear(); 793 + if (sel.ob.x != -1 && term.row > 0) 794 + selclear(); 795 } 796 797 + 798 void 799 tnew(int col, int row) 800 { 801 term = (Term){ .c = { .attr = { .fg = defaultfg, .bg = defaultbg } } }; 802 + sb_init(scrollback_lines); 803 tresize(col, row); 804 treset(); 805 } 806 @@ -1086,10 +1562,54 @@ void 807 tscrollup(int orig, int n) 808 { 809 int i; 810 + int attop; 811 + int pushed = 0; 812 Line temp; 813 814 LIMIT(n, 0, term.bot-orig+1); 815 816 + if (!IS_SET(MODE_ALTSCREEN) && orig == term.top) { 817 + /* 818 + * Pushed lines keep their logical rows, but rows outside 819 + * of a partial scroll region move along with the screen. 820 + */ 821 + if ((term.top > 0 || term.bot < term.row-1) && 822 + sel.ob.x != -1 && !sel.alt && sel.ne.y >= sb.len) 823 + selclear(); 824 + 825 + /* At top of history only if history exists */ 826 + attop = (sb.len != 0 && sb.view_offset == sb.len); 827 + 828 + if (sb.view_offset > 0 && !attop) 829 + sb.view_offset += n; 830 + 831 + for (i = 0; i < n; i++) 832 + sb_push(term.line[orig + i]); 833 + pushed = sb.cap > 0; 834 + 835 + /* if at the top, keep me there */ 836 + if (attop) 837 + sb.view_offset = sb.len; 838 + /* otherwise clamp me */ 839 + else if (sb.view_offset > sb.len) 840 + sb.view_offset = sb.len; 841 + } 842 + 843 + if (pushed) { 844 + /* 845 + * The lines went to history, so the selection stays valid. 846 + * Rotate before clearing, so clearing doesn't drop it. 847 + */ 848 + tsetdirt(orig, term.bot); 849 + for (i = orig; i <= term.bot-n; i++) { 850 + temp = term.line[i]; 851 + term.line[i] = term.line[i+n]; 852 + term.line[i+n] = temp; 853 + } 854 + tclearregion(0, term.bot-n+1, term.col-1, term.bot); 855 + return; 856 + } 857 + 858 tclearregion(0, orig, term.col-1, orig+n-1); 859 tsetdirt(orig+n, term.bot); 860 861 @@ -1105,16 +1625,23 @@ tscrollup(int orig, int n) 862 void 863 selscroll(int orig, int n) 864 { 865 + int top, bot; 866 + 867 if (sel.ob.x == -1 || sel.alt != IS_SET(MODE_ALTSCREEN)) 868 return; 869 870 - if (BETWEEN(sel.nb.y, orig, term.bot) != BETWEEN(sel.ne.y, orig, term.bot)) { 871 + /* the scrolled screen region, in logical rows */ 872 + orig += sb.len; 873 + top = sb.len + term.top; 874 + bot = sb.len + term.bot; 875 + 876 + if (BETWEEN(sel.nb.y, orig, bot) != BETWEEN(sel.ne.y, orig, bot)) { 877 selclear(); 878 - } else if (BETWEEN(sel.nb.y, orig, term.bot)) { 879 + } else if (BETWEEN(sel.nb.y, orig, bot)) { 880 sel.ob.y += n; 881 sel.oe.y += n; 882 - if (sel.ob.y < term.top || sel.ob.y > term.bot || 883 - sel.oe.y < term.top || sel.oe.y > term.bot) { 884 + if (sel.ob.y < top || sel.ob.y > bot || 885 + sel.oe.y < top || sel.oe.y > bot) { 886 selclear(); 887 } else { 888 selnormalize(); 889 @@ -1250,7 +1777,7 @@ tclearregion(int x1, int y1, int x2, int y2) 890 term.dirty[y] = 1; 891 for (x = x1; x <= x2; x++) { 892 gp = &term.line[y][x]; 893 - if (selected(x, y)) 894 + if (isselected(x, sb.len + y)) 895 selclear(); 896 gp->fg = term.c.attr.fg; 897 gp->bg = term.c.attr.bg; 898 @@ -1727,6 +2254,12 @@ csihandle(void) 899 break; 900 case 2: /* all */ 901 tclearregion(0, 0, term.col-1, term.row-1); 902 + if (!IS_SET(MODE_ALTSCREEN)) 903 + sb_reset_on_clear(); 904 + break; 905 + case 3: 906 + if (!IS_SET(MODE_ALTSCREEN)) 907 + sb_reset_on_clear(); 908 break; 909 default: 910 goto unknown; 911 @@ -2116,7 +2649,7 @@ tdumpline(int n) 912 const Glyph *bp, *end; 913 914 bp = &term.line[n][0]; 915 - end = &bp[MIN(tlinelen(n), term.col) - 1]; 916 + end = &bp[MIN(tlinelen(term.line[n]), term.col) - 1]; 917 if (bp != end || bp->u != ' ') { 918 for ( ; bp <= end; ++bp) 919 tprinter(buf, utf8encode(bp->u, buf)); 920 @@ -2173,6 +2706,29 @@ tdeftran(char ascii) 921 } 922 } 923 924 +static void 925 +kscroll(const Arg *arg) 926 +{ 927 + sb.view_offset += arg->i; 928 + LIMIT(sb.view_offset, 0, sb.len); 929 + redraw(); 930 +} 931 + 932 +void 933 +kscrolldown(const Arg *arg) 934 +{ 935 + Arg a; 936 + 937 + a.i = -arg->i; 938 + kscroll(&a); 939 +} 940 + 941 +void 942 +kscrollup(const Arg *arg) 943 +{ 944 + kscroll(arg); 945 +} 946 + 947 void 948 tdectest(char c) 949 { 950 @@ -2500,7 +3056,7 @@ check_control_code: 951 */ 952 return; 953 } 954 - if (selected(term.c.x, term.c.y)) 955 + if (isselected(term.c.x, sb.len + term.c.y)) 956 selclear(); 957 958 gp = &term.line[term.c.y][term.c.x]; 959 @@ -2579,83 +3135,146 @@ twrite(const char *buf, int buflen, int show_ctrl) 960 void 961 tresize(int col, int row) 962 { 963 - int i; 964 + int i, j; 965 + int min_limit; 966 int minrow = MIN(row, term.row); 967 - int mincol = MIN(col, term.col); 968 - int *bp; 969 - TCursor c; 970 + int old_row = term.row; 971 + int old_col = term.col; 972 + int save_end = 0; /* Track effective pushed height */ 973 + int loaded = 0; 974 + int pop_width = 0; 975 + int needs_reflow = 0; 976 + int is_alt = IS_SET(MODE_ALTSCREEN); 977 + Line *tmp; 978 979 if (col < 1 || row < 1) { 980 fprintf(stderr, 981 - "tresize: error resizing to %dx%d\n", col, row); 982 + "tresize: error resizing to %dx%d\n", col, row); 983 return; 984 } 985 986 /* 987 - * slide screen to keep cursor where we expect it - 988 - * tscrollup would work here, but we can optimize to 989 - * memmove because we're freeing the earlier lines 990 + * Reflow and truncation move text around, and the alt screen 991 + * is rebuilt from scratch. A pure height change keeps logical 992 + * rows intact (lines go through scrollback and back). 993 */ 994 - for (i = 0; i <= term.c.y - row; i++) { 995 - free(term.line[i]); 996 - free(term.alt[i]); 997 - } 998 - /* ensure that both src and dst are not NULL */ 999 - if (i > 0) { 1000 - memmove(term.line, term.line + i, row * sizeof(Line)); 1001 - memmove(term.alt, term.alt + i, row * sizeof(Line)); 1002 - } 1003 - for (i += row; i < term.row; i++) { 1004 - free(term.line[i]); 1005 - free(term.alt[i]); 1006 + if (col != old_col || sel.alt) 1007 + selclear(); 1008 + 1009 + /* Operate on the currently visible screen buffer. */ 1010 + if (is_alt) { 1011 + tmp = term.line; 1012 + term.line = term.alt; 1013 + term.alt = tmp; 1014 } 1015 1016 - /* resize to new height */ 1017 - term.line = xrealloc(term.line, row * sizeof(Line)); 1018 - term.alt = xrealloc(term.alt, row * sizeof(Line)); 1019 - term.dirty = xrealloc(term.dirty, row * sizeof(*term.dirty)); 1020 - term.tabs = xrealloc(term.tabs, col * sizeof(*term.tabs)); 1021 + save_end = term.row; 1022 + if (term.row != 0 && term.col != 0) { 1023 + if (!is_alt && term.c.y > 0 && term.c.y < term.row) { 1024 + term.line[term.c.y - 1][term.col - 1].mode &= ~ATTR_WRAP; 1025 + } 1026 + min_limit = is_alt ? 0 : term.c.y; 1027 1028 - /* resize each row to new width, zero-pad if needed */ 1029 - for (i = 0; i < minrow; i++) { 1030 - term.line[i] = xrealloc(term.line[i], col * sizeof(Glyph)); 1031 - term.alt[i] = xrealloc(term.alt[i], col * sizeof(Glyph)); 1032 - } 1033 + for (i = term.row - 1; i > min_limit; i--) { 1034 + if (tlinelen(term.line[i]) > 0) 1035 + break; 1036 + } 1037 + save_end = i + 1; 1038 1039 - /* allocate any new rows */ 1040 - for (/* i = minrow */; i < row; i++) { 1041 - term.line[i] = xmalloc(col * sizeof(Glyph)); 1042 - term.alt[i] = xmalloc(col * sizeof(Glyph)); 1043 + for (i = 0; i < save_end; i++) { 1044 + sb_push(term.line[i]); 1045 + } 1046 + /* Optimization: Only reflow if content doesn't fit in new width. 1047 + * This avoids expensive reflow operations when resizing doesn't 1048 + * affect line wrapping (e.g., when terminal is wide enough). */ 1049 + if (col > term.col) { 1050 + /* Growing: We MUST reflow. Even if the text doesn't need 1051 + * un-wrapping, the history lines must be physically reallocated 1052 + * to the new width to prevent heap-buffer-overflows on read. */ 1053 + needs_reflow = 1; 1054 + } else if (col < term.col) { 1055 + /* Shrinking: Only reflow if content is wider than new width. */ 1056 + if (sb.max_width > col) 1057 + needs_reflow = 1; 1058 + } 1059 + if (needs_reflow) { 1060 + sb_resize(col); 1061 + } else { 1062 + /* If we don't reflow, we still need to reset the view 1063 + * because sb_pop_screen() might change the history length. */ 1064 + sb.view_offset = 0; 1065 + } 1066 } 1067 - if (col > term.col) { 1068 - bp = term.tabs + term.col; 1069 1070 - memset(bp, 0, sizeof(*term.tabs) * (col - term.col)); 1071 - while (--bp > term.tabs && !*bp) 1072 - /* nothing */ ; 1073 - for (bp += tabspaces; bp < term.tabs + col; bp += tabspaces) 1074 - *bp = 1; 1075 - } 1076 - /* update terminal size */ 1077 + if (term.line) { 1078 + for (i = 0; i < term.row; i++) { 1079 + free(term.line[i]); 1080 + free(term.alt[i]); 1081 + } 1082 + free(term.line); 1083 + free(term.alt); 1084 + free(term.dirty); 1085 + free(term.tabs); 1086 + } 1087 + 1088 term.col = col; 1089 term.row = row; 1090 - /* reset scrolling region */ 1091 - tsetscroll(0, row-1); 1092 - /* make use of the LIMIT in tmoveto */ 1093 - tmoveto(term.c.x, term.c.y); 1094 - /* Clearing both screens (it makes dirty all lines) */ 1095 - c = term.c; 1096 - for (i = 0; i < 2; i++) { 1097 - if (mincol < col && 0 < minrow) { 1098 - tclearregion(mincol, 0, col - 1, minrow - 1); 1099 - } 1100 - if (0 < col && minrow < row) { 1101 - tclearregion(0, minrow, col - 1, row - 1); 1102 + 1103 + term.line = xmalloc(term.row * sizeof(Line)); 1104 + term.alt = xmalloc(term.row * sizeof(Line)); 1105 + term.dirty = xmalloc(term.row * sizeof(int)); 1106 + term.tabs = xmalloc(term.col * sizeof(*term.tabs)); 1107 + 1108 + for (i = 0; i < term.row; i++) { 1109 + term.line[i] = xmalloc(term.col * sizeof(Glyph)); 1110 + term.alt[i] = xmalloc(term.col * sizeof(Glyph)); 1111 + term.dirty[i] = 1; 1112 + 1113 + for (j = 0; j < term.col; j++) { 1114 + term.line[i][j] = term.c.attr; 1115 + term.line[i][j].u = ' '; 1116 + term.line[i][j].mode = 0; 1117 + 1118 + term.alt[i][j] = term.c.attr; 1119 + term.alt[i][j].u = ' '; 1120 + term.alt[i][j].mode = 0; 1121 } 1122 - tswapscreen(); 1123 - tcursor(CURSOR_LOAD); 1124 } 1125 - term.c = c; 1126 + 1127 + memset(term.tabs, 0, term.col * sizeof(*term.tabs)); 1128 + for (i = 8; i < term.col; i += 8) 1129 + term.tabs[i] = 1; 1130 + 1131 + tsetscroll(0, term.row - 1); 1132 + 1133 + if (minrow > 0) { 1134 + loaded = MIN(sb.len, term.row); 1135 + pop_width = needs_reflow ? col : MIN(col, old_col); 1136 + sb_pop_screen(loaded, pop_width); 1137 + } 1138 + if (is_alt) { 1139 + tmp = term.line; 1140 + term.line = term.alt; 1141 + term.alt = tmp; 1142 + } 1143 + if (!is_alt && old_row > 0) { 1144 + term.c.y += (loaded - save_end); 1145 + } 1146 + if (term.c.y >= term.row) { 1147 + term.c.y = term.row - 1; 1148 + } 1149 + if (term.c.x >= term.col) { 1150 + term.c.x = term.col - 1; 1151 + } 1152 + if (term.c.y < 0) { 1153 + term.c.y = 0; 1154 + } 1155 + if (term.c.x < 0) { 1156 + term.c.x = 0; 1157 + } 1158 + 1159 + tfulldirt(); 1160 + sb_view_changed(); 1161 } 1162 1163 void 1164 @@ -2669,12 +3288,13 @@ drawregion(int x1, int y1, int x2, int y2) 1165 { 1166 int y; 1167 1168 + Line line; 1169 for (y = y1; y < y2; y++) { 1170 if (!term.dirty[y]) 1171 continue; 1172 - 1173 term.dirty[y] = 0; 1174 - xdrawline(term.line[y], x1, y, x2); 1175 + line = renderline(y); 1176 + xdrawline(line, x1, y, x2); 1177 } 1178 } 1179 1180 @@ -2695,10 +3315,12 @@ draw(void) 1181 cx--; 1182 1183 drawregion(0, 0, term.col, term.row); 1184 - xdrawcursor(cx, term.c.y, term.line[term.c.y][cx], 1185 - term.ocx, term.ocy, term.line[term.ocy][term.ocx]); 1186 - term.ocx = cx; 1187 - term.ocy = term.c.y; 1188 + if (sb.view_offset == 0) { 1189 + xdrawcursor(cx, term.c.y, term.line[term.c.y][cx], 1190 + term.ocx, term.ocy, term.line[term.ocy][term.ocx]); 1191 + term.ocx = cx; 1192 + term.ocy = term.c.y; 1193 + } 1194 xfinishdraw(); 1195 if (ocx != term.ocx || ocy != term.ocy) 1196 xximspot(term.ocx, term.ocy); 1197 diff --git a/st.h b/st.h 1198 index fd3b0d8..66f1436 100644 1199 --- a/st.h 1200 +++ b/st.h 1201 @@ -86,6 +86,7 @@ void printsel(const Arg *); 1202 void sendbreak(const Arg *); 1203 void toggleprinter(const Arg *); 1204 1205 +int tisaltscreen(void); 1206 int tattrset(int); 1207 void tnew(int, int); 1208 void tresize(int, int); 1209 @@ -102,6 +103,7 @@ void selclear(void); 1210 void selinit(void); 1211 void selstart(int, int, int); 1212 void selextend(int, int, int, int); 1213 +int selextendto(int, int, int); 1214 int selected(int, int); 1215 char *getsel(void); 1216 1217 @@ -111,6 +113,9 @@ void *xmalloc(size_t); 1218 void *xrealloc(void *, size_t); 1219 char *xstrdup(const char *); 1220 1221 +void kscrollup(const Arg *arg); 1222 +void kscrolldown(const Arg *arg); 1223 + 1224 /* config.h globals */ 1225 extern char *utmp; 1226 extern char *scroll; 1227 @@ -124,3 +129,4 @@ extern unsigned int tabspaces; 1228 extern unsigned int defaultfg; 1229 extern unsigned int defaultbg; 1230 extern unsigned int defaultcs; 1231 +extern unsigned int scrollback_lines; 1232 diff --git a/x.c b/x.c 1233 index 76f572d..8091e6f 100644 1234 --- a/x.c 1235 +++ b/x.c 1236 @@ -181,6 +181,7 @@ static void selnotify(XEvent *); 1237 static void selclear_(XEvent *); 1238 static void selrequest(XEvent *); 1239 static void setsel(char *, Time); 1240 +static int mouseseltype(XEvent *); 1241 static void mousesel(XEvent *, int); 1242 static void mousereport(XEvent *); 1243 static char *kmap(KeySym, uint); 1244 @@ -344,19 +345,23 @@ evrow(XEvent *e) 1245 return y / win.ch; 1246 } 1247 1248 -void 1249 -mousesel(XEvent *e, int done) 1250 +int 1251 +mouseseltype(XEvent *e) 1252 { 1253 - int type, seltype = SEL_REGULAR; 1254 + int type; 1255 uint state = e->xbutton.state & ~(Button1Mask | forcemousemod); 1256 1257 for (type = 1; type < LEN(selmasks); ++type) { 1258 - if (match(selmasks[type], state)) { 1259 - seltype = type; 1260 - break; 1261 - } 1262 + if (match(selmasks[type], state)) 1263 + return type; 1264 } 1265 - selextend(evcol(e), evrow(e), seltype, done); 1266 + return SEL_REGULAR; 1267 +} 1268 + 1269 +void 1270 +mousesel(XEvent *e, int done) 1271 +{ 1272 + selextend(evcol(e), evrow(e), mouseseltype(e), done); 1273 if (done) 1274 setsel(getsel(), e->xbutton.time); 1275 } 1276 @@ -472,6 +477,26 @@ bpress(XEvent *e) 1277 struct timespec now; 1278 int snap; 1279 1280 + if (btn == Button4 || btn == Button5) { 1281 + Arg a; 1282 + if (IS_SET(MODE_MOUSE) && !(e->xbutton.state & forcemousemod)) { 1283 + mousereport(e); 1284 + return; 1285 + } 1286 + if (!tisaltscreen()) { 1287 + a.i = 1; 1288 + if (btn == Button4) { 1289 + kscrollup(&a); 1290 + } else { 1291 + kscrolldown(&a); 1292 + } 1293 + /* keep a selection being dragged under the pointer */ 1294 + if (buttons & 1) 1295 + mousesel(e, 0); 1296 + } 1297 + return; 1298 + } 1299 + 1300 if (1 <= btn && btn <= 11) 1301 buttons |= 1 << (btn-1); 1302 1303 @@ -484,6 +509,11 @@ bpress(XEvent *e) 1304 return; 1305 1306 if (btn == Button1) { 1307 + /* shift+click extends the current selection */ 1308 + if ((e->xbutton.state & ShiftMask) && !IS_SET(MODE_MOUSE) && 1309 + selextendto(evcol(e), evrow(e), mouseseltype(e))) 1310 + return; 1311 + 1312 /* 1313 * If the user clicks below predefined timeouts specific 1314 * snapping behaviour is exposed. 1315 -- 1316 2.56.0 1317