mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00: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>
197 lines
7.3 KiB
Rust
197 lines
7.3 KiB
Rust
//! The resize seam: what a consumer is allowed to rely on across a reflow.
|
|
//!
|
|
//! The dedup encoder caches a hash per line. A resize reflows content into
|
|
//! rows of a different width, so those cached hashes describe a grid that no
|
|
//! longer exists -- if a resize did not force a full frame, dedup could
|
|
//! suppress a row whose content genuinely changed and leave the renderer
|
|
//! showing reflowed-away text.
|
|
//!
|
|
//! It does force one: upstream's `TermDamageState::resize` sets `full`
|
|
//! (`alacritty_terminal-0.26.0` term/mod.rs:240). These fixtures hold that
|
|
//! behaviour to the seam, because it is upstream's invariant and not ours.
|
|
|
|
use buzz_terminal::damage::Encoder;
|
|
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. These fixtures
|
|
/// don't assert on actions, but a fixture that quietly disables a code path is
|
|
/// how a future assertion gets written against a dead one.
|
|
fn shared(size: Size) -> (SharedTerminal, Receiver<Action>) {
|
|
let (term, actions) = Terminal::new(size, Fences::ALL);
|
|
(SharedTerminal::new(term), actions)
|
|
}
|
|
|
|
fn size(columns: usize) -> Size {
|
|
Size {
|
|
columns,
|
|
screen_lines: 10,
|
|
scrollback: 1000,
|
|
}
|
|
}
|
|
|
|
fn grid(columns: usize, screen_lines: usize) -> Size {
|
|
Size {
|
|
columns,
|
|
screen_lines,
|
|
scrollback: 1000,
|
|
}
|
|
}
|
|
|
|
/// A resize invalidates dedup and republishes the whole grid at the new width.
|
|
#[test]
|
|
fn resize_forces_a_full_frame_at_the_new_width() {
|
|
let (shared, _actions) = shared(size(40));
|
|
let mut encoder = Encoder::new();
|
|
shared.feed_fully(b"\x1b[2J\x1b[Hhello world\r\nsecond line\r\n");
|
|
|
|
let first = shared.render(&mut encoder);
|
|
assert!(first.full, "first frame after a fresh Term must be full");
|
|
assert_eq!(first.viewport.columns, 40);
|
|
assert_eq!(first.viewport.generation, 0);
|
|
|
|
// Nothing changed: dedup suppresses everything. Without this the next
|
|
// assertion could pass simply because every frame is full.
|
|
let idle = shared.render(&mut encoder);
|
|
assert!(!idle.full, "an unchanged grid must not republish");
|
|
assert!(
|
|
idle.rows.is_empty(),
|
|
"dedup let {} unchanged rows through",
|
|
idle.rows.len()
|
|
);
|
|
|
|
let applied = shared.resize(size(20));
|
|
assert_eq!(
|
|
applied.columns, 20,
|
|
"resize did not report the grid it applied"
|
|
);
|
|
assert_eq!(
|
|
applied.generation, 1,
|
|
"generation must advance across a resize"
|
|
);
|
|
|
|
let after = shared.render(&mut encoder);
|
|
assert!(
|
|
after.full,
|
|
"a resize must invalidate the renderer's cached rows"
|
|
);
|
|
assert_eq!(
|
|
after.viewport, applied,
|
|
"frame's viewport disagrees with the one resize reported applying"
|
|
);
|
|
assert_eq!(after.rows.len(), 10, "full frame must carry every line");
|
|
let row0: String = after.rows[0]
|
|
.spans
|
|
.iter()
|
|
.map(|s| s.text.as_str())
|
|
.collect();
|
|
assert_eq!(row0.chars().count(), 20, "row emitted at the old width");
|
|
assert!(
|
|
row0.starts_with("hello world"),
|
|
"content lost across reflow: {row0:?}"
|
|
);
|
|
}
|
|
|
|
/// A no-op resize is not a resize: it must not burn a generation, or every
|
|
/// `ResizeObserver` tick would look like a discontinuity to the consumer.
|
|
#[test]
|
|
fn identical_resize_is_inert() {
|
|
let (shared, _actions) = shared(size(40));
|
|
let mut encoder = Encoder::new();
|
|
shared.feed_fully(b"hello");
|
|
shared.render(&mut encoder);
|
|
|
|
let applied = shared.resize(size(40));
|
|
assert_eq!(
|
|
applied.generation, 0,
|
|
"a same-size resize advanced the generation"
|
|
);
|
|
|
|
let after = shared.render(&mut encoder);
|
|
assert_eq!(after.viewport, applied);
|
|
assert!(
|
|
!after.full,
|
|
"a same-size resize forced a needless full repaint"
|
|
);
|
|
}
|
|
|
|
/// A **full frame must carry every row**, including rows whose content is
|
|
/// byte-identical to what sat at that index before the resize.
|
|
///
|
|
/// This is the arm that catches a dedup cache surviving a full frame, and the
|
|
/// width-changing fixture above does *not* catch it: changing the width changes
|
|
/// every row's cell contents, so the hashes differ and the rows are emitted for
|
|
/// the wrong reason. A **height-only** resize keeps the width, so reflowed rows
|
|
/// hash exactly as before -- and a stale cache suppresses them right after the
|
|
/// consumer was told to discard what it had. The result is a renderer holding
|
|
/// nothing where content should be.
|
|
///
|
|
/// Verified concretely: growing 10 -> 20 lines moves "hello world" from row 0
|
|
/// to row 1, so correctness here is not merely about frame bookkeeping.
|
|
#[test]
|
|
fn full_frame_after_height_resize_republishes_unchanged_rows() {
|
|
let (shared, _actions) = shared(grid(40, 10));
|
|
let mut encoder = Encoder::new();
|
|
shared.feed_fully(b"\x1b[2J\x1b[Hhello world\r\nsecond line");
|
|
let first = shared.render(&mut encoder);
|
|
assert!(first.full);
|
|
assert_eq!(first.rows.len(), 10);
|
|
|
|
shared.resize(grid(40, 20));
|
|
let after = shared.render(&mut encoder);
|
|
assert!(
|
|
after.full,
|
|
"a resize must invalidate the renderer's cached rows"
|
|
);
|
|
assert_eq!(after.viewport.screen_lines, 20);
|
|
assert_eq!(
|
|
after.rows.len(),
|
|
20,
|
|
"full frame carried {} of 20 rows -- dedup suppressed rows the consumer \
|
|
was simultaneously told to discard, leaving them blank",
|
|
after.rows.len()
|
|
);
|
|
}
|
|
|
|
/// A frame is stamped with the grid it was **captured on**, and a later resize
|
|
/// does not retroactively re-label it.
|
|
///
|
|
/// This is the cross-transport race in the integration lane: frame delivery and
|
|
/// the resize call are separate paths, so a generation-N frame can arrive after
|
|
/// generation N+1 has been applied. Rejecting it requires the stamp to be
|
|
/// capture-time truth.
|
|
///
|
|
/// Note what is and is not proven here. That an owned `Frame` cannot mutate is
|
|
/// guaranteed by the language, so asserting it against a copy of itself would
|
|
/// be tautological. What this asserts is that `capture()` stamps the viewport
|
|
/// as it was **at capture**, against explicit expected values -- a `capture()`
|
|
/// that read the viewport a moment later, or a `Frame` that carried a handle
|
|
/// back to the terminal, would fail here.
|
|
#[test]
|
|
fn a_frame_is_stamped_with_the_grid_it_was_captured_on() {
|
|
let (shared, _actions) = shared(grid(40, 10));
|
|
let mut encoder = Encoder::new();
|
|
shared.feed_fully(b"\x1b[2J\x1b[Hhello world");
|
|
|
|
let in_flight = shared.render(&mut encoder);
|
|
assert_eq!(in_flight.viewport.generation, 0);
|
|
assert_eq!(in_flight.viewport.columns, 40);
|
|
|
|
let applied = shared.resize(grid(20, 10));
|
|
assert_eq!(applied.generation, 1);
|
|
assert_eq!(applied.columns, 20);
|
|
|
|
// The held frame still describes the pre-resize grid, so a consumer can
|
|
// compare the two and discard it rather than paint 40-column rows onto a
|
|
// 20-column grid.
|
|
assert_eq!(
|
|
in_flight.viewport.columns, 40,
|
|
"a frame captured before the resize describes the post-resize grid; \
|
|
a stale frame arriving late would be indistinguishable from a fresh one"
|
|
);
|
|
assert_eq!(in_flight.viewport.generation, 0);
|
|
assert_ne!(in_flight.viewport, applied);
|
|
}
|