zgui

Scrolling

Scroll containers, the offset model, scrollbars, wheel and touch input, programmatic scrolling, and virtualising a long list by hand.

A scroll container shows part of content that is larger than the space given to it, and lets the user move through the rest. This page covers what makes one, where the offset lives, how input reaches it, how to move it from code, and how to build a list of a hundred thousand rows that costs the same as a list of thirty. It assumes the Learn guide, in particular Layout and Events.

What makes a scroll container

use zgui::prelude::*;
use zgui::{component, css, view};

#[component]
fn Notes() -> impl IntoView {
    view! {
        scroll(class = "notes") {
            column(class = "notes__body") {
                text {"…a great deal of text…"}
            }
        }
    }
}

const SHEET: &str = css!(
    ".notes      { height: 240px; }
     .notes__body { padding: 12px; }"
);

The framework's own style sheet declares one rule for the scroll element:

scroll { display: block; overflow: auto; }

That rule is the whole of what makes scroll special. A box is a scroll container when its used overflow-x or its used overflow-y is auto or scroll, whatever element it came from. A column with overflow-y: auto is a scroll container; a scroll element with overflow: visible is not.

overflowClips its contentScroll containerGutter for the bar
visiblenononone
clipyesnonone
hiddenyesnonone
autoyesyesreserved only once the content is found to overflow
scrollyesyesalways reserved

auto is the one value whose effect on layout depends on the result of that layout: it reserves the bar's width exactly when the content overflows, and whether the content overflows depends on how much room the reservation left. It therefore enters layout as hidden, and a box that turns out to overflow is laid out a second time with the gutter reserved. Two passes, and no more — the decision is kept on the box, so a container that was scrolling last frame does not flicker its gutter on every keystroke inside it.

Layout keeps a roster of boxes with undecided overflow: auto. The gutter check examines those boxes only. A document with no such box does not scan its box tree and does not enter a second pass.

Three quantities describe a scroll container, and none of them means anything without the other two:

TermWhat it is
scrollportthe part of the container the content is seen through — its content box
content extenthow far the content reaches inside it
offsethow far the content has been moved, measured from its start

The legal range of an offset in one axis runs from zero to content extent − scrollport, and is zero when the content fits.

The offset model

Layout owns scroll regions: which boxes scroll, how far the content reaches, where the scrollport is. A separate layer owns scroll offsets. The split is the reason scrolling is cheap.

Writing an offset marks the container as scrolled and marks nothing else — not its children, not its ancestors. The pass that turns boxes into absolute geometry composes the new positions on its way past. No selector matches again, nothing is measured again, no box is rebuilt. Scrolling a five-thousand-row list is one mark, not five thousand.

An offset is filed under the element, never under the box it generated. A box is destroyed and made again whenever the element's display type changes, whenever a subtree is rebuilt, whenever a :hover rule reveals a control. An offset filed under a box would survive until the next such event and then send the reader silently back to the top.

Two offsets, not one

OffsetWhat it isWho reads it
clampedthe legal position, snapped to the device pixel gridlisteners, observations, the scrollbar thumb, ScrollPosition
composedclamped plus the elastic displacement of an overscroll, unsnappedthe pass that places fragments

The two round differently on purpose. The clamped offset is snapped because a scrollport whose content begins a third of a pixel further along breaks and rounds text differently, and re-encoding a slice of the document on every frame of every wheel detent is not worth a third of a pixel. The elastic displacement is not snapped, because it is a rigid translation that can be composed exactly at any fraction, and a return that covers its band in about a third of a second spends most of its frames moving well under one device pixel — rounded, the edge would ratchet.

Units

Positive scroll offset
Reveals content further right and down

The content itself travels in the opposite direction: up and left across the screen.

What a positive scroll offset means

Two coordinate spaces are in play, and the boundary between them is a real hazard.

WhereUnit
ScrollPosition, NodeRef::scroll_offset, ScrollTarget::Offset, ScrollTarget::Bydevice pixels
ScrollEvent — the on:scroll payloadCSS pixels
ScrollDelta::Pixels — part of the on:wheel payloadCSS pixels

