mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
## Summary - ship **Buzz Term** end to end: the terminal engine/runtime, mounted desktop substrate, and user-visible naming - add Quinn's tape-deck-inspired banner: a beveled chassis filled by the `buzz term` wordmark, surrounded by a complete-hex field - derive the wordmark's three-stop sweep from each theme's terminal palette so primary, secondary, and accent roles remain visibly distinct across all 62 shipped themes, including light themes - paint the banner once on its own pointer-transparent canvas; PTY rendering beneath it remains unchanged ## Banner behavior - uses the renderer's shared `8.4 × 17` cell metrics and production aspect ratio `2.0238` - regenerates only for viewport/theme changes; palette switches repaint correctly while the banner is visible - dismisses on non-empty output from the active terminal session; empty output and inactive sessions do not dismiss it - fails closed below **70 columns** rather than squeezing or clipping the wordmark - adds **8 lines** to `terminalRenderer.ts` for shared cell metrics and **zero lines inside `paint()`** ## Screenshots | Buzz (light) | Buzz Dark | |---|---| |  |  | | Kanagawa Lotus (light) | Red | |---|---| |  |  | Additional production-aspect finals: [Vesper](https://buzz.block.builderlab.xyz/media/9ca6514b63f8cfb2107a85ca46f16a940c0883848e6fbc718e411af94aa13100.png), [Min Dark](https://buzz.block.builderlab.xyz/media/f67bd2970e5d64ffb07b1ae78ab58c847e6ebc23e7e7a48e067eb024dba64ec8.png), and [Dark Plus](https://buzz.block.builderlab.xyz/media/290fee08924f37d064abc687ecf3e9526ab05b87e8e56d610f23048949793dbe.png). The screenshot harness was checked against the shipped painter at this exact head: all **2,541 draw calls** matched on color, glyph, x, and y; four deliberate divergence controls fired. ## Verification at `98ebc8f9048bd5f0ceb7e843b67874d642f0b7fd` - desktop tests: **3,946 / 3,946** - TypeScript: clean - checks: pass (two pre-existing informational `useTemplate` notices only) - integration/e2e: PASS (independent exact-SHA lane; artifacts recorded in the originating Buzz thread) - artifact/dead-path sweep: clean - redteam G1–G7: PASS - all six named banner emitter-deletion mutants die - independent handwritten five-row full-wordmark fixture kills Quinn's seven-mutant battery, including a one-pixel glyph change - real `112 × 46` canvas-rect dismissal tests separately cover active non-empty, active empty, and inactive non-empty output - layer-drop and zero-draw painter mutants die; z-order and pointer-events verified - CI's `tsc && vite build` includes all three banner modules - performance at DPR 2 (worst-case measured envelope): - one-time content paint: **~0.7–0.8 ms**, paid only when the banner is built or its palette changes - busy compositor, CSS `1277 × 697`, backing `2554 × 1394`: **470–497 µs/frame** for the full banner (**2.82–2.98%** of a 60 Hz frame) - busy compositor, CSS `1920 × 1080`, backing `3840 × 2160`: **1,139–1,212 µs/frame** (**6.83–7.27%**) - empty, one-glyph, and full-banner controls converge: compositor cost follows backing-layer area and DPR rather than painted-cell count - in the actual idle welcome state, cost is below both vsync-clamped rigs' resolution; it is not claimed as zero - **Pane cross-rig spread: resolved at matched loop rate.** Two independent rigs initially differed 2.3× (58–68 vs 136 µs/Mpx of backing store; pane, CSS 1277×697 / backing 2554×1394, DPR 2). The cause of *that* spread is rAF loop rate: the higher figure came from a free-running loop at ~1600fps. Throttled to ~200–236fps, both rigs read 58–68 µs/Mpx (1.25–1.44% of a 60Hz frame). The busy-composite figures quoted above remain the **unthrottled worst case** and are conservative by ~2.3× at the pane. Not established: the mechanism and sign of free-running distortion (one rig under-charges ~15%, the other over-charges 2.3×), and the 1080p figure has not been re-measured throttled. - the layer paints only on generation/theme/resize and dismisses on first non-empty active-session output, so the measurable busy cost is a short-lived worst case rather than a persistent PTY paint-path tax ## Follow-ups in this PR These are intentionally subsequent commits after the certified static-banner head, not claims about `98ebc8f90`: 1. close the compositor metrology: remeasure the 1080p point throttled and characterize the opposite-sign free-running rAF distortion, with each measurement regime stated 2. add Tyler's animated honeycomb color waves, gated by `prefers-reduced-motion`, a full 62-theme phase-sweep contrast check, and DPR-2 per-tick performance certification 3. land the already-proven mounted theme-switch regression probe from `RESEARCH/BUZZ_TERM_G3A_PROBE/` 4. bound the slow/hang-shaped G1-c mutant `waitFor` 5. optionally trim the generator to its ink bounding box, reducing the minimum viewport from 70 to 62 columns --------- Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz> Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: Dawn (sprout agent) <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@buzz.block.builderlab.xyz> Co-authored-by: npub1cc3ha7z055mu0rwwu7806t2wt8mj3pvu0uv5mfp2c50dahaqhczshdalg6 <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@buzz.block.builderlab.xyz> Co-authored-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@buzz.block.builderlab.xyz> Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
400 lines
15 KiB
Rust
400 lines
15 KiB
Rust
//! Reaching the scrollback the engine has always been keeping.
|
|
//!
|
|
//! The grid retains 10k lines in production and, before this, nothing could
|
|
//! move the viewport off the live edge. Three things have to hold at once for
|
|
//! that to become usable, and each one fails silently on its own:
|
|
//!
|
|
//! 1. **Direction.** A flipped sign still scrolls, still clamps, and still
|
|
//! repaints. Only a human notices. So the direction is asserted here, in
|
|
//! test names, rather than left to the caller to get right.
|
|
//! 2. **Coordinates.** Capture reads screen rows out of a grid indexed from
|
|
//! the live edge. Off-by-the-offset shows *some* plausible text.
|
|
//! 3. **Dedup.** The renderer's per-row hashes describe the screen it last
|
|
//! saw. Scrolling changes every row without changing the grid, so a scroll
|
|
//! that consumed the full-damage flag would leave those hashes describing
|
|
//! a viewport that is no longer shown -- and they would then suppress a row
|
|
//! that really did change.
|
|
|
|
use buzz_terminal::damage::{Encoder, Frame};
|
|
use buzz_terminal::fences::Fences;
|
|
use buzz_terminal::{Action, SharedTerminal, Size, Terminal};
|
|
use std::sync::mpsc::Receiver;
|
|
|
|
/// The receiver is returned rather than dropped: dropping it disconnects the
|
|
/// channel and every subsequent listener send silently fails.
|
|
fn terminal(
|
|
columns: usize,
|
|
screen_lines: usize,
|
|
scrollback: usize,
|
|
) -> (SharedTerminal, Receiver<Action>) {
|
|
let size = Size {
|
|
columns,
|
|
screen_lines,
|
|
scrollback,
|
|
};
|
|
let (term, actions) = Terminal::new(size, Fences::ALL);
|
|
(SharedTerminal::new(term), actions)
|
|
}
|
|
|
|
/// The text of every row the frame carries, indexed by screen row.
|
|
///
|
|
/// Blank rows are kept as empty strings rather than filtered out: this suite
|
|
/// is about *which row shows which line*, and dropping the blanks would
|
|
/// renumber every row after one.
|
|
fn rows_by_line(frame: &Frame) -> Vec<(usize, String)> {
|
|
frame
|
|
.rows
|
|
.iter()
|
|
.map(|row| {
|
|
(
|
|
row.line,
|
|
row.spans
|
|
.iter()
|
|
.map(|span| span.text.as_str())
|
|
.collect::<String>()
|
|
.trim_end()
|
|
.to_string(),
|
|
)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Just the text, in screen order. Only meaningful for a full frame.
|
|
fn screen(frame: &Frame) -> Vec<String> {
|
|
rows_by_line(frame)
|
|
.into_iter()
|
|
.map(|(_, text)| text)
|
|
.collect()
|
|
}
|
|
|
|
/// Fill history with numbered lines, then take a caught-up renderer.
|
|
///
|
|
/// Returns the terminal and an encoder that has already consumed the damage
|
|
/// from that output, so anything a later assertion sees is caused by the
|
|
/// thing under test rather than by the fixture.
|
|
fn scrolled_terminal(lines: usize) -> (SharedTerminal, Receiver<Action>, Encoder) {
|
|
let (shared, actions) = terminal(20, 4, 100);
|
|
let payload = (1..=lines)
|
|
.map(|n| format!("L{n:02}"))
|
|
.collect::<Vec<_>>()
|
|
.join("\r\n");
|
|
shared.feed_fully(payload.as_bytes());
|
|
let mut renderer = Encoder::new();
|
|
let _ = shared.render(&mut renderer);
|
|
(shared, actions, renderer)
|
|
}
|
|
|
|
#[test]
|
|
fn the_fixture_starts_at_the_live_edge_showing_the_newest_lines() {
|
|
let (shared, _actions, _) = scrolled_terminal(10);
|
|
let mut encoder = Encoder::new();
|
|
assert_eq!(
|
|
screen(&shared.snapshot(&mut encoder)),
|
|
vec!["L07", "L08", "L09", "L10"]
|
|
);
|
|
assert_eq!(shared.lock().display_offset(), 0);
|
|
}
|
|
|
|
/// **The direction, at the engine boundary.** Positive goes *into* history.
|
|
///
|
|
/// This is upstream's convention and the reason the embedder negates the DOM
|
|
/// delta exactly once. If this assertion and `terminal_scroll`'s negation are
|
|
/// ever flipped together the pair still passes -- which is why the embedder's
|
|
/// own direction test asserts against the DOM sign rather than against this
|
|
/// one.
|
|
#[test]
|
|
fn positive_lines_scroll_backwards_into_history() {
|
|
let (shared, _actions, _) = scrolled_terminal(10);
|
|
|
|
assert!(shared.scroll(2), "two lines of history exist to move into");
|
|
|
|
let mut encoder = Encoder::new();
|
|
assert_eq!(
|
|
screen(&shared.snapshot(&mut encoder)),
|
|
vec!["L05", "L06", "L07", "L08"],
|
|
"scrolling back two lines must show two older lines"
|
|
);
|
|
assert_eq!(shared.lock().display_offset(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn negative_lines_scroll_forwards_towards_the_live_edge() {
|
|
let (shared, _actions, _) = scrolled_terminal(10);
|
|
assert!(shared.scroll(3));
|
|
|
|
assert!(shared.scroll(-1), "one line back towards the edge");
|
|
|
|
let mut encoder = Encoder::new();
|
|
assert_eq!(
|
|
screen(&shared.snapshot(&mut encoder)),
|
|
vec!["L05", "L06", "L07", "L08"]
|
|
);
|
|
assert_eq!(shared.lock().display_offset(), 2);
|
|
}
|
|
|
|
/// The momentum guard. A trackpad flick keeps delivering events for about a
|
|
/// second after the fingers lift; once history runs out every one of them
|
|
/// must be free.
|
|
#[test]
|
|
fn scrolling_past_the_oldest_line_clamps_and_reports_no_movement() {
|
|
let (shared, _actions, _) = scrolled_terminal(10);
|
|
// Six lines of history: ten written, four on screen.
|
|
assert!(shared.scroll(6));
|
|
assert_eq!(shared.lock().display_offset(), 6);
|
|
|
|
assert!(
|
|
!shared.scroll(1),
|
|
"there is nothing older, so nothing moved"
|
|
);
|
|
assert!(
|
|
!shared.scroll(1_000),
|
|
"and a whole flick of it still moves nothing"
|
|
);
|
|
assert_eq!(shared.lock().display_offset(), 6);
|
|
|
|
let mut encoder = Encoder::new();
|
|
assert_eq!(
|
|
screen(&shared.snapshot(&mut encoder)),
|
|
vec!["L01", "L02", "L03", "L04"],
|
|
"the top of history is the oldest line, not a blank grid"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn scrolling_forwards_at_the_live_edge_reports_no_movement() {
|
|
let (shared, _actions, _) = scrolled_terminal(10);
|
|
assert!(!shared.scroll(-1));
|
|
assert!(!shared.scroll(-1_000));
|
|
assert_eq!(shared.lock().display_offset(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn snapping_to_the_bottom_moves_only_when_scrolled_back() {
|
|
let (shared, _actions, _) = scrolled_terminal(10);
|
|
assert!(
|
|
!shared.scroll_to_bottom(),
|
|
"already live: a keystroke must not cost a repaint"
|
|
);
|
|
|
|
assert!(shared.scroll(4));
|
|
assert!(shared.scroll_to_bottom(), "scrolled back: this is the snap");
|
|
assert_eq!(shared.lock().display_offset(), 0);
|
|
|
|
let mut encoder = Encoder::new();
|
|
assert_eq!(
|
|
screen(&shared.snapshot(&mut encoder)),
|
|
vec!["L07", "L08", "L09", "L10"]
|
|
);
|
|
}
|
|
|
|
/// Why the snap has to exist at all: output does **not** bring the viewport
|
|
/// back. The grid pins a scrolled-back viewport and piles new lines above it
|
|
/// (`Grid::scroll_up` advances `display_offset` when it is non-zero), which is
|
|
/// the behaviour you want while reading -- and means the echo of a keystroke
|
|
/// would otherwise land on a screen the user cannot see.
|
|
#[test]
|
|
fn output_while_scrolled_back_leaves_the_viewport_where_the_reader_put_it() {
|
|
let (shared, _actions, mut renderer) = scrolled_terminal(10);
|
|
assert!(shared.scroll(3));
|
|
|
|
shared.feed_fully(b"\r\nL11\r\nL12");
|
|
|
|
let mut encoder = Encoder::new();
|
|
assert_eq!(
|
|
screen(&shared.snapshot(&mut encoder)),
|
|
vec!["L04", "L05", "L06", "L07"],
|
|
"the reader stays put while new output accumulates below"
|
|
);
|
|
|
|
// And the snap still returns to the *new* live edge, not the old one.
|
|
assert!(shared.scroll_to_bottom());
|
|
let after = shared.render(&mut renderer);
|
|
assert!(after.full, "a viewport move is a repaint");
|
|
assert_eq!(screen(&after), vec!["L09", "L10", "L11", "L12"]);
|
|
}
|
|
|
|
/// **The silent-corruption case.**
|
|
///
|
|
/// The renderer's `Encoder` holds one content hash per screen row. Scrolling
|
|
/// changes what every row shows without changing a single cell, so those
|
|
/// hashes are stale the instant the viewport moves. The engine's protection is
|
|
/// that `scroll_display` marks the grid fully damaged and the embedder
|
|
/// republishes via `snapshot`, which does not consume damage -- so the
|
|
/// full-damage flag survives for the renderer's own next `render()`, which is
|
|
/// what clears its hashes.
|
|
///
|
|
/// The discriminating part is the row content. Row 0 after the scroll holds
|
|
/// `L04`; if a stale hash for row 0 -- taken when it held `L07` -- survived,
|
|
/// the row would still ship, because the hashes differ. So the test scrolls to
|
|
/// a position where the *pre-scroll* text reappears at the *same screen row*:
|
|
/// scrolling back 4 puts `L03..L06` on screen, and then scrolling forward 4
|
|
/// restores exactly the rows the hashes describe. A renderer whose hashes were
|
|
/// never cleared suppresses the whole screen there, and the user is left
|
|
/// looking at history that has scrolled away.
|
|
#[test]
|
|
fn a_scroll_does_not_leave_the_renderer_deduping_against_a_viewport_it_no_longer_shows() {
|
|
let (shared, _actions, mut renderer) = scrolled_terminal(10);
|
|
|
|
// The embedder's scroll path: move, then republish by snapshot.
|
|
assert!(shared.scroll(4));
|
|
let mut scroll_encoder = Encoder::new();
|
|
let republished = shared.snapshot(&mut scroll_encoder);
|
|
assert_eq!(screen(&republished), vec!["L03", "L04", "L05", "L06"]);
|
|
|
|
// The renderer thread's own next capture must still be told to repaint.
|
|
let after_scroll = shared.render(&mut renderer);
|
|
assert!(
|
|
after_scroll.full,
|
|
"the scroll's snapshot must not have eaten the full-damage flag"
|
|
);
|
|
assert_eq!(screen(&after_scroll), vec!["L03", "L04", "L05", "L06"]);
|
|
|
|
// Now back to where the renderer's *original* hashes were taken. Every row
|
|
// matches a hash it already holds, so only a cleared cache ships them.
|
|
assert!(shared.scroll(-4));
|
|
let mut back_encoder = Encoder::new();
|
|
let _ = shared.snapshot(&mut back_encoder);
|
|
let after_return = shared.render(&mut renderer);
|
|
assert!(after_return.full);
|
|
assert_eq!(
|
|
screen(&after_return),
|
|
vec!["L07", "L08", "L09", "L10"],
|
|
"returning to a previously-hashed viewport must still repaint it"
|
|
);
|
|
}
|
|
|
|
/// A row that genuinely changes while the viewport is scrolled back must
|
|
/// still reach the renderer. This is the same dedup hazard from the other
|
|
/// side: content changing under a stale hash rather than a stale hash under
|
|
/// unchanged content.
|
|
#[test]
|
|
fn a_row_that_changes_while_scrolled_back_still_ships() {
|
|
let (shared, _actions, mut renderer) = scrolled_terminal(10);
|
|
assert!(shared.scroll(2));
|
|
let mut scroll_encoder = Encoder::new();
|
|
let _ = shared.snapshot(&mut scroll_encoder);
|
|
let _ = shared.render(&mut renderer);
|
|
|
|
// Rewrite the top line of the active area, which is screen row 2 while
|
|
// scrolled back two.
|
|
shared.feed_fully(b"\x1b[1;1HCHANGED\x1b[K");
|
|
|
|
let frame = shared.render(&mut renderer);
|
|
let changed = rows_by_line(&frame)
|
|
.into_iter()
|
|
.find(|(_, text)| text == "CHANGED");
|
|
assert_eq!(
|
|
changed,
|
|
Some((2, "CHANGED".to_string())),
|
|
"the rewritten active row must ship, at its scrolled screen position; got {:?}",
|
|
rows_by_line(&frame)
|
|
);
|
|
}
|
|
|
|
/// The cursor plane travels with the viewport, because the renderer paints it
|
|
/// at a screen row and the grid stores it at an active-area row.
|
|
#[test]
|
|
fn the_cursor_moves_down_the_screen_as_the_viewport_scrolls_back() {
|
|
let (shared, _actions, _) = scrolled_terminal(10);
|
|
let mut encoder = Encoder::new();
|
|
let live = shared.snapshot(&mut encoder);
|
|
assert_eq!(live.cursor.line, 3, "cursor sits on the last active row");
|
|
assert!(live.cursor.visible);
|
|
|
|
assert!(shared.scroll(2));
|
|
let mut scrolled_encoder = Encoder::new();
|
|
let scrolled = shared.snapshot(&mut scrolled_encoder);
|
|
assert_eq!(
|
|
scrolled.cursor.line, 3,
|
|
"row 3 + 2 is off a four-row screen, so it clamps to the last row"
|
|
);
|
|
assert!(
|
|
!scrolled.cursor.visible,
|
|
"scrolled off the bottom, so it must not be painted on an unrelated line"
|
|
);
|
|
}
|
|
|
|
/// The clamp above is not the whole story: a cursor that is merely pushed
|
|
/// *down* -- still on screen -- must report its new row, not its old one. A
|
|
/// capture that ignored the offset entirely would pass the clamp test above
|
|
/// (row 3 is where the cursor already was) and fail this one.
|
|
///
|
|
/// Parking the cursor on the top row with `ESC[H` is what leaves it room to
|
|
/// move: at the live edge it is on row 0, and scrolling back two puts it on
|
|
/// row 2 of a four-row screen, still visible.
|
|
#[test]
|
|
fn a_cursor_still_on_screen_reports_its_scrolled_row() {
|
|
let (shared, _actions, _) = scrolled_terminal(10);
|
|
shared.feed_fully(b"\x1b[H");
|
|
|
|
let mut live_encoder = Encoder::new();
|
|
let live = shared.snapshot(&mut live_encoder);
|
|
assert_eq!(live.cursor.line, 0, "parked on the top row");
|
|
assert!(live.cursor.visible);
|
|
|
|
assert!(shared.scroll(2));
|
|
let mut encoder = Encoder::new();
|
|
let frame = shared.snapshot(&mut encoder);
|
|
assert_eq!(
|
|
frame.cursor.line, 2,
|
|
"the caret follows the row it is written on down the screen"
|
|
);
|
|
assert!(
|
|
frame.cursor.visible,
|
|
"still inside the viewport, so still painted"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_cursor_becomes_visible_again_on_the_way_back() {
|
|
let (shared, _actions, _) = scrolled_terminal(10);
|
|
assert!(shared.scroll(3));
|
|
assert!(shared.scroll_to_bottom());
|
|
|
|
let mut encoder = Encoder::new();
|
|
let frame = shared.snapshot(&mut encoder);
|
|
assert_eq!(frame.cursor.line, 3);
|
|
assert!(frame.cursor.visible);
|
|
}
|
|
|
|
/// The alternate screen has no scrollback by construction: `Term::new` builds
|
|
/// the inactive grid with a zero scroll limit. So scrolling inside `vim` or
|
|
/// `less` must be a clamped no-op, leaving the application's own scrolling to
|
|
/// the application. Asserted rather than assumed -- a viewport that drifted
|
|
/// here would show the primary screen's history behind a full-screen app.
|
|
#[test]
|
|
fn the_alternate_screen_has_no_scrollback_to_reach() {
|
|
let (shared, _actions, _) = scrolled_terminal(10);
|
|
|
|
shared.feed_fully(b"\x1b[?1049h");
|
|
shared.feed_fully(b"ALT");
|
|
|
|
assert!(!shared.scroll(1), "no history exists on the alt screen");
|
|
assert!(!shared.scroll(1_000));
|
|
assert_eq!(shared.lock().display_offset(), 0);
|
|
|
|
// And the primary screen's position is undisturbed on the way back.
|
|
shared.feed_fully(b"\x1b[?1049l");
|
|
assert!(shared.scroll(2));
|
|
assert_eq!(shared.lock().display_offset(), 2);
|
|
}
|
|
|
|
/// A terminal configured with no history cannot scroll at all. The guard is
|
|
/// upstream's clamp against `history_size()`, and this pins it: without it the
|
|
/// offset would advance and capture would index above the grid.
|
|
#[test]
|
|
fn a_terminal_without_scrollback_never_moves() {
|
|
let (shared, _actions) = terminal(20, 4, 0);
|
|
shared.feed_fully(b"a\r\nb\r\nc\r\nd\r\ne\r\nf");
|
|
|
|
assert!(!shared.scroll(1));
|
|
assert!(!shared.scroll(1_000));
|
|
assert_eq!(shared.lock().display_offset(), 0);
|
|
|
|
let mut encoder = Encoder::new();
|
|
assert_eq!(
|
|
screen(&shared.snapshot(&mut encoder)),
|
|
vec!["c", "d", "e", "f"]
|
|
);
|
|
}
|