htoprs 0.5.3

A faithful Rust port of htop — the interactive process viewer
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
//! htoprs-original: single-write frame presentation to kill terminal flicker.
//!
//! htop relies on ncurses' `doupdate` (diff against a virtual screen, emit only
//! changed cells). htoprs draws directly on crossterm, so the run loop repaints
//! every cell each frame. Wrapping that in a DEC-2026 synchronized-update region
//! (`\e[?2026h … \e[?2026l`) makes it atomic — but only if the terminal sees
//! `End` before its synchronized-update *timeout*. The draw code flushes to
//! stdout incrementally, so on a slow machine the bytes trickle out over the
//! whole (multi-millisecond) repaint; the terminal times out, auto-ends the
//! region, and renders a partial frame — visible flicker. Same terminal, faster
//! machine: the repaint finishes inside the timeout, so it never showed.
//!
//! The fix: collect the entire frame into a thread-local buffer during drawing
//! (no stdout writes, no flushes), then in [`present`] emit
//! `Begin` + the whole buffer + `End` in one `write_all`. Now `Begin` and `End`
//! reach the terminal microseconds apart regardless of how long the *drawing*
//! took, so the timeout can never trip; the slow compute happens before `Begin`
//! is ever sent. It also collapses many small writes into one, which by itself
//! removes most of the flicker even on terminals that ignore 2026.
//!
//! Drawing routes through [`frame_out`], a zero-sized [`Write`] that appends to
//! the buffer while a frame is open and falls back to real stdout otherwise —
//! so a draw done outside a `begin_frame`/`present` bracket (e.g. a modal that
//! paints and waits) still reaches the screen.

use std::cell::RefCell;
use std::collections::BTreeMap;
use std::io::{self, Write};

thread_local! {
    /// The in-progress frame's bytes, or `None` when no frame is open.
    static FRAME: RefCell<Option<Vec<u8>>> = const { RefCell::new(None) };
    /// The last frame we presented, split into per-terminal-row byte segments,
    /// so [`present`] can skip re-emitting rows that did not change — the core
    /// of the flicker fix (a CPU-% tick repaints a handful of rows, not the
    /// whole ~150 KB screen). `None` until the first frame or after a clear.
    static PREV: RefCell<Option<Screen>> = const { RefCell::new(None) };
    /// Set by [`request_clear`] when the physical screen must be wiped before
    /// the next frame (a terminal resize reflowed/scrolled it out from under the
    /// diff). Consumed by the next [`begin_frame`], which prepends a `\e[2J` to
    /// the buffer so the whole terminal — including rows this frame won't draw —
    /// is cleared.
    static CLEAR_PENDING: RefCell<bool> = const { RefCell::new(false) };
}

/// DEC private mode 2026 (synchronized update) begin/end.
const BEGIN_SYNC: &[u8] = b"\x1b[?2026h";
const END_SYNC: &[u8] = b"\x1b[?2026l";

/// Erase the whole display (`\e[2J`).
const ERASE_DISPLAY: &[u8] = b"\x1b[2J";

/// A presented frame decomposed for row-level diffing: a `preamble` (bytes
/// before the first cursor move — usually an initial SGR / cursor toggle) and
/// one self-contained byte segment per terminal row. Each row segment begins
/// with the SGR that was in effect when the cursor landed on the row, so it can
/// be re-emitted alone and render identically regardless of which other rows
/// were skipped.
#[derive(Default, PartialEq)]
struct Screen {
    preamble: Vec<u8>,
    rows: BTreeMap<usize, Vec<u8>>,
}

/// Open a frame: subsequent [`frame_out`] writes are buffered until [`present`].
/// Idempotent — reusing the allocation and clearing any un-presented bytes.
///
/// If a full clear was requested (see [`request_clear`]), the buffer opens with
/// a `\e[2J` erase and the diff cache is dropped, so this frame wipes the whole
/// terminal and repaints every row — the recovery path after a resize reflowed
/// the screen out from under the row diff.
pub fn begin_frame() {
    let clear = CLEAR_PENDING.with(|c| c.replace(false));
    FRAME.with(|f| {
        let mut slot = f.borrow_mut();
        match slot.as_mut() {
            Some(buf) => buf.clear(),
            None => *slot = Some(Vec::with_capacity(64 * 1024)),
        }
        if clear {
            slot.as_mut().unwrap().extend_from_slice(ERASE_DISPLAY);
        }
    });
    if clear {
        invalidate();
    }
}