A device pixel is a pixel of the surface. A CSS pixel is what a style sheet is written in. On a surface with two device pixels per CSS pixel the same position is two different numbers. NodeRef::scale() returns how many device pixels one CSS pixel is, and is the only conversion you need: divide a device-pixel quantity by it to get CSS pixels.

The unit is on the type, so the compiler catches most of it. CssPx, DevicePx, Point and Size live in zgui::geom and are not in the prelude; the examples below import them where they are used.

Scrollbars

The framework draws them. They are not elements and they are not in the document; they are extra fragments the layout pass produces for the container, painted in the container's own slot.

PartWhen it is drawnSize
trackwhenever a gutter is reserved, whatever the content doesfills the gutter
thumbonly when the content really can moveas long as the fraction of the content that is visible

The gutter is 15 CSS pixels wide. It is a constant — zgui_style::sheets::ua::SCROLLBAR_SIZE — because there is no CSS property that states it. Every rectangle a bar occupies is derived from the difference between the container's inner rectangle and its content box, which is the gutter; deriving it from the width a second time leaves a seam at a fractional scale.

A bar is drawn outside the scrollport and is not clipped by it. A bar clipped by the region it scrolls would disappear the moment the content did.

How they are styled

They are not styled with CSS. There is no selector for a track or a thumb, scrollbar-color and scrollbar-width are not supported properties, and scrollbar-gutter is not read. The bars follow the colour scheme the window is presented in, and nothing else:

PartLight schemeDark scheme
trackblack at 6 % alpha, square cornerswhite at 6 % alpha, square corners
thumbblack at 32 % alpha, 4 device pixel radiuswhite at 28 % alpha, 4 device pixel radius

The thumb is drawn over the track, which is why a translucent thumb is legible and a translucent track is a hairline.

The bars are chrome rather than content, so nothing in the document cascades to them. That is deliberate: a thousand identical scrollports would otherwise each carry a computed style for a strip of pixels no rule in the application ever mentions.

To reserve the gutter permanently, so that the content does not shift sideways on the frame a bar appears, use overflow: scroll instead of auto, or add padding-right: 15px to the container.

What a press on a bar does

The router asks which scrollbar a press landed on before it asks anything else, and a press that landed on one is taken away from everything under it: it neither moves focus nor clicks the container.

Press lands onWhat happens
the thumbthe distance from the thumb's near edge to the pointer is recorded, and nothing moves; each following move puts that edge at pointer − grab
the trackthe container pages one screenful towards the press

A thumb drag is expressed as an absolute destination rather than a relative step, so rounding cannot walk the thumb away from the pointer over a long drag.

Wheel, trackpad and touch

on:wheel is the input. It bubbles, and it can be cancelled.

pub struct WheelEvent {
    pub delta: ScrollDelta,   // Lines { x, y }, or Pixels(Size<CssPx, Css>)
    pub phase: ScrollPhase,   // Discrete | Started | Moved | Momentum | Ended
    pub position: Point<CssPx, Css>,
    pub id: PointerId,
    pub kind: PointerKind,
}

A notched wheel reports whole lines and always reports ScrollPhase::Discrete. A trackpad or another precision surface reports pixels and reports Started, Moved, Momentum and Ended around the gesture. The unit stays on the value because converting it needs information the value does not have.

How one wheel notch is read

Three owners answer the question, and no constant is invented anywhere:

QuestionAnswered by
how many lines does one detent meanthe desktop, through ScrollSettings::lines_per_notch — 3.0 on an ordinary Linux or Windows desktop
has the person's direction preference been applied alreadythe desktop, through ScrollSettings::direction
how tall is one line, and how far is one pagethe scrolled container's own computed style

The line height comes from the container's resolved text style. A page is scrollport − line, and never less than one line, so a scrollport shorter than a line still pages by something.

ScrollDelta::to_pixels(line_height) does the conversion if you need it yourself:

use zgui::geom::CssPx;

