mirror of
https://github.com/block/buzz.git
synced 2026-11-13 21:01:53 +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>
383 lines
18 KiB
Rust
383 lines
18 KiB
Rust
//! Counting what the parser *does*, not how many bytes it read.
|
|
//!
|
|
//! Both fences in [`crate::fences`] meter bytes. That is the right denominator
|
|
//! for memory -- a buffer's size is bytes -- and the wrong one for time. `ESC[m`
|
|
//! and `ESC#8` are four bytes each; the first sets an attribute and the second
|
|
//! rewrites every cell of the grid. Metering the reader's lock hold in bytes
|
|
//! therefore prices those identically, and a stream of the second one holds the
|
|
//! lock for as long as it likes without ever tripping a byte budget.
|
|
//!
|
|
//! Measured: a DECALN flood at 200x50 reaches p95 65535us against a 4000us
|
|
//! budget with **zero** F1 aborts -- the fence never fires, because nothing is
|
|
//! buffered. Unfenced, one acquisition was observed at 22.1s, about 1300
|
|
//! dropped frames in a single lock hold.
|
|
//!
|
|
//! So this module adds a third quantity: the number of *completed parser
|
|
//! units* -- one per `Handler` callback the parser dispatches, which is one
|
|
//! per fully-parsed escape sequence or printed character. It is a proxy for
|
|
//! work rather than a measure of it, but it has the property the byte count
|
|
//! lacks: it advances once per thing the emulator actually did.
|
|
//!
|
|
//! ## Why a wrapper, and not vte's own stopping point
|
|
//!
|
|
//! `Parser::advance_until_terminated` already supports stopping mid-buffer,
|
|
//! but termination is driven by `Perform::terminated()`, and in the ansi layer
|
|
//! the implementor is `Performer`, which is private (`vte-0.15.0/src/ansi.rs`:
|
|
//! `struct Performer` at 425, `terminated` at 1825, set only for BSU handling).
|
|
//! An embedder cannot reach it, so the stopping point has to be built outside
|
|
//! the parser rather than inside it.
|
|
//!
|
|
//! ## What this deliberately does not do
|
|
//!
|
|
//! It does not skip or veto expensive callbacks once a budget is spent. That
|
|
//! would bound the lock hold perfectly and silently corrupt the screen, which
|
|
//! is a worse failure than the one being fixed: a slow terminal recovers, a
|
|
//! wrong one does not. Every unit is delegated; the count only decides where
|
|
//! the *caller* may cut the input.
|
|
|
|
use alacritty_terminal::event::EventListener;
|
|
use alacritty_terminal::term::Term;
|
|
use alacritty_terminal::vte::ansi::cursor_icon::CursorIcon;
|
|
use alacritty_terminal::vte::ansi::{
|
|
Attr, CharsetIndex, ClearMode, CursorShape, CursorStyle, Handler, Hyperlink, KeyboardModes,
|
|
KeyboardModesApplyBehavior, LineClearMode, Mode, ModifyOtherKeys, PrivateMode, Rgb,
|
|
ScpCharPath, ScpUpdateMode, StandardCharset, TabulationClearMode,
|
|
};
|
|
|
|
/// Read access to the cursor column of whatever the wrapper is driving.
|
|
///
|
|
/// Exists for exactly one callback. CBT's cost is bounded by *cursor
|
|
/// movement*, and the only way to charge it honestly -- or to stop it early
|
|
/// -- is to watch the cursor between steps. Everything else in this module is
|
|
/// priced from the grid alone, which is why this is a separate trait and a
|
|
/// separate bound rather than a field on [`Counting`].
|
|
///
|
|
/// Implemented over the public path in `alacritty_terminal-0.26.0`:
|
|
/// `Term::grid` (term/mod.rs:645) -> `Grid::cursor` (grid/mod.rs:113) ->
|
|
/// `Cursor::point` (grid/mod.rs:36). No private field, no fork.
|
|
pub trait CursorColumn {
|
|
fn cursor_column(&self) -> usize;
|
|
}
|
|
|
|
impl<L: EventListener> CursorColumn for Term<L> {
|
|
#[inline]
|
|
fn cursor_column(&self) -> usize {
|
|
self.grid().cursor.point.column.0
|
|
}
|
|
}
|
|
|
|
/// Wraps a [`Handler`], forwarding every callback and counting them.
|
|
///
|
|
/// Every one of the trait's 71 methods has an empty default body upstream, so
|
|
/// a method left undelegated here would compile cleanly and silently discard
|
|
/// that escape sequence. The delegations are therefore generated by a macro
|
|
/// over the full method list rather than written out: the failure mode of
|
|
/// hand-copying is invisible.
|
|
pub struct Counting<'a, H: Handler + CursorColumn> {
|
|
inner: &'a mut H,
|
|
/// Callbacks dispatched, one per unit regardless of cost. This is the
|
|
/// fixture-facing number: it says what the parser *did*, and it is kept
|
|
/// separate from `work` because collapsing them is precisely the mistake
|
|
/// that made the first version of this seam useless.
|
|
units: u64,
|
|
/// Cost-weighted work, in cell-equivalents. This is the scheduling number.
|
|
work: u64,
|
|
columns: u64,
|
|
lines: u64,
|
|
/// Configured scrollback depth, not current fill. See `reset_state`.
|
|
scrollback: u64,
|
|
}
|
|
|
|
impl<'a, H: Handler + CursorColumn> Counting<'a, H> {
|
|
/// `columns` and `lines` are the grid the handler is about to act on, and
|
|
/// they are the weights' only input: an O(cells) callback is charged
|
|
/// `columns * lines` because that is what it touches.
|
|
pub fn new(inner: &'a mut H, columns: usize, lines: usize, scrollback: usize) -> Self {
|
|
Self {
|
|
inner,
|
|
units: 0,
|
|
work: 0,
|
|
columns: columns as u64,
|
|
lines: lines as u64,
|
|
scrollback: scrollback as u64,
|
|
}
|
|
}
|
|
|
|
/// Callbacks dispatched since this wrapper was created.
|
|
pub fn units(&self) -> u64 {
|
|
self.units
|
|
}
|
|
|
|
/// Cost-weighted work dispatched, in cell-equivalents.
|
|
pub fn work(&self) -> u64 {
|
|
self.work
|
|
}
|
|
|
|
/// Cells in the grid. Saturating: `Size` is unclamped `usize`, so this
|
|
/// product is reachable, and a wrapped weight prices the most expensive
|
|
/// callbacks as the cheapest.
|
|
#[inline]
|
|
fn cells(&self) -> u64 {
|
|
self.columns.saturating_mul(self.lines)
|
|
}
|
|
|
|
/// A parameter charged at its clamped value.
|
|
///
|
|
/// Upstream clamps most counts to the grid before acting on them, so the
|
|
/// bound is the clamp, not the parameter: `ESC[65535X` on an 80-column
|
|
/// grid touches 80 cells. Charging the raw parameter would let a
|
|
/// four-byte escape spend the whole slice budget without doing the work,
|
|
/// which stalls the parser as surely as under-charging lets it run away.
|
|
#[inline]
|
|
fn clamp(&self, n: usize, bound: u64) -> u64 {
|
|
(n as u64).min(bound).max(1)
|
|
}
|
|
|
|
#[inline]
|
|
fn charge(&mut self, weight: u64) {
|
|
self.units = self.units.saturating_add(1);
|
|
self.work = self.work.saturating_add(weight);
|
|
}
|
|
}
|
|
|
|
/// Generate a delegating, counting implementation for every `Handler` method.
|
|
///
|
|
/// Two groups, because the methods differ in *cost*, not in kind. `plain`
|
|
/// methods are charged one unit. `weighted` methods are charged what they
|
|
/// touch, using the expressions in the table below -- these are the ones a
|
|
/// hostile stream can use to buy grid-sized work with a four-byte escape.
|
|
///
|
|
/// The count is incremented *before* delegating, so a callback that panics
|
|
/// still leaves evidence it was attempted.
|
|
macro_rules! counting_handler {
|
|
(
|
|
plain { $($pname:ident($($parg:ident: $pty:ty),* $(,)?);)* }
|
|
weighted { $($wname:ident($($warg:ident: $wty:ty),* $(,)?) => |$this:ident| $weight:expr;)* }
|
|
) => {
|
|
impl<H: Handler + CursorColumn> Handler for Counting<'_, H> {
|
|
$(
|
|
#[inline]
|
|
fn $pname(&mut self $(, $parg: $pty)*) {
|
|
self.charge(1);
|
|
self.inner.$pname($($parg),*);
|
|
}
|
|
)*
|
|
$(
|
|
#[inline]
|
|
fn $wname(&mut self $(, $warg: $wty)*) {
|
|
let weight = { let $this = &*self; $weight };
|
|
self.charge(weight);
|
|
self.inner.$wname($($warg),*);
|
|
}
|
|
)*
|
|
|
|
/// The one callback this wrapper does not delegate verbatim.
|
|
///
|
|
/// CBT (`ESC[NZ`) is upstream's only unbounded atom. With no
|
|
/// tabstop below the cursor, `move_backward_tabs`
|
|
/// (`term/mod.rs:1580`) assigns `col` *inside* the `if
|
|
/// self.tabs[i]` test, so the cursor never moves, the `col == 0`
|
|
/// break is unreachable, and all N iterations rescan the row.
|
|
/// `ESC[3g ESC[65535Z` is eight bytes and 82 ms at 1600 columns.
|
|
/// Its twin `move_forward_tabs` (1605) assigns *outside* the
|
|
/// test, always advances, and is fine: same file, same loop
|
|
/// skeleton, and the entire difference is one assignment's
|
|
/// placement relative to one branch.
|
|
///
|
|
/// The fix is a termination condition, not a smaller number.
|
|
/// Each step either moves the cursor strictly left or is a fixed
|
|
/// point, and **a fixed point is permanent** -- the scan depends
|
|
/// only on the cursor, which did not move. So the loop can stop
|
|
/// at the first one. The leftward distances telescope to at most
|
|
/// the starting column, plus one final failed scan, so the whole
|
|
/// callback is O(columns) and the delegated-call count is at most
|
|
/// `columns - 1`.
|
|
///
|
|
/// Equivalence is not argued, it is checked: every tabstop subset
|
|
/// of a 12-column grid x 4 start columns x 7 counts (114_688
|
|
/// cases) lands on the same column as the naive loop, on a real
|
|
/// `Term`. See `examples/probe_cbt_equiv.rs`. Deleting the
|
|
/// fixed-point break leaves the *landing column correct* and only
|
|
/// the cost wrong, so the fixture that guards this must assert
|
|
/// units, never the cursor.
|
|
#[inline]
|
|
fn move_backward_tabs(&mut self, count: u16) {
|
|
// One unit for the escape, as every other callback gets.
|
|
self.charge(1);
|
|
for _ in 0..count {
|
|
let before = self.inner.cursor_column();
|
|
// No `before == 0` guard: column 0 is already a fixed
|
|
// point (upstream's own `col == 0` break leaves the
|
|
// cursor alone), so the check below covers it and a
|
|
// second one would be unreachable-by-construction code
|
|
// that no test could distinguish.
|
|
self.inner.move_backward_tabs(1);
|
|
let after = self.inner.cursor_column();
|
|
// Charge the cells this step scanned. A step that finds a
|
|
// stop scans the distance it moved; a step that finds
|
|
// none scans the whole prefix and moves nothing --
|
|
// charging that one zero would leave a loop that spins
|
|
// without ever paying, which is precisely the mutant this
|
|
// pricing has to make visible.
|
|
let scanned = if after == before { before } else { before - after };
|
|
self.work = self.work.saturating_add(scanned as u64);
|
|
if after == before {
|
|
// A fixed point is permanent: the scan depends only
|
|
// on the cursor, and the cursor did not move.
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
counting_handler! {
|
|
plain {
|
|
set_title(a0: Option<String>);
|
|
set_cursor_style(a0: Option<CursorStyle>);
|
|
set_cursor_shape(shape: CursorShape);
|
|
input(c: char);
|
|
goto(line: i32, col: usize);
|
|
goto_line(line: i32);
|
|
goto_col(col: usize);
|
|
move_up(a0: usize);
|
|
move_down(a0: usize);
|
|
identify_terminal(intermediate: Option<char>);
|
|
device_status(a0: usize);
|
|
move_forward(col: usize);
|
|
move_backward(col: usize);
|
|
move_down_and_cr(row: usize);
|
|
move_up_and_cr(row: usize);
|
|
backspace();
|
|
carriage_return();
|
|
linefeed();
|
|
bell();
|
|
substitute();
|
|
newline();
|
|
set_horizontal_tabstop();
|
|
save_cursor_position();
|
|
restore_cursor_position();
|
|
clear_tabs(mode: TabulationClearMode);
|
|
set_tabs(interval: u16);
|
|
reverse_index();
|
|
terminal_attribute(attr: Attr);
|
|
set_mode(mode: Mode);
|
|
unset_mode(mode: Mode);
|
|
report_mode(mode: Mode);
|
|
set_private_mode(mode: PrivateMode);
|
|
unset_private_mode(mode: PrivateMode);
|
|
report_private_mode(mode: PrivateMode);
|
|
set_scrolling_region(top: usize, bottom: Option<usize>);
|
|
set_keypad_application_mode();
|
|
unset_keypad_application_mode();
|
|
set_active_charset(a0: CharsetIndex);
|
|
configure_charset(a0: CharsetIndex, a1: StandardCharset);
|
|
set_color(a0: usize, a1: Rgb);
|
|
dynamic_color_sequence(a0: String, a1: usize, a2: &str);
|
|
reset_color(a0: usize);
|
|
clipboard_store(a0: u8, a1: &[u8]);
|
|
clipboard_load(a0: u8, a1: &str);
|
|
push_title();
|
|
pop_title();
|
|
text_area_size_pixels();
|
|
text_area_size_chars();
|
|
set_hyperlink(a0: Option<Hyperlink>);
|
|
set_mouse_cursor_icon(a0: CursorIcon);
|
|
report_keyboard_mode();
|
|
push_keyboard_mode(mode: KeyboardModes);
|
|
pop_keyboard_modes(to_pop: u16);
|
|
set_keyboard_mode(mode: KeyboardModes, behavior: KeyboardModesApplyBehavior);
|
|
set_modify_other_keys(mode: ModifyOtherKeys);
|
|
report_modify_other_keys();
|
|
set_scp(char_path: ScpCharPath, update_mode: ScpUpdateMode);
|
|
}
|
|
weighted {
|
|
// Every weight below is an upper bound on the cells the callback can
|
|
// touch, **read from `alacritty_terminal-0.26.0/src/term/mod.rs`** and
|
|
// then checked against measurement -- never fitted to a curve. The
|
|
// direction of the error is the whole point: an over-charge slices
|
|
// early and costs throughput, an under-charge is an attack surface, so
|
|
// where source and measurement disagree the source bound wins and the
|
|
// slack is recorded here rather than tuned away.
|
|
//
|
|
// `min(N, ...)` appears wherever upstream clamps the parameter; a raw
|
|
// `N` would let `ESC[65535X` charge 65535 on an 80-column grid and
|
|
// stall the parser on a cheap escape.
|
|
|
|
// O(min(N, columns)): `end = min(start + count, columns)`, loop
|
|
// `row[start..end]` (1519). Knee measured exactly at N == columns.
|
|
erase_chars(count: usize) => |this| this.clamp(count, this.columns);
|
|
// O(columns) for *every* N, worst at N=1: the swap loop runs
|
|
// `columns - end` times where `end = min(start + N, columns - 1)`
|
|
// (1538), so cost *falls* as N rises. Charging by N would be backwards
|
|
// and would under-charge the worst case by the full terminal width --
|
|
// measured 3422ns at N=1/1600 columns against 863ns at N=65535.
|
|
delete_chars(a0: usize) => |this| this.columns;
|
|
// O(columns) for every N, worst at N=1. Same shape as `delete_chars`:
|
|
// `num_cells = columns - (column + count)` (1187).
|
|
insert_blank(a0: usize) => |this| this.columns;
|
|
// O(columns): scans to the next tabstop per count, and always advances
|
|
// (`col` is assigned unconditionally at 1592), so the whole loop is
|
|
// bounded by one traversal of the row. This is the sibling that CBT
|
|
// should have been, one asymmetric line apart in the same file.
|
|
put_tab(count: u16) => |this| this.columns;
|
|
move_forward_tabs(count: u16) => |this| this.columns;
|
|
// NOTE: `move_backward_tabs` is NOT in this table. It is the one
|
|
// callback whose argument is rewritten, so it is written out by hand
|
|
// below the macro's generated methods -- a weight can price an atom but
|
|
// cannot shrink one.
|
|
// O(min(N, lines) x columns) in steady state: the row rotation is O(1)
|
|
// on the ring buffer, but `positions` rows are `reset()`, and a row
|
|
// reset is O(columns).
|
|
//
|
|
// **Known overshoot, measured and frequency-bounded.** While scrollback
|
|
// is still growing, `Grid::increase_scroll_limit` -> `Storage::initialize`
|
|
// reallocates in blocks of `MAX_CACHE_SIZE` = 1000 rows and `rezero`s
|
|
// the ring (`grid/storage.rs`). That is not chargeable from here -- the
|
|
// weight function cannot see history depth -- and it is real: at 1600
|
|
// columns the spikes land at call 0, 1000, 2000, 3000 of a 4000-call
|
|
// scroll, ~4 ms each, against a 125 ns median. It is bounded in
|
|
// frequency (once per 1000 new history rows, and never once history
|
|
// saturates: with scrollback=100 only call 0 spikes) and it is upstream
|
|
// allocation rather than anything a stream can amplify, so it is
|
|
// recorded here instead of being priced into every scroll -- charging
|
|
// 1000x on 999 calls out of 1000 to cover the thousandth would make
|
|
// ordinary scrolling the slow path.
|
|
scroll_up(n: usize) => |this| this.clamp(n, this.lines).saturating_mul(this.columns);
|
|
delete_lines(n: usize) => |this| this.clamp(n, this.lines).saturating_mul(this.columns);
|
|
scroll_down(n: usize) => |this| this.clamp(n, this.lines).saturating_mul(this.columns);
|
|
insert_blank_lines(n: usize) => |this| this.clamp(n, this.lines).saturating_mul(this.columns);
|
|
// O(columns): one row, `damage_line(line, 0, columns - 1)`.
|
|
clear_line(mode: LineClearMode) => |this| this.columns;
|
|
// O(cells): 18.7us at 200x50, doubling on both axes.
|
|
clear_screen(mode: ClearMode) => |this| this.cells();
|
|
// O(cells): rewrites every cell.
|
|
decaln() => |this| this.cells();
|
|
// Both grids, plus the scrollback the primary owns.
|
|
//
|
|
// `reset_state` (1835) resets the primary *and* the alternate, and each
|
|
// `Grid::reset` runs `clear_history` -> `shrink_lines` -> `truncate` +
|
|
// `rezero`, which walks the raw buffer. So the cost carries a history
|
|
// axis that `cells` alone cannot see: measured 0.5 us empty against
|
|
// 1.68 ms with 10k rows filled at 400x100, a 42x per-cell miss, with
|
|
// the knee exactly at `screen_lines + MAX_CACHE_SIZE` where
|
|
// `shrink_lines` starts calling `truncate`.
|
|
//
|
|
// Priced on **configured** depth rather than current fill, which is the
|
|
// conservative choice and the only correct one: `history_size()` reads
|
|
// the *active* grid, so a filled primary followed by `ESC[?1049h`
|
|
// reports an empty history while RIS still pays for the inactive
|
|
// primary's rows -- underpriced 41x on exactly the arm an attacker
|
|
// would pick. The inactive grid is private, so there is no stateless
|
|
// way to observe the real fill; the configured depth bounds both.
|
|
//
|
|
// This is the one weight that can exceed [`crate::fences::WORK_BUDGET`]
|
|
// on its own -- 16x at the default 10k scrollback -- which is correct:
|
|
// it is a genuinely oversized uninterruptible atom, and a budget that
|
|
// hid that would be lying about what one drain can cost.
|
|
reset_state() => |this| this.cells().saturating_mul(2)
|
|
.saturating_add(this.scrollback.saturating_mul(this.columns));
|
|
}
|
|
}
|