/// Request that the next [`begin_frame`] wipe the whole physical screen (emit a
/// `\e[2J`) before drawing. Call this after a terminal resize: the emulator may
/// have reflowed or scrolled the old image, so rows the next frame does not
/// redraw would otherwise keep stale content — the "data above the header"
/// artifact on SIGWINCH. A plain [`invalidate`] only re-emits the rows the frame
/// *does* draw; this also clears the ones it doesn't.
pub fn request_clear() {
    CLEAR_PENDING.with(|c| *c.borrow_mut() = true);
}

/// A [`Write`] sink for all frame drawing. While a frame is open it appends to
/// the thread-local buffer; otherwise it writes straight to stdout (so draws
/// outside a frame still show). Zero-sized: cheap to construct per draw call.
pub struct FrameOut;

impl Write for FrameOut {
    fn write(&mut self, data: &[u8]) -> io::Result<usize> {
        FRAME.with(|f| {
            let mut slot = f.borrow_mut();
            match slot.as_mut() {
                Some(buf) => {
                    buf.extend_from_slice(data);
                    Ok(data.len())
                }
                None => io::stdout().lock().write(data),
            }
        })
    }

    fn flush(&mut self) -> io::Result<()> {
        // A frame is flushed atomically in `present`; an incremental flush mid
        // frame is exactly what causes the tearing, so swallow it. Outside a
        // frame, forward the flush so direct draws land immediately.
        FRAME.with(|f| {
            if f.borrow().is_none() {
                io::stdout().lock().flush()
            } else {
                Ok(())
            }
        })
    }
}

/// The frame sink. Draw code uses this in place of `io::stdout().lock()`.
pub fn frame_out() -> FrameOut {
    FrameOut
}

/// Drop the diff cache so the next [`present`] re-emits every row in full.
/// Call this whenever something painted the screen outside the frame pipeline
/// (a modal that draws directly and clears, a resize, an explicit redraw) —
/// otherwise the diff would compare against a stale picture and skip rows that
/// are actually different on screen.
pub fn invalidate() {
    PREV.with(|p| *p.borrow_mut() = None);
}

/// Close the open frame and present only what changed since the last frame:
/// parse the buffer into per-row segments, diff against the previous frame, and
/// write just the changed rows (wrapped in one 2026 region). A no-op when no
/// frame is open, the frame is empty, or nothing changed.
pub fn present() {
    let mut out = io::stdout().lock();
    if present_to(&mut out) {
        let _ = out.flush();
    }
}

/// Diff the open frame against the last one and write only the changed rows to
/// `out`, wrapped in `Begin`/`End`. Returns `true` if anything was written.
/// Split out from [`present`] so the diff is testable against an in-memory
/// writer. The frame is always closed (taken) if one was open.
fn present_to<W: Write>(out: &mut W) -> bool {
    let taken = FRAME.with(|f| f.borrow_mut().take());
    let Some(buf) = taken else {
        return false;
    };
    if buf.is_empty() {
        return false;
    }

    let (next, clear) = parse_frame(&buf);

    // A screen clear (`\e[2J`, from a modal) invalidates the whole prior frame.
    let base = PREV.with(|p| p.borrow_mut().take());
    let base = if clear { None } else { base };

    let mut body: Vec<u8> = Vec::new();

    // Preamble (initial SGR / cursor toggle): emit when it changed.
    if base.as_ref().map(|b| &b.preamble) != Some(&next.preamble) {
        body.extend_from_slice(&next.preamble);
    }

    // Changed rows only — this is what removes the full-screen repaint.
    for (row, seg) in &next.rows {
        if base.as_ref().and_then(|b| b.rows.get(row)) != Some(seg) {
            body.extend_from_slice(seg);
        }
    }

    // Rows that existed last frame but are absent now (terminal shrank / fewer
    // lines drawn): blank them so no stale text lingers.
    if let Some(b) = base.as_ref() {
        for row in b.rows.keys() {
            if !next.rows.contains_key(row) {
                let _ = write!(&mut body, "\x1b[{};1H\x1b[0m\x1b[K", row + 1);
            }
        }
    }

    PREV.with(|p| *p.borrow_mut() = Some(next));

    if body.is_empty() {
        return false; // nothing changed → nothing to draw → no flicker
    }
    let _ = out.write_all(BEGIN_SYNC);
    let _ = out.write_all(&body);
    let _ = out.write_all(END_SYNC);
    true
}