let delta = ScrollDelta::Lines { x: 0.0, y: -3.0 };
assert_eq!(delta.to_pixels(CssPx(16.0)).height, CssPx(-48.0));

Chaining

The wheel does not act on one container. It acts on the chain of scroll containers from the element under the pointer outwards to the root. Each one takes as much of the delta as it has room for and passes the remainder outwards, innermost first.

100 px requested

The inner list has 40 px of room left.

60 px remains
0 px remains

The entire delta has been consumed.

How nested scrolling consumes a delta

The two axes are answered independently, which is why a sideways flick over a vertical list scrolls the page sideways while the list stays exactly where it is. Whatever survives the outermost container becomes elastic displacement on that container.

The chain is walked up the element tree, so a wrapper with display: contents does not hide the container above it.

Taking the wheel over

Scrolling is the framework's default behaviour for a wheel event, computed after every listener on the path has run and dropped if any of them called prevent_default. The :prevent modifier does that for you.

use zgui::geom::CssPx;

let zoom = RwSignal::new_local(1.0_f32);

view! {
    box(
        class = "map",
        on:wheel:prevent = move |ev| {
            // Nothing scrolls. The zoom is ours.
            let by = ev.delta.to_pixels(CssPx(16.0)).height.0;
            zoom.update(|level| *level = (*level - by * 0.001).clamp(0.25, 8.0));
        }
    ) {
        canvas(class = "map__surface")
    }
}

Touch

A finger dragging a container is a scroll and nothing else.

GestureWhat it does
pan startsthe chain is decided once, where the finger went down, and held for the whole drag
pan movesthe container scrolls against the movement, so the content follows the contact
pan endsthe container is given the speed the finger left with, and flings

The chain is fixed at the start on purpose. A chain re-derived from where the finger is now is a list that stops following it the moment the drag leaves the scrollport. One drag runs at a time; a second contact that begins panning takes the scroll over.

There is no keyboard scrolling

The framework has exactly two defaults for a key: Tab moves focus, and Enter or Space activates what has focus. The arrows, Page Up, Page Down, Home and End do nothing to a scroll container. Write them where you want them:

use zgui::geom::{DevicePx, Point};

let port = NodeRef::new();

view! {
    scroll(
        class = "list",
        node_ref = port,
        tabindex = Focus::Sequential,
        on:key_down = move |ev| {
            let page = port.scroll_position().scrollport.height.0;
            let line = 40.0 * port.scale();
            let step = match &ev.key {
                Key::Named(NamedKey::ArrowDown) => line,
                Key::Named(NamedKey::ArrowUp) => -line,
                Key::Named(NamedKey::PageDown) => page,
                Key::Named(NamedKey::PageUp) => -page,
                _ => return,
            };
            port.scroll_to(
                ScrollTarget::By(Point::new(DevicePx(0.0), DevicePx(step))),
                ScrollBehavior::Smooth,
            );
        }
    ) { /* rows */ }
}

scrollport.height is already in device pixels, which is the space ScrollTarget::By takes, so paging needs no conversion. A line of 40 is a number somebody wrote in CSS pixels, so it is multiplied by port.scale().

The glide, and overscroll

A wheel detent does not jump. It travels.

InputWhat it does
a notched wheel — ScrollPhase::Discreteglides to its destination over 240 ms, on a cubic ease-out
a trackpad or touch — any other phasearrives in the frame it was delivered in

The split is the whole of it. A precision surface's deltas are already a motion, and animating a motion again is what makes a trackpad feel like treacle. A backend that reports that the platform animates its own wheel turns the glide off entirely.

Detents compose; they do not restart. Somebody turning a wheel three times in half a second is asking to go three times as far. A new detent is added to the destination the running motion is already heading for, and only the ease is restarted. Re-aiming from the container's current position would throw away everything the previous detents had not yet covered, and three quick turns would land barely further than one.

The ease has no ease-in half. A scroll that visibly does nothing for two frames after a key press reads as a dropped key press.

Overscroll

What no container in the chain could absorb becomes an elastic displacement on the outermost one.

