mirror of
https://github.com/block/buzz.git
synced 2026-11-15 22:02:04 +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>
180 lines
7.4 KiB
Rust
180 lines
7.4 KiB
Rust
//! The one place the DOM's wheel sign meets the engine's scroll sign.
|
|
//!
|
|
//! Its own module because the crossing is the whole subject: the conversion is
|
|
//! two characters of code and roughly a hundred lines of tests and reasoning
|
|
//! about which way is back, and inlining that into the command file buries it
|
|
//! among unrelated session plumbing.
|
|
|
|
use buzz_terminal::SharedTerminal;
|
|
use serde::Deserialize;
|
|
|
|
/// A wheel delta in whole cells, carrying the **DOM's** sign.
|
|
///
|
|
/// A newtype rather than a bare `i32` so the engine's opposite convention
|
|
/// cannot be reached by accident: `SharedTerminal::scroll` takes an `i32`, so
|
|
/// handing it this value straight from the wire is a type error rather than a
|
|
/// silently reversed terminal. Deleting the conversion is caught by the tests
|
|
/// below; *bypassing* it is caught by the compiler.
|
|
///
|
|
/// Serde already reads a one-field tuple struct as its inner value, so no
|
|
/// `transparent` attribute is needed -- verified by the wire test below, which
|
|
/// deserializes from a bare number.
|
|
#[derive(Debug, Clone, Copy, Deserialize)]
|
|
pub(crate) struct DomLines(i32);
|
|
|
|
/// Scroll `terminal` by a DOM wheel delta in cells.
|
|
///
|
|
/// This is the only place the two sign conventions meet, so it is a named
|
|
/// function rather than a `-` in an argument list: inlined, the conversion is
|
|
/// executed by every scroll test and asserted by none of them, and deleting it
|
|
/// leaves both the Rust and the frontend suites green while reversing the
|
|
/// terminal on a real trackpad. It takes the terminal rather than returning a
|
|
/// number so the tests below drive the same call `terminal_scroll` makes,
|
|
/// instead of a conversion that agrees with itself.
|
|
///
|
|
/// The DOM sign is the spec because the OS-reported delta is the only stable
|
|
/// thing here. macOS "natural scrolling" flips what a given finger motion
|
|
/// reports, so a rule stated as "swipe up goes back" is correct for one
|
|
/// preference setting and backwards for the other. Stated against `deltaY` it
|
|
/// is correct for both, and matches whatever the page around the terminal
|
|
/// does: **negative DOM lines -- the direction that scrolls a web page toward
|
|
/// the top of the document -- go back into history**, which is positive in the
|
|
/// engine's `Scroll::Delta` convention.
|
|
///
|
|
/// `saturating_neg` because this arrives over IPC: plain unary negation on
|
|
/// `i32::MIN` panics in debug and wraps to `i32::MIN` in release, so a crafted
|
|
/// command could scroll the wrong way. Saturating leaves it going back, which
|
|
/// the engine then clamps at the oldest line.
|
|
///
|
|
/// Returns whether the viewport actually moved.
|
|
pub(super) fn scroll_by_dom_lines(terminal: &SharedTerminal, dom_lines: DomLines) -> bool {
|
|
terminal.scroll(dom_lines.0.saturating_neg())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// A terminal with four lines of history and a two-row viewport, used by
|
|
/// the sign tests below.
|
|
fn scrollable() -> SharedTerminal {
|
|
let size = buzz_terminal::Size {
|
|
columns: 8,
|
|
screen_lines: 2,
|
|
scrollback: 16,
|
|
};
|
|
let (term, actions) =
|
|
buzz_terminal::Terminal::new(size, buzz_terminal::fences::Fences::ALL);
|
|
// Leaked deliberately: dropping the receiver disconnects the channel
|
|
// and every later listener send fails silently.
|
|
std::mem::forget(actions);
|
|
let shared = SharedTerminal::new(term);
|
|
shared.feed_fully(b"L1\r\nL2\r\nL3\r\nL4");
|
|
shared
|
|
}
|
|
|
|
fn screen(terminal: &SharedTerminal) -> Vec<String> {
|
|
let mut encoder = buzz_terminal::damage::Encoder::new();
|
|
terminal
|
|
.snapshot(&mut encoder)
|
|
.rows
|
|
.iter()
|
|
.map(|row| {
|
|
row.spans
|
|
.iter()
|
|
.map(|span| span.text.as_str())
|
|
.collect::<String>()
|
|
.trim_end()
|
|
.to_string()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// The newtype is on the wire, so it has to deserialize from the bare
|
|
/// number the frontend sends. The frontend test mocks `invoke` and cannot
|
|
/// see this; if the wrapper did not read transparently, every scroll would
|
|
/// fail to deserialize and the terminal would silently stop scrolling.
|
|
#[test]
|
|
fn a_wheel_delta_deserializes_from_the_bare_number_the_frontend_sends() {
|
|
#[derive(Deserialize)]
|
|
struct Args {
|
|
lines: DomLines,
|
|
}
|
|
let args: Args = serde_json::from_str(r#"{"lines":-2}"#).unwrap();
|
|
assert_eq!(args.lines.0, -2);
|
|
|
|
let terminal = scrollable();
|
|
assert!(scroll_by_dom_lines(&terminal, args.lines));
|
|
assert_eq!(
|
|
screen(&terminal),
|
|
vec!["L1", "L2"],
|
|
"the value off the wire must scroll back, not forwards"
|
|
);
|
|
}
|
|
|
|
/// **The DOM->engine crossing, asserted rather than executed in passing.**
|
|
///
|
|
/// The direction under test: *negative DOM lines go back into history.*
|
|
/// Negative is what a wheel reports for the gesture that scrolls a web
|
|
/// page toward the top of the document, and the engine's `Scroll::Delta`
|
|
/// takes positive for backwards, so the boundary negates.
|
|
///
|
|
/// This asserts on the text that lands on screen rather than on the
|
|
/// converted number. A test of the arithmetic alone passes just as well
|
|
/// against an engine that reads the sign the other way -- two one-sided
|
|
/// tests are not a test of the crossing.
|
|
#[test]
|
|
fn a_negative_dom_delta_scrolls_backwards_into_history() {
|
|
let terminal = scrollable();
|
|
assert_eq!(
|
|
screen(&terminal),
|
|
vec!["L3", "L4"],
|
|
"starts at the live edge"
|
|
);
|
|
|
|
assert!(scroll_by_dom_lines(&terminal, DomLines(-2)));
|
|
assert_eq!(
|
|
screen(&terminal),
|
|
vec!["L1", "L2"],
|
|
"a page-upwards gesture must reach older lines, not newer ones"
|
|
);
|
|
|
|
assert!(scroll_by_dom_lines(&terminal, DomLines(2)));
|
|
assert_eq!(
|
|
screen(&terminal),
|
|
vec!["L3", "L4"],
|
|
"and a page-downwards gesture returns to the live edge"
|
|
);
|
|
}
|
|
|
|
/// The momentum tail: once history runs out the engine clamps, and the
|
|
/// boundary must report that nothing moved so the command skips the
|
|
/// republish rather than shipping an identical frame per event.
|
|
#[test]
|
|
fn a_dom_delta_past_the_oldest_line_reports_no_movement() {
|
|
let terminal = scrollable();
|
|
assert!(scroll_by_dom_lines(&terminal, DomLines(-2)));
|
|
assert!(
|
|
!scroll_by_dom_lines(&terminal, DomLines(-1_000)),
|
|
"there is nothing older, so nothing moved"
|
|
);
|
|
assert_eq!(screen(&terminal), vec!["L1", "L2"]);
|
|
}
|
|
|
|
/// `lines` arrives over IPC, so it can be any `i32`. Plain unary negation
|
|
/// panics on `i32::MIN` in debug and wraps back to `i32::MIN` in release --
|
|
/// the release case being the dangerous one, since it silently reverses
|
|
/// the direction. Saturating keeps the direction the caller asked for and
|
|
/// lets the engine clamp.
|
|
#[test]
|
|
fn the_most_negative_dom_delta_saturates_backwards_instead_of_wrapping_forwards() {
|
|
let terminal = scrollable();
|
|
assert!(scroll_by_dom_lines(&terminal, DomLines(i32::MIN)));
|
|
assert_eq!(
|
|
screen(&terminal),
|
|
vec!["L1", "L2"],
|
|
"a hostile delta must clamp at the oldest line, not flip direction"
|
|
);
|
|
}
|
|
}
|