/// Split a frame's raw byte stream into a [`Screen`] for row diffing. Tracks the
/// cursor row from CUP (`\e[row;colH`) sequences and the active SGR state; each
/// row segment is prefixed with the entry SGR so it renders correctly on its
/// own. Also reports whether a full-screen clear (`\e[2J`) appeared. Any bytes
/// that are not a recognized CSI are copied through verbatim, so unknown
/// sequences and text are preserved.
fn parse_frame(frame: &[u8]) -> (Screen, bool) {
    let mut screen = Screen::default();
    let mut clear = false;
    let mut cur_row: Option<usize> = None;
    let mut cur_sgr: Vec<u8> = Vec::new();

    // Append `bytes` to whichever bucket the cursor is currently in.
    fn push(screen: &mut Screen, cur_row: Option<usize>, bytes: &[u8]) {
        match cur_row {
            None => screen.preamble.extend_from_slice(bytes),
            // The bucket exists: it is created when the cursor moves to the row.
            Some(r) => {
                if let Some(buf) = screen.rows.get_mut(&r) {
                    buf.extend_from_slice(bytes);
                }
            }
        }
    }

    let n = frame.len();
    let mut i = 0;
    while i < n {
        if frame[i] == 0x1b && i + 1 < n && frame[i + 1] == b'[' {
            // CSI: params (0x30..=0x3f), intermediates (0x20..=0x2f), final (0x40..=0x7e).
            let start = i;
            let mut j = i + 2;
            while j < n && (0x20..=0x3f).contains(&frame[j]) {
                j += 1;
            }
            if j >= n {
                // Truncated CSI at end of buffer — copy the rest through.
                push(&mut screen, cur_row, &frame[start..]);
                break;
            }
            let final_b = frame[j];
            let params = &frame[i + 2..j];
            let seq = &frame[start..=j];
            match final_b {
                b'H' | b'f' => {
                    let row = parse_first_param(params).saturating_sub(1);
                    cur_row = Some(row);
                    // Re-assert the active SGR on EVERY move into the row, not
                    // just the first: a draw commonly sets colors *before* its
                    // `MoveTo` (e.g. the status toast: fg/bg/bold, then move,
                    // then text). Those SGR bytes were attributed to whatever
                    // row the cursor was on before, so without re-applying here
                    // the moved-to content (and any later overdraw of the same
                    // row) would render with the wrong/again no colors — that is
                    // why the toast lost its background. Prepending `cur_sgr` to
                    // each move keeps every row segment self-contained.
                    let bucket = screen.rows.entry(row).or_default();
                    bucket.extend_from_slice(&cur_sgr);
                    bucket.extend_from_slice(seq);
                }
                b'm' => {
                    if is_sgr_reset(params) {
                        cur_sgr.clear();
                    }
                    cur_sgr.extend_from_slice(seq);
                    push(&mut screen, cur_row, seq);
                }
                b'J' => {
                    if params == b"2" {
                        clear = true;
                    }
                    push(&mut screen, cur_row, seq);
                }
                _ => push(&mut screen, cur_row, seq),
            }
            i = j + 1;
        } else {
            // Text or a non-CSI escape: copy the single byte through.
            push(&mut screen, cur_row, &frame[i..i + 1]);
            i += 1;
        }
    }

    (screen, clear)
}

/// The first numeric parameter of a CSI sequence (digits before `;`), or `1`
/// when absent — matching the CUP default (`\e[H` == row 1, col 1).
fn parse_first_param(params: &[u8]) -> usize {
    let mut n = 0usize;
    let mut seen = false;
    for &b in params {
        if b.is_ascii_digit() {
            n = n * 10 + (b - b'0') as usize;
            seen = true;
        } else {
            break;
        }
    }
    if seen {
        n
    } else {
        1
    }
}