ConstantValueMeaning
band120 device pixelsthe displacement approaches this and never reaches it
resistanceband × x / (band + x) over the unresisted distanceevery further pixel of gesture moves less than the last
return rate20 rad/s, critically dampedsettled in about a third of a second

The displacement is not a scroll offset. scroll_offset, the thumb and the on:scroll payload all report the clamped position while the edge is stretched. A displacement that has not returned keeps the frame loop awake, so the return is drawn rather than skipped.

A run of detents against the bottom of a list keeps the speed the previous one left, so it reads as one continuous stretch rather than one jerk per detent.

Programmatic scrolling

Three types, all in the prelude.

Prop

Type

Prop

Type

pub struct ScrollPosition {
    pub offset: Point<DevicePx, Device>,
    pub content_size: Size<DevicePx, Device>,
    pub scrollport: Size<DevicePx, Device>,
}

impl ScrollPosition {
    pub fn remaining(self) -> Size<DevicePx, Device>;   // never below zero
    pub fn is_at_end_vertically(self) -> bool;
    pub fn is_at_end_horizontally(self) -> bool;
}

A NodeRef is how a view reaches a node it built.

MethodAnswers
scroll_offset()the offset, from the last completed frame
scroll_position()offset, content extent and scrollport, from the last completed frame
scroll_to(target, behavior)asks for a scroll; returns nothing
observe_scroll()a Signal<ScrollPosition, LocalStorage> that updates every frame the container moves
observe_scroll_while(active)the same, but the observation is given back while active answers false

The two targeting families act on different nodes, and this is the part to get right:

TargetWhich container moves
Offset, Bythis node, which must itself be a scroll container
IntoView, IntoViewStart, IntoViewEnd, IntoViewCenterthe nearest scrolling ancestor that is not this node

IntoView on a target taller than the scrollport aligns it to its start, which is the part of it that anyone asking to see it means.

Scrolling an element into view

use zgui::prelude::*;
use zgui::{component, css, view};

/// The handle is part of the row, exactly as a per-row signal is: `NodeRef` is `Copy`, and a
/// row that goes away takes its handle with it. A `NodeRef` is not `Send`, so a collection of
/// these needs `LocalStorage`.
#[derive(Clone)]
struct Section {
    id: u64,
    title: String,
    node: NodeRef,
}

#[component]
fn Manual(sections: Signal<Vec<Section>, LocalStorage>) -> impl IntoView {
    view! {
        row(class = "manual") {
            column(class = "manual__index") {
                for section in move || sections.get(), key = |s: &Section| s.id {
                    control(
                        class = "manual__link",
                        // Only `section.node` is captured, and it is `Copy`.
                        on:click = move |_| section.node.scroll_to(
                            ScrollTarget::IntoViewStart,
                            ScrollBehavior::Smooth,
                        )
                    ) {
                        {section.title.clone()}
                    }
                }
            }
            scroll(class = "manual__body") {
                column {
                    for section in move || sections.get(), key = |s: &Section| s.id {
                        box(class = "manual__section", node_ref = section.node) {
                            label(class = "manual__heading") {{section.title.clone()}}
                            text {"…"}
                        }
                    }
                }
            }
        }
    }
}

const SHEET: &str = css!(
    ".manual          { height: 100%; gap: 16px; }
     .manual__index   { width: 200px; overflow-y: auto; }
     .manual__body    { flex: 1 1 auto; }
     .manual__section { padding: 24px; }"
);

section.node names the section, not the container, so scroll_to moves .manual__body — the nearest scrolling ancestor that is not the section itself. .manual__index is a scroll container too, made by one overflow-y declaration and no scroll element.

scroll_to schedules a scroll; it does not perform one. The scroll is carried out later in the same frame, against the geometry of the last completed frame. Two things follow. Reading scroll_position() immediately after the call still reports the old offset. And scrolling to a node that was created in the same turn moves nothing, because that node had no geometry when the scroll was carried out — revealing a panel and scrolling to it is two turns, not one.

Watching a scroll

on:scroll is the result rather than the input. It does not bubble and cannot be cancelled.

scroll(
    class = "log",
    on:scroll = move |ev| pinned.set(ev.is_at_end_vertically())
) { /* … */ }
pub struct ScrollEvent {
    pub offset: Point<CssPx, Css>,
    pub content_size: Size<CssPx, Css>,
    pub scrollport: Size<CssPx, Css>,
}

One container that moved several times in one frame — a wheel event plus a glide step — is reported once, from where it started to where it ended. The event is dispatched after geometry is computed and before anything is painted, and the reactive graph is flushed and the document restyled immediately afterwards, so a handler that rebuilds what is on screen is drawn in its final place in the same frame rather than one frame late.

observe_scroll() is the same information as a signal, and is what a virtualised list uses. It costs the frame one observation per node, refcounted, so several callers watching one container pay for one.

Freezing the window

zgui::view::current_host() answers Some(HostHandle) inside a window's scope, and freeze_scrolling(true) on that handle stops the window's own container moving while leaving everything about the layout alone: the offset it holds, the width its content wrapped to, the gutter its bar occupies. A modal surface holds this while it is open. Taking the scrolling away by restyling instead — overflow: hidden on the root — clamps the offset to the top, so the page jumps under the modal and jumps back when it closes. Containers inside the page keep their own scrolling. Calls do not nest. Overlays and portals covers the rest.

When the content changes size

There is no scroll anchoring. overflow-anchor is not a supported property and nothing tries to guess which line the reader was looking at. The offset is a number, held under the element, and layout moves the content underneath it.

Exactly two corrections are made, and both are stated as one rule: a resize never moves the reader on purpose, and never leaves them past the end.

What changedWhat happens to the offset
the container got shorter, or the content got smaller, so the offset is now past the endclamped down to the new end
the container got taller, or only its width changed, and the offset is still legalnothing moves at all
the device pixel ratio changedevery offset is multiplied by the change

An offset is never re-derived as a fraction of the extent, because a fraction is not what a reader is looking at. The clamp runs after any layout pass that actually ran, and costs one region lookup per container that has ever been scrolled.

The consequence is the one to plan for: content that grows above the scrollport pushes what the reader is looking at down by exactly the height that was added. Nothing corrects it. Two patterns handle it.

Follow the end. A log or a chat that should stay at the bottom watches the content extent and puts itself back whenever it grows:

use zgui::geom::{DevicePx, Point};
use zgui::reactive::RenderEffect;

#[derive(Clone)]
struct Line {
    id: u64,
    text: String,
}

#[component]
fn Log(lines: Signal<Vec<Line>>) -> impl IntoView {
    let port = NodeRef::new();
    // Whether the reader is at the bottom. Scrolling up turns it off; scrolling back turns it on.
    let pinned = RwSignal::new_local(true);
    // `content_size` here is the extent of the scrolled content, so an appended line moves it.
    let position = port.observe_scroll();

    let following = RenderEffect::new(move |_| {
        let position = position.get();
        if pinned.get_untracked() && !position.is_at_end_vertically() {
            // Past the end; the offset is clamped to the true end when it lands.
            port.scroll_to(
                ScrollTarget::Offset(Point::new(DevicePx(0.0), DevicePx(f32::MAX))),
                ScrollBehavior::Instant,
            );
        }
    });
    on_cleanup_local(move || drop(following));

    view! {
        scroll(
            class = "log",
            node_ref = port,
            on:scroll = move |ev| pinned.set(ev.is_at_end_vertically())
        ) {
            column {
                for line in move || lines.get(), key = |line: &Line| line.id {
                    text {{line.text.clone()}}
                }
            }
        }
    }
}

The observation is delivered after the layout that grew the content, and the scroll it asks for is carried out on the next frame, against that layout. The effect settles: the scroll it performs raises is_at_end_vertically, so the next run does nothing.

Correct by what was added. When older rows are inserted above, move the offset down by the height they occupy. With rows of a known height that is arithmetic on ScrollTarget::By. With rows of unknown height, hold a node_ref on the row that was at the top and call scroll_to(ScrollTarget::IntoViewStart, ScrollBehavior::Instant) in the turn after the insert, when that row has geometry again.