/// Whether an SGR sequence resets all attributes (`\e[m`, `\e[0m`, `\e[00m`).
fn is_sgr_reset(params: &[u8]) -> bool {
    params.is_empty() || params == b"0" || params == b"00"
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Fresh thread-local state (each test thread starts clean, but be explicit).
    fn reset() {
        FRAME.with(|f| *f.borrow_mut() = None);
        PREV.with(|p| *p.borrow_mut() = None);
        CLEAR_PENDING.with(|c| *c.borrow_mut() = false);
    }

    /// Draw `bytes` as one frame and present it to an in-memory sink; returns
    /// (sink bytes, wrote?).
    fn render(bytes: &[u8]) -> (Vec<u8>, bool) {
        begin_frame();
        frame_out().write_all(bytes).unwrap();
        let mut sink = Vec::new();
        let wrote = present_to(&mut sink);
        (sink, wrote)
    }

    /// Strip the 2026 begin/end wrapper to inspect the body.
    fn body(sink: &[u8]) -> Vec<u8> {
        assert!(sink.starts_with(BEGIN_SYNC) && sink.ends_with(END_SYNC));
        sink[BEGIN_SYNC.len()..sink.len() - END_SYNC.len()].to_vec()
    }

    /// The first frame is emitted in full (no previous frame to diff against),
    /// wrapped in exactly one 2026 region.
    #[test]
    fn first_frame_emits_everything() {
        reset();
        let (sink, wrote) = render(b"\x1b[1;1HAAAA\x1b[2;1HBBBB");
        assert!(wrote);
        assert_eq!(body(&sink), b"\x1b[1;1HAAAA\x1b[2;1HBBBB");
        assert_eq!(
            sink.windows(BEGIN_SYNC.len())
                .filter(|w| *w == BEGIN_SYNC)
                .count(),
            1
        );
        reset();
    }

    /// An identical second frame changes nothing → nothing is written. This is
    /// what stops the idle full-screen repaint from flickering.
    #[test]
    fn identical_frame_writes_nothing() {
        reset();
        let f = b"\x1b[1;1HAAAA\x1b[2;1HBBBB";
        assert!(render(f).1);
        let (sink, wrote) = render(f);
        assert!(!wrote, "unchanged frame must not emit");
        assert!(sink.is_empty());
        reset();
    }

    /// When one row changes, ONLY that row is re-emitted — not the whole screen.
    /// This is the core of the flicker fix: a CPU-% tick touches a few rows.
    #[test]
    fn only_changed_rows_are_emitted() {
        reset();
        assert!(render(b"\x1b[1;1HAAAA\x1b[2;1HBBBB\x1b[3;1HCCCC").1);
        // Row 2 (index 1) changes; rows 1 and 3 stay.
        let (sink, wrote) = render(b"\x1b[1;1HAAAA\x1b[2;1HZZZZ\x1b[3;1HCCCC");
        assert!(wrote);
        // Body is exactly the changed row's segment, nothing else.
        assert_eq!(body(&sink), b"\x1b[2;1HZZZZ");
        reset();
    }

    /// A row's colour, set by an SGR that preceded its cursor move, is carried
    /// as the row's entry SGR — so re-emitting only that row keeps the colour
    /// even though the SGR-setting bytes lived in an earlier (skipped) place.
    #[test]
    fn changed_row_keeps_its_entry_sgr() {
        reset();
        // Green set once up front, then two rows drawn in it.
        assert!(render(b"\x1b[32m\x1b[1;1HG1\x1b[2;1HG2").1);
        // Row 2 changes; the emitted segment must re-assert green.
        let (sink, _) = render(b"\x1b[32m\x1b[1;1HG1\x1b[2;1HXY");
        assert_eq!(body(&sink), b"\x1b[32m\x1b[2;1HXY");
        reset();
    }

    /// The status-toast pattern: row 2 is drawn as a plain process row earlier
    /// in the frame; then a toast sets its bg SGR (while the cursor is on a
    /// DIFFERENT row), moves back to row 2, and prints. Because the SGR is
    /// emitted before the move AND row 2 already exists, the fix must re-assert
    /// the active SGR on the revisit so the toast keeps its background.
    #[test]
    fn toast_over_existing_row_keeps_background() {
        // proc on row2, other on row1, then bg-highlight + move back to row2.
        let (s, _) = parse_frame(b"\x1b[2;1Hproc\x1b[1;1Hother\x1b[48;5;4m\x1b[2;1Htoast");
        let seg = s.rows.get(&1).expect("row 2 present");
        // The bg SGR must appear immediately before the toast's move-to(2,1).
        let want = b"\x1b[48;5;4m\x1b[2;1Htoast";
        assert!(
            seg.windows(want.len()).any(|w| w == want),
            "row 2 segment missing bg before toast text: {:?}",
            String::from_utf8_lossy(seg)
        );
    }

    /// A `\e[2J` clear invalidates the whole previous frame → everything is
    /// re-emitted even if row bytes match.
    #[test]
    fn clear_forces_full_redraw() {
        reset();
        let f = b"\x1b[1;1HAAAA\x1b[2;1HBBBB";
        assert!(render(f).1);
        // Same rows, but prefixed with a clear: must re-emit both rows.
        let (sink, wrote) = render(b"\x1b[2J\x1b[1;1HAAAA\x1b[2;1HBBBB");
        assert!(wrote);
        assert!(body(&sink).windows(4).any(|w| w == b"AAAA"));
        assert!(body(&sink).windows(4).any(|w| w == b"BBBB"));
        reset();
    }

    /// A row present last frame but absent now is blanked (cleared to EOL) so
    /// no stale text is left behind when the drawn area shrinks.
    #[test]
    fn vanished_row_is_blanked() {
        reset();
        assert!(render(b"\x1b[1;1HAAAA\x1b[2;1HBBBB").1);
        // Second frame only draws row 1; row 2 disappears.
        let (sink, wrote) = render(b"\x1b[1;1HAAAA");
        assert!(wrote);
        // Body clears row 2 (1-based line 2).
        assert!(body(&sink).windows(6).any(|w| w == b"\x1b[2;1H"));
        assert!(body(&sink).windows(3).any(|w| w == b"\x1b[K"));
        reset();
    }

    // ── parser unit tests ──

    /// CUP row parsing is 1-based → 0-based; text lands in the right row bucket.
    #[test]
    fn parse_splits_rows_by_cup() {
        let (s, clear) = parse_frame(b"pre\x1b[3;1Hthird\x1b[1;5Hfirst");
        assert!(!clear);
        assert_eq!(s.preamble, b"pre");
        assert_eq!(s.rows.get(&2).map(|v| &v[..]), Some(&b"\x1b[3;1Hthird"[..]));
        assert_eq!(s.rows.get(&0).map(|v| &v[..]), Some(&b"\x1b[1;5Hfirst"[..]));
    }

    /// `\e[2J` sets the clear flag.
    #[test]
    fn parse_detects_clear() {
        let (_, clear) = parse_frame(b"\x1b[2J\x1b[1;1Hx");
        assert!(clear);
        let (_, clear2) = parse_frame(b"\x1b[0J\x1b[1;1Hx");
        assert!(!clear2);
    }

    /// A bare `\e[H` defaults to row 1 (index 0).
    #[test]
    fn parse_first_param_defaults_to_one() {
        assert_eq!(parse_first_param(b""), 1);
        assert_eq!(parse_first_param(b"12;34"), 12);
        assert_eq!(parse_first_param(b"7"), 7);
    }

    /// SGR reset detection.
    #[test]
    fn sgr_reset_recognized() {
        assert!(is_sgr_reset(b""));
        assert!(is_sgr_reset(b"0"));
        assert!(!is_sgr_reset(b"1"));
        assert!(!is_sgr_reset(b"38;5;2"));
    }

    /// A truncated CSI at end of buffer is copied through, not dropped/panicked.
    #[test]
    fn truncated_csi_is_preserved() {
        let (s, _) = parse_frame(b"\x1b[1;1Hx\x1b[3");
        assert_eq!(
            s.rows.get(&0).map(|v| &v[..]),
            Some(&b"\x1b[1;1Hx\x1b[3"[..])
        );
    }

    /// Frame buffering: writes accumulate and `begin_frame` clears stale bytes.
    #[test]
    fn begin_frame_buffers_and_clears() {
        reset();
        begin_frame();
        frame_out().write_all(b"stale").unwrap();
        begin_frame();
        FRAME.with(|f| assert_eq!(f.borrow().as_deref(), Some(&b""[..])));
        reset();
    }

    /// After [`request_clear`] (the SIGWINCH recovery path), the next frame
    /// opens with a `\e[2J`, drops the diff cache, and re-emits every row even
    /// though the row bytes are byte-identical to the previous frame — so stale
    /// content the terminal reflowed on resize is wiped instead of skipped.
    #[test]
    fn request_clear_wipes_and_repaints_next_frame() {
        reset();
        let f = b"\x1b[1;1HAAAA\x1b[2;1HBBBB";
        assert!(render(f).1);
        // Without a clear, an identical frame would emit nothing.
        request_clear();
        let (sink, wrote) = render(f);
        assert!(wrote, "resize recovery frame must emit");
        let out = body(&sink);
        assert!(
            out.windows(4).any(|w| w == ERASE_DISPLAY),
            "must erase display"
        );
        assert!(out.windows(4).any(|w| w == b"AAAA"), "row 1 re-emitted");
        assert!(out.windows(4).any(|w| w == b"BBBB"), "row 2 re-emitted");
        // The flag is one-shot: the following identical frame goes quiet again.
        let (_, wrote2) = render(f);
        assert!(!wrote2, "clear must not persist past one frame");
        reset();
    }

    /// `present_to` with no open frame writes nothing and returns false.
    #[test]
    fn present_without_frame_is_noop() {
        reset();
        let mut sink = Vec::new();
        assert!(!present_to(&mut sink));
        assert!(sink.is_empty());
        reset();
    }
}