Virtualising a long list

A list of a hundred thousand rows is a hundred thousand elements to style, lay out, paint and hit-test, and a scrollport thirty rows tall never shows more than thirty. Virtualising means building only the rows that can be seen, and standing in for the rest with two empty boxes so that the container still measures the whole list and the scrollbar is still the right length.

There is no framework machinery for this. It is built from a node_ref, one observation, one derived value and a keyed for. Here is the whole of it.

use zgui::prelude::*;
use zgui::{component, css, view};

/// How tall one row is, in CSS pixels. Declared, not measured — see the note below.
const ROW: f32 = 24.0;

/// How many rows to build past each edge of the scrollport.
const OVERSCAN: usize = 4;

/// Which rows are worth building, and how much space stands in for the rest.
#[derive(Copy, Clone, PartialEq, Debug, Default)]
struct Visible {
    first: usize,
    count: usize,
    /// The height of everything before the first built row, in CSS pixels.
    lead: f32,
    /// The height of everything after the last built row, in CSS pixels.
    trail: f32,
}

impl Visible {
    fn indices(self) -> Vec<usize> {
        (self.first..self.first + self.count).collect()
    }
}

/// `port` and `offset` are both in CSS pixels.
fn visible(rows: usize, port: f32, offset: f32) -> Visible {
    if rows == 0 {
        return Visible::default();
    }
    // Rounded up, plus one: a port 400 px tall showing 24 px rows meets eighteen rows whenever
    // it is scrolled to anything but an exact multiple of the row height.
    let fits = ((port.max(0.0) / ROW).ceil() as usize).max(1) + 1;
    let anchor = ((offset.max(0.0) / ROW).floor() as usize).min(rows - 1);
    let first = anchor.saturating_sub(OVERSCAN);
    let end = (anchor + fits + OVERSCAN).min(rows);
    Visible {
        first,
        count: end - first,
        lead: first as f32 * ROW,
        trail: (rows - end) as f32 * ROW,
    }
}

#[component]
fn Ledger(rows: Signal<usize>) -> impl IntoView {
    let port = NodeRef::new();
    // One observation on the container. It is taken when the handle binds, so asking for it
    // here — before the element exists — is the ordinary way to use it.
    let position = port.observe_scroll();

    let seen = Signal::derive_local(move || {
        let position = position.get();
        // The observation answers in device pixels and ROW is in CSS pixels.
        let scale = port.scale();
        let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
        visible(
            rows.get(),
            position.scrollport.height.0 / scale,
            position.offset.y.0 / scale,
        )
    });

    view! {
        scroll(class = "ledger", node_ref = port) {
            column(
                style:padding-top = move || Some(format!("{}px", seen.get().lead)),
                style:padding-bottom = move || Some(format!("{}px", seen.get().trail))
            ) {
                for index in move || seen.get().indices(), key = |index: &usize| *index {
                    row(class = "ledger__row") {
                        text {{move || format!("row {index}")}}
                    }
                }
            }
        }
    }
}

const SHEET: &str = css!(
    ".ledger      { height: 480px; }
     .ledger__row { height: 24px; align-items: center; padding: 0 12px; }"
);

What each part does:

The handle and the observation. port names the scroll container. observe_scroll() turns its position into a signal that is written during the frame the scroll happens in, before anything is painted.

The derived run. seen reads the position, converts to CSS pixels, and answers which contiguous run of rows to build. It re-runs on every scroll — but its answer changes only when the run changes, so scrolling within one row rebuilds nothing at all.

The two spacers. padding-top and padding-bottom on the inner column stand in for the rows that are not built. Their heights plus the built rows always add up to the whole list, which is what keeps the scrollbar the size it would be if every row existed.

The keyed list. The key is the row's own index. Moving the run by one row destroys one row, builds one row, and moves the rest — see Control flow for why a key is what makes that possible.

The row height is declared and not measured. Which rows to build has to be decided before those rows exist, so it cannot be decided from their heights: measuring row 4 200 means building row 4 200, which is the cost virtualisation exists to avoid. A style sheet that disagrees with ROW gives a scrollbar of the wrong length, not a wrong set of rows.

Two inputs need care and are handled above. A row height of zero would divide the list into infinitely many rows. A scrollport of zero is the ordinary state on the first frame, before anything has been laid out and before the observation has fired; fits is floored at one row so that the container has something to measure and the next frame knows better.

What scrolling costs

Scrolling a virtualised list is two different frames wearing the same clothes:

  • a translation frame — the same rows at a new offset;
  • a recycle frame — one row-height of travel, so one row leaves one end and one arrives at the other.

Averaging them hides the interesting one, so they are measured separately. The numbers below were measured by the run that wrote docs/performance.md, which cargo xtask perf generates; the scenario is a ten-thousand-row virtualised list carried past its port at 120 Hz (crates/zgui-bench/src/scenario/scroll.rs), 600 ticks with one wheel notch every fortieth.

The band is the ceiling this build must stay under, and a run above it fails the test suite. The budget is what the design was supposed to cost, and "met" says the measurement is inside it. Measurements covers both.

MeasurementValueBandBudget
scroll.translation44.18 µs61.601000.00 met
scroll.recycle1021.42 µs1442.001500.00 met
scroll.translation.restyles0.00 elements0.000.00 met
scroll.translation.relayouts0.00 nodes0.000.00 met
scroll.translation.hit_rebuilds0.00 rebuilds0.000.00 met

The last three are the design statement, and their band is zero rather than "small". A translation restyles nothing because moving content changes no computed style: an offset is not a layout input and is not part of any declaration, so no selector can match differently because of it. It relays out nothing because moving content changes no size: the scroll region — where the scrollport is, how far the content reaches — is layout's answer and did not change. And the hit index is translated with the content rather than rebuilt from it, so a pointer still finds the right row without the index being made again.

The fragment pass still moves the rigid subtree and carries its hit entries. It also reports whether all movement used one vector and whether the frame damaged anything else. When the renderer keeps a persistent composed target, zgui can copy the valid pixels inside the scrollport and draw only the bands exposed by the movement.

The shift is accepted only when all of these conditions are true:

  • exactly one scroll container moved;
  • all reported movement is rigid, settled, and equal;
  • the movement is a whole number of device pixels and no elastic overscroll is active;
  • the renderer supports shifts of its composed target;
  • the scrollport has an opaque backing, and later painted content does not overlap it.

If any condition is false, zgui draws the normal damage. A refusal changes performance only. The output stays the same.

A successful shift keeps damage from other changes and adds the exposed horizontal and vertical bands. A scrollbar thumb or a newly mounted row can therefore draw over the shifted pixels without forcing the complete scrollport to be emitted again.

A recycle frame pays for the row that arrived. The current workload rebuilds 8 boxes, relays out 29 nodes, and emits 404 primitives. Its measured median is 1021.42 µs (docs/performance.md).

The framework's own glide over the 1 851-box gallery is measured at kitchen.glide_tick = 973.26 µs (docs/performance.md).

Why virtualising is not optional for a long list

These are advisory absolutes recorded on the maintainer's machine — a 13th Gen Intel Core i9-13900K, release profile — and they gate nothing (docs/perf/reference-workloads.md):

DocumentGlide cost per box
virtualised, 100 000 rows27 845 – 28 217 ns per realised box, and 39 of them are realised
unvirtualised probe, 17 507 boxes172 ns
unvirtualised probe, 35 007 boxes211 ns
unvirtualised probe, 70 007 boxes287 ns
unvirtualised probe, 140 007 boxes5 660 ns

The virtualised list costs about a millisecond a frame whatever the data behind it. The unvirtualised one is a couple of hundred nanoseconds per box until it stops fitting in cache, and then it is 792 milliseconds a frame at twenty thousand rows — a cliff rather than a slope. A rigid non-virtualised scroller crosses a 16.6 ms frame budget at roughly 160 000 boxes, about 23 000 rows (docs/perf/glide-split.md).

Next

On this page