Prune commentary and stale Rust port notes

This commit is contained in:
iris committed 2026-09-10 00:44:13 -04:00
1 parent 3ae034a47b
commit 1e6d3b1edd
84 files changed
+334 -5648

No files matched your search

-1
View File
@@ -79,7 +79,6 @@ type EventData<Rsc, E> = (E, Rc<dyn for<'a> EventFn<Rsc, <E as Event>::Data<'a>>
pub struct TypeEventManager<Rsc: HasEvents, E: Event> {
// TODO: reduce visiblity!!
pub active: HashMap<LayerId, HashMap<WidgetId, E::State>>,
/// This event's own input-wide state -- see [`Event::Global`].
pub global: E::Global,
map: HashMap<WidgetId, Vec<EventData<Rsc, E>>>,
}
-13
View File
@@ -9,19 +9,6 @@ pub use rsc::*;
pub trait Event: Sized + 'static + Clone {
type Data<'a>: Clone = ();
type State: Default = ();
/// State this event owns that belongs to no single widget -- what the
/// thing dispatching the event knows about the *input*, rather than
/// about a listener. `()` for almost every event; the cursor's is
/// `iris::sense::PointerInput` (which widget holds pointer capture,
/// and who is tracking the press in flight).
///
/// It lives here so that such state has one owner, reached by `&mut`
/// through the event manager, instead of being parked on whatever
/// structure a handler happens to be able to reach and guarded with a
/// lock. Iris asked for that on 2026-09-08, of the pointer capture
/// that used to sit in a `Mutex` on `UiRenderState`: "everything
/// global should be stored in the general input handler, not in
/// specific senses with locking stuff."
type Global: Default = ();
#[allow(unused_variables)]
fn should_run<'a>(&self, data: &Self::Data<'a>) -> Option<Self::Data<'a>> {
-34
View File
@@ -1,39 +1,5 @@
//! The icons iris draws, as codepoints in the Nerd Fonts subset it ships.
//!
//! **Why a bundled font rather than ordinary Unicode**: the disclosure
//! mark used to be U+25B8/25BE/25B4 out of whatever face the platform
//! resolved, and once iris stopped bundling fonts (decided
//! 2026-09-07) Iris's phone drew an empty box for them and this VM drew a
//! dot. UI_RULES' answer is not to avoid glyphs but to ship them, which is
//! also what the Compose app has always done for its icons
//! (`app/build-icon-font.sh`, `NerdIcons.kt`) -- the same Material Design
//! family, so an icon means the same thing in both apps.
//!
//! **Why not vector assets or drawn shapes**: an icon beside a line of
//! text wants that line's size, colour and baseline, and text gets all
//! three for free. This replaced `iris::widget::mark`, which drew the
//! triangle into a texture: correct, but one shape, and every further icon
//! would have been another bespoke rasteriser.
//!
//! Each constant here has to have a matching codepoint in
//! `iris/core/build-icon-font.sh`'s `GLYPHS`; a codepoint here that the
//! script did not subset is a glyph that silently isn't there. The subset
//! is the font's **Mono** face, where every glyph is one em wide and one
//! em tall, so two icons at one font size are one size without either
//! being given one -- and why an icon looks smaller than text at the same
//! size, since the glyph is drawn inside that em rather than filling it.
//!
//! Draw one with [`crate::Family::Icons`]:
//!
//! ```ignore
//! text(icon::OPEN, 12.0, MUTED).family(Family::Icons)
//! ```
/// `md-menu_down` -- a filled triangle pointing down: this card is open.
pub const OPEN: &str = "\u{F035D}";
/// `md-menu_right` -- pointing right: this card opens.
pub const CLOSED: &str = "\u{F035F}";
/// `md-menu_up` -- pointing up: fold this group of cards away again.
pub const COLLAPSE: &str = "\u{F0360}";
+4 -6
View File
@@ -88,6 +88,10 @@ impl RegionAlign {
pub const fn rel(&self) -> Vec2 {
vec2(self.x.rel(), self.y.rel())
}
pub const fn pos(self) -> UiVec2 {
UiVec2::from(self)
}
}
impl UiVec2 {
@@ -192,9 +196,3 @@ const impl From<RegionAlign> for UiVec2 {
Self::rel(align.rel())
}
}
impl RegionAlign {
pub const fn pos(self) -> UiVec2 {
UiVec2::from(self)
}
}
-18
View File
@@ -15,24 +15,6 @@ pub struct Len {
/// the two are kept separate rather than one field a caller has to
/// remember to pre-multiply.
pub abs: f32,
/// Density-independent pixels -- Android's `dp` / CSS's reference pixel
/// (1 unit = 1/160in), resolved against the display's density at
/// layout time (`apply_rest`'s `density` parameter) rather than at the
/// point a widget is built, since density is a property of the device
/// this ends up running on, not of the widget tree. This is the unit
/// IRIS_TODO.md's "a density-independent length unit" item asked for,
/// 2026-09-06: before it existed, every size in the tree was `abs`
/// (physical pixels), and the only way to make a 16px design draw at
/// the right *size* on a denser display was a single global multiply
/// applied to the whole rendered scene after layout -- which is also
/// what made text blurry (RUST.md's P0 box, "blurry ... glyphs drawn
/// at logical size and stretched by the scale"): a glyph rasterised at
/// 16 physical px and then stretched 3x by that global multiply is a
/// 48px area sampled from a 16px bitmap. Resolving `dp` per-length at
/// layout time instead means the font size handed to the text shaper
/// is already the physical size (`16.0.dp() * 3.0`), so the glyph
/// atlas rasterises at the display's real resolution and nothing
/// downstream needs to stretch anything.
pub dp: f32,
pub rel: f32,
pub rest: f32,
-1
View File
@@ -1,6 +1,5 @@
use std::marker::Destruct;
/// stored in linear for sane manipulation
#[repr(C)]
#[derive(Clone, Copy, Hash, PartialEq, Eq, bytemuck::Zeroable, Debug)]
pub struct Color<T> {
-9
View File
@@ -14,19 +14,13 @@ struct LayerNode<T> {
#[derive(Clone, Copy, Debug)]
enum Ptr {
/// continue on same level
Next(usize),
/// go back to parent
Parent(usize),
/// end
None,
}
/// TODO: currently this does not ever free layers
/// is that realistically desired?
pub struct Layers<T> {
vec: Vec<LayerNode<T>>,
/// index of last layer at top level (start at first = 0)
last: usize,
}
@@ -36,9 +30,6 @@ struct Child {
tail: usize,
}
/// The draw order of every layer. The primitives themselves live in one
/// arena beside this (`UiRenderState::primitives`); a layer names the
/// slots it draws, which is what its vertex buffer is.
pub type PrimitiveLayers = Layers<LayerOrder>;
impl<T: Default> Layers<T> {
+244 -409
View File
@@ -12,40 +12,13 @@ use swash::{
zeno::{Format, Vector},
};
/// The icon font iris ships: the Nerd Fonts Symbols **Mono** subset built
/// by `iris/core/build-icon-font.sh`, holding only the codepoints
/// `crate::icon` names (992 bytes for three glyphs today).
///
/// This is the one font bundled here, and it is not a text font: body and
/// monospace text still come from the platform's own collection
/// (decided 2026-09-07). An icon is the opposite case -- a small,
/// closed set of codepoints no system font is guaranteed to have -- which
/// is the same division the Compose app makes.
const NERD_ICONS: &[u8] = include_bytes!("../../assets/fonts/nerd_icons.ttf");
/// What starting up found about text rendering, for the on-screen
/// Diagnostics page and the one startup log line (RUST.md's P0 box, "log
/// once at startup ... the number of font families found, the default
/// family resolved"). Built once by `TextData::font_diagnostics` --
/// `Default::default` still exists for callers (tests, examples) that
/// don't need the report.
#[derive(Clone, Debug)]
pub struct FontDiagnostics {
/// `Collection::family_names().count()` after registering the bundled
/// fonts -- system families plus the two bundled ones.
pub families_found: usize,
/// The family `GenericFamily::SansSerif` resolves to first -- the
/// bundled "Noto Sans" unless registration itself failed.
pub default_family: Option<String>,
/// The family `GenericFamily::Monospace` resolves to first.
pub default_mono_family: Option<String>,
/// One resolved family name per style axis this crate actually uses
/// (`SpanStyle::bold`/`italic`), so a report can say plainly whether a
/// bold/italic request is landing on a real face rather than being
/// silently absorbed by whatever the sans-serif default resolves to
/// for every weight (RUST.md's P0 box, "bold words render as blank
/// gaps" -- a family that resolves but has no distinct bold face is
/// exactly what produced that).
pub regular_resolved: Option<String>,
pub bold_resolved: Option<String>,
pub italic_resolved: Option<String>,
@@ -57,8 +30,6 @@ pub struct FontDiagnostics {
pub icon_family: Option<String>,
}
/// Everything text needs that outlives one string: the font collection, the
/// layout scratch space, the glyph rasteriser and the atlas they fill.
pub struct TextData {
pub font_cx: FontContext,
pub layout_cx: LayoutContext<UiColor>,
@@ -84,19 +55,6 @@ pub struct TextData {
}
impl Default for TextData {
/// Text comes entirely from the platform's own font collection --
/// `FontContext::new()` builds a `fontique::Collection` with
/// `CollectionOptions::system_fonts` on by default, which is real
/// discovery on both targets this crate ships on: Android's backend
/// parses `/system/fonts` and `/system/etc/fonts.xml` and maps
/// `SansSerif`/`SystemUi` to `["Roboto Flex", "Roboto", "Noto Sans"]`
/// and `Monospace` to the platform's `"monospace"` alias; the desktop
/// build's backend is fontconfig. No font is bundled or registered
/// here -- see the 2026-09-07 decision for why (matching what
/// the Compose app does: it takes body/monospace text from
/// `FontFamily.Default`/`FontFamily.Monospace`, i.e. Android's Roboto
/// and its platform monospace face, and ships no text font of its own,
/// only its committed Nerd Fonts icon subset for fixed glyphs).
fn default() -> Self {
let mut font_cx = FontContext::new();
patch_android_monospace(&mut font_cx);
@@ -112,16 +70,6 @@ impl Default for TextData {
}
}
/// Registers the bundled icon font as an ordinary named family and
/// answers the name it registered under -- read back from the collection
/// rather than written down here, so the name cannot drift from the file
/// (`build-icon-font.sh` takes whatever face the Nerd Fonts release
/// ships).
///
/// A *named* family rather than a generic one: nothing should fall back
/// to it for ordinary text, and nothing should fall back out of it for an
/// icon -- a system face that happens to have one of these codepoints
/// would draw somebody else's picture.
fn register_icon_font(font_cx: &mut FontContext) -> Option<String> {
let blob = Blob::new(Arc::new(NERD_ICONS));
let id = font_cx
@@ -133,24 +81,6 @@ fn register_icon_font(font_cx: &mut FontContext) -> Option<String> {
font_cx.collection.family_name(id).map(str::to_string)
}
/// Works around `fontique` 0.11.1's Android backend never resolving
/// `GenericFamily::Monospace` (confirmed against
/// `fontique-0.11.1/src/backend/android.rs`'s `SystemFonts::new`, and still
/// present on `linebender/parley`'s `main` as of 2026-09-07, so there is no
/// released fix to bump to yet -- see the 2026-09-07 decision,
/// "Platform fonts," for the full account). Two bugs stack, not one:
/// `DEFAULT_GENERIC_FAMILIES` looks up the name `"monospace"` *before*
/// `fonts.xml` is parsed into that same name map, and even after parsing,
/// AOSP's `fonts.xml` names it with a `<family name="monospace">` element
/// (not an `<alias>`) whose `<font>` children the backend's own parser
/// does not read (a `TODO` in that match arm) -- so the name gets a
/// `FamilyId` with no font data behind it, and `family_by_name("monospace")`
/// comes back empty too. Confirmed on this checkout's emulator: `adb pull
/// /system/etc/fonts.xml` shows
/// `<family name="monospace"><font weight="400"
/// style="normal">DroidSansMono.ttf</font></family>` with no matching
/// alias.
///
/// So this reads `fonts.xml` itself (already on-device, already the
/// authority Compose's own `Typeface.MONOSPACE` resolves through) for the
/// filename that declaration names, then finds which of fontique's
@@ -204,13 +134,6 @@ fn patch_android_monospace(font_cx: &mut FontContext) {
}
}
/// Reads the font filename `fonts.xml` names for its `"monospace"` family
/// (e.g. `"DroidSansMono.ttf"`), by plain substring search rather than a
/// real XML parser -- a new dependency for one well-known, stable AOSP file
/// whose structure fontique itself already parses with a full parser one
/// module over. Not a general XML reader; assumes the file has exactly one
/// `<family name="monospace">` element with at least one `<font>` child,
/// which is the format on every AOSP `fonts.xml` this was checked against.
#[cfg(target_os = "android")]
fn android_monospace_font_filename() -> Option<String> {
let android_root = std::env::var("ANDROID_ROOT").unwrap_or_else(|_| "/system".to_string());
@@ -231,9 +154,6 @@ fn android_monospace_font_filename() -> Option<String> {
fn patch_android_monospace(_font_cx: &mut FontContext) {}
impl TextData {
/// [`Family::Icons`] as the name the bundled font actually registered
/// under; everything else unchanged.
///
/// Cloned rather than borrowed because the caller needs it while the
/// layout builder holds `&mut self` -- a `String` per shaped icon run,
/// paid only when the layout is rebuilt.
@@ -247,9 +167,6 @@ impl TextData {
}
}
/// Builds the startup report -- see `FontDiagnostics`. Queries the
/// collection directly (`fontique::Query`) rather than shaping a real
/// string, since all that's needed is which family each axis lands on.
pub fn font_diagnostics(&mut self) -> FontDiagnostics {
use parley::fontique::{Attributes, FontWidth, QueryStatus};
let families_found = self.font_cx.collection.family_names().count();
@@ -268,11 +185,6 @@ impl TextData {
let default_mono_family = default_mono_family_id
.and_then(|id| self.font_cx.collection.family_name(id).map(str::to_string));
// Resolves the family a (generic family, weight, style) query lands
// on, without holding the `Query`'s borrow of `collection` across
// the `family_name` lookup that needs it back -- the `FamilyId` is
// captured out of the closure first, then looked up once `query`
// (and its borrow) has been dropped.
let mut resolve_family =
|generic: GenericFamily, weight: FontWeight, style: FontStyle| -> Option<String> {
let mut family_id = None;
@@ -327,285 +239,7 @@ impl TextData {
icon_family: self.icon_family.clone(),
}
}
}
/// Which family to ask for. Kept as an owned name rather than parley's
/// borrowed `FontFamily<'_>` so that a widget can hold one without a lifetime.
#[derive(Clone, PartialEq)]
pub enum Family {
SansSerif,
Serif,
Monospace,
/// The bundled icon font -- see [`crate::icon`] for what is in it.
/// Named as an intention rather than as a font name because only
/// [`TextData`] knows what the file registered as; it resolves this
/// during shaping ([`TextData::resolve_family`]).
Icons,
Named(String),
}
impl Family {
fn family(&self) -> FontFamily<'_> {
let name = match self {
Self::SansSerif => FontFamilyName::Generic(GenericFamily::SansSerif),
Self::Serif => FontFamilyName::Generic(GenericFamily::Serif),
Self::Monospace => FontFamilyName::Generic(GenericFamily::Monospace),
// Only reachable if `resolve_family` did not run, which no
// shaping path allows -- and sans-serif is the honest answer
// for a build whose icon font failed to register: the reader
// gets the platform's own tofu rather than a wrong picture.
Self::Icons => FontFamilyName::Generic(GenericFamily::SansSerif),
Self::Named(name) => FontFamilyName::Named(name.as_str().into()),
};
FontFamily::Single(name)
}
}
/// One styled run inside a `TextBuffer`, overriding `TextAttrs`' base style
/// over `range` (a byte range into the buffer's text). Every field is
/// optional so a span only says what it changes -- e.g. a link span sets
/// `color` and `underline` and leaves weight/family at the paragraph's own
/// default. This is I5's answer to RUST.md's inline-rich-text ceiling
/// (`masonry/src/widgets/text_area.rs`'s `StyleSet` is one style for the
/// whole editor, with `// TODO: RichTextInput` beside it): parley's own
/// `RangedBuilder::push` already takes a style and a range, so per-span
/// bold/italic/monospace/colour/underline only needed plumbing this struct
/// through to it and giving each glyph its own colour at draw time (see
/// `PlacedGlyph::color` and `TextData::place` below) instead of the one
/// `RenderedText::color` every glyph used to share.
#[derive(Clone, PartialEq)]
pub struct SpanStyle {
pub range: Range<usize>,
pub color: Option<UiColor>,
pub family: Option<Family>,
/// Overrides `TextAttrs::font_size` for just this range -- what lets a
/// heading inside a transcript row's single `TextEdit` be bigger than
/// the paragraph text around it, so a whole markdown-folded row (block
/// and inline styling both) can stay one selectable text buffer instead
/// of one widget per block.
pub font_size: Option<f32>,
pub bold: bool,
pub italic: bool,
pub underline: bool,
}
impl SpanStyle {
pub fn new(range: Range<usize>) -> Self {
Self {
range,
color: None,
family: None,
font_size: None,
bold: false,
italic: false,
underline: false,
}
}
pub fn color(mut self, color: UiColor) -> Self {
self.color = Some(color);
self
}
pub fn family(mut self, family: Family) -> Self {
self.family = Some(family);
self
}
pub fn font_size(mut self, size: f32) -> Self {
self.font_size = Some(size);
self
}
pub fn bold(mut self) -> Self {
self.bold = true;
self
}
pub fn italic(mut self) -> Self {
self.italic = true;
self
}
pub fn underline(mut self) -> Self {
self.underline = true;
self
}
}
#[derive(Clone, PartialEq)]
pub struct TextAttrs {
pub color: UiColor,
pub font_size: f32,
pub line_height: f32,
pub family: Family,
pub wrap: bool,
/// inner alignment of text region (within where it's drawn)
pub align: RegionAlign,
}
pub const LINE_HEIGHT_MULT: f32 = 1.1;
impl Default for TextAttrs {
fn default() -> Self {
let size = 16.0;
Self {
color: UiColor::WHITE,
font_size: size,
line_height: size * LINE_HEIGHT_MULT,
family: Family::SansSerif,
wrap: false,
align: Align::CENTER_LEFT,
}
}
}
/// A string together with its laid-out form.
///
/// The text and the layout live in one place because parley's `Layout` borrows
/// nothing but is only meaningful against the string it was built from: keeping
/// them apart is how they get out of step.
pub struct TextBuffer {
text: String,
layout: Layout<UiColor>,
spans: Vec<SpanStyle>,
/// What the current layout was built for, so `shape` can decline to redo
/// work that would come out the same. Spans are not part of this key --
/// `set_spans` forces `shaped` to `None` directly, the same way `edit`
/// does, since spans change far less often than a naive equality check
/// on the whole `Vec` would cost to compute every frame.
shaped: Option<(TextAttrs, Option<f32>, f32)>,
}
impl TextBuffer {
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
layout: Layout::new(),
spans: Vec::new(),
shaped: None,
}
}
/// Replace this buffer's per-range style overrides (I5's rich text --
/// see `SpanStyle`). Invalidates the layout unconditionally, mirroring
/// `set_text`.
pub fn set_spans(&mut self, spans: Vec<SpanStyle>) {
self.spans = spans;
self.shaped = None;
}
pub fn new_empty() -> Self {
Self::new("")
}
pub fn text(&self) -> &str {
&self.text
}
pub fn layout(&self) -> &Layout<UiColor> {
&self.layout
}
pub fn is_empty(&self) -> bool {
self.text.is_empty()
}
pub fn set_text(&mut self, text: impl Into<String>) {
let text = text.into();
if text != self.text {
self.text = text;
self.shaped = None;
}
}
/// Edit the string in place; invalidates the layout unconditionally, since
/// the caller is assumed to have changed something.
pub fn edit(&mut self) -> &mut String {
self.shaped = None;
&mut self.text
}
pub fn size(&self) -> Vec2 {
Vec2::new(self.layout.width(), self.layout.height())
}
/// Lay the text out, unless it is already laid out for these
/// attributes, this width and this density.
///
/// **`attrs.font_size`/`line_height` and every span's own `font_size`
/// are density-independent (dp) units, multiplied by `density` here --
/// the one place text crosses from the widget tree's dp sizes into the
/// physical pixels the shaper and rasteriser (`TextData::place`) both
/// then work in.** This is what makes glyphs sharp on a dense display:
/// before this existed, `font_size` was already a physical-pixel value
/// (RUST.md's P0 box's global-scale stopgap resolved density by
/// stretching the whole rendered frame afterward instead), so a glyph
/// was rasterised small and then upscaled by whatever the display's
/// scale factor was -- exactly the blur Iris's report described.
/// Multiplying here instead means the font size hitting `ScaleContext`
/// in `place` below is already the display's real physical size, so
/// the atlas holds a bitmap at the resolution it is actually shown at.
/// `GlyphKey.size` already keys on that resolved `font_size`
/// (`(font_size * 16.0).round()`), so a cache entry is naturally per
/// physical size with no change needed there.
pub fn shape(
&mut self,
data: &mut TextData,
attrs: &TextAttrs,
width: Option<f32>,
density: f32,
) {
if self.shaped.as_ref() == Some(&(attrs.clone(), width, density)) {
return;
}
// Resolved before the builder borrows `data`: `Family::Icons`
// names an intention, and the name behind it lives on `TextData`.
let base_family = data.resolve_family(&attrs.family);
let span_families: Vec<Option<Family>> = self
.spans
.iter()
.map(|span| span.family.as_ref().map(|f| data.resolve_family(f)))
.collect();
let mut builder = data
.layout_cx
.ranged_builder(&mut data.font_cx, &self.text, 1.0, true);
builder.push_default(StyleProperty::FontFamily(base_family.family()));
builder.push_default(StyleProperty::FontSize(attrs.font_size * density));
builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute(
attrs.line_height * density,
)));
builder.push_default(StyleProperty::Brush(attrs.color));
for (span, family) in self.spans.iter().zip(&span_families) {
let range = span.range.clone();
if let Some(color) = span.color {
builder.push(StyleProperty::Brush(color), range.clone());
}
if let Some(family) = family {
builder.push(StyleProperty::FontFamily(family.family()), range.clone());
}
if let Some(size) = span.font_size {
builder.push(StyleProperty::FontSize(size * density), range.clone());
}
if span.bold {
builder.push(StyleProperty::FontWeight(FontWeight::BOLD), range.clone());
}
if span.italic {
builder.push(StyleProperty::FontStyle(FontStyle::Italic), range.clone());
}
if span.underline {
builder.push(StyleProperty::Underline(true), range.clone());
}
}
builder.build_into(&mut self.layout, &self.text);
self.layout.break_all_lines(width);
self.layout
.align(Alignment::Start, AlignmentOptions::default());
self.shaped = Some((attrs.clone(), width, density));
}
}
impl TextData {
/// Rasterise whatever of `buffer` is not in the atlas yet, and return where
/// each glyph goes relative to the text's top-left.
///
/// Nothing is uploaded for a glyph already in the atlas, which is the point
/// of having one: a resize re-runs this and touches the GPU only if the new
/// width brought genuinely new glyphs into view.
pub fn place(&mut self, buffer: &TextBuffer, textures: &mut Textures) -> Vec<PlacedGlyph> {
let mut placed = Vec::new();
for line in buffer.layout.lines() {
@@ -622,8 +256,6 @@ impl TextData {
continue;
};
let coords_hash = hash_coords(coords);
// `font.data.id()` rather than the pointer, so the same font
// loaded twice is still one set of entries.
let font_id = font.data.id();
for glyph in run.positioned_glyphs() {
@@ -676,41 +308,7 @@ impl TextData {
}
placed
}
}
fn hash_coords(coords: &[i16]) -> u64 {
// FxHash over the coordinates; they are short and change rarely.
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for c in coords {
h ^= *c as u16 as u64;
h = h.wrapping_mul(0x1000_0000_01b3);
}
h
}
/// A laid-out string, ready to draw: where each glyph goes and how big the
/// whole thing is.
///
/// Cheap to clone and to keep, which is the point -- a widget holds one across
/// frames and re-emits its quads without going near the rasteriser. `color`
/// is the buffer's *base* colour (`TextAttrs::color`) for a caller that wants
/// it as a whole (e.g. tinting a cursor to match); the colour each glyph is
/// actually drawn in is `PlacedGlyph::color`, which a `SpanStyle` can
/// override per range.
#[derive(Clone)]
pub struct RenderedText {
pub glyphs: std::sync::Arc<Vec<PlacedGlyph>>,
pub size: Vec2,
pub color: UiColor,
/// The [`GlyphAtlas::generation`] the glyphs above were placed against.
/// A holder must re-render rather than re-emit these quads once the
/// atlas has moved on (`GlyphAtlas::clear`'s doc says what happens
/// otherwise); `Painter::glyphs` debug-asserts it.
pub generation: u64,
}
impl TextData {
/// Lay out and place in one step, which is what a widget wants.
pub fn render(
&mut self,
buffer: &mut TextBuffer,
@@ -730,16 +328,255 @@ impl TextData {
}
}
/// Which family to ask for. Kept as an owned name rather than parley's
/// borrowed `FontFamily<'_>` so that a widget can hold one without a lifetime.
#[derive(Clone, PartialEq)]
pub enum Family {
SansSerif,
Serif,
Monospace,
/// The bundled icon font -- see [`crate::icon`] for what is in it.
/// Named as an intention rather than as a font name because only
/// [`TextData`] knows what the file registered as; it resolves this
/// during shaping ([`TextData::resolve_family`]).
Icons,
Named(String),
}
impl Family {
fn family(&self) -> FontFamily<'_> {
let name = match self {
Self::SansSerif => FontFamilyName::Generic(GenericFamily::SansSerif),
Self::Serif => FontFamilyName::Generic(GenericFamily::Serif),
Self::Monospace => FontFamilyName::Generic(GenericFamily::Monospace),
Self::Icons => FontFamilyName::Generic(GenericFamily::SansSerif),
Self::Named(name) => FontFamilyName::Named(name.as_str().into()),
};
FontFamily::Single(name)
}
}
#[derive(Clone, PartialEq)]
pub struct SpanStyle {
pub range: Range<usize>,
pub color: Option<UiColor>,
pub family: Option<Family>,
pub font_size: Option<f32>,
pub bold: bool,
pub italic: bool,
pub underline: bool,
}
impl SpanStyle {
pub fn new(range: Range<usize>) -> Self {
Self {
range,
color: None,
family: None,
font_size: None,
bold: false,
italic: false,
underline: false,
}
}
pub fn color(mut self, color: UiColor) -> Self {
self.color = Some(color);
self
}
pub fn family(mut self, family: Family) -> Self {
self.family = Some(family);
self
}
pub fn font_size(mut self, size: f32) -> Self {
self.font_size = Some(size);
self
}
pub fn bold(mut self) -> Self {
self.bold = true;
self
}
pub fn italic(mut self) -> Self {
self.italic = true;
self
}
pub fn underline(mut self) -> Self {
self.underline = true;
self
}
}
#[derive(Clone, PartialEq)]
pub struct TextAttrs {
pub color: UiColor,
pub font_size: f32,
pub line_height: f32,
pub family: Family,
pub wrap: bool,
pub align: RegionAlign,
}
pub const LINE_HEIGHT_MULT: f32 = 1.1;
impl Default for TextAttrs {
fn default() -> Self {
let size = 16.0;
Self {
color: UiColor::WHITE,
font_size: size,
line_height: size * LINE_HEIGHT_MULT,
family: Family::SansSerif,
wrap: false,
align: Align::CENTER_LEFT,
}
}
}
/// The text and the layout live in one place because parley's `Layout` borrows
/// nothing but is only meaningful against the string it was built from: keeping
/// them apart is how they get out of step.
pub struct TextBuffer {
text: String,
layout: Layout<UiColor>,
spans: Vec<SpanStyle>,
shaped: Option<(TextAttrs, Option<f32>, f32)>,
}
impl TextBuffer {
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
layout: Layout::new(),
spans: Vec::new(),
shaped: None,
}
}
pub fn set_spans(&mut self, spans: Vec<SpanStyle>) {
self.spans = spans;
self.shaped = None;
}
pub fn new_empty() -> Self {
Self::new("")
}
pub fn text(&self) -> &str {
&self.text
}
pub fn layout(&self) -> &Layout<UiColor> {
&self.layout
}
pub fn is_empty(&self) -> bool {
self.text.is_empty()
}
pub fn set_text(&mut self, text: impl Into<String>) {
let text = text.into();
if text != self.text {
self.text = text;
self.shaped = None;
}
}
/// Edit the string in place; invalidates the layout unconditionally, since
/// the caller is assumed to have changed something.
pub fn edit(&mut self) -> &mut String {
self.shaped = None;
&mut self.text
}
pub fn size(&self) -> Vec2 {
Vec2::new(self.layout.width(), self.layout.height())
}
pub fn shape(
&mut self,
data: &mut TextData,
attrs: &TextAttrs,
width: Option<f32>,
density: f32,
) {
if self.shaped.as_ref() == Some(&(attrs.clone(), width, density)) {
return;
}
let base_family = data.resolve_family(&attrs.family);
let span_families: Vec<Option<Family>> = self
.spans
.iter()
.map(|span| span.family.as_ref().map(|f| data.resolve_family(f)))
.collect();
let mut builder = data
.layout_cx
.ranged_builder(&mut data.font_cx, &self.text, 1.0, true);
builder.push_default(StyleProperty::FontFamily(base_family.family()));
builder.push_default(StyleProperty::FontSize(attrs.font_size * density));
builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute(
attrs.line_height * density,
)));
builder.push_default(StyleProperty::Brush(attrs.color));
for (span, family) in self.spans.iter().zip(&span_families) {
let range = span.range.clone();
if let Some(color) = span.color {
builder.push(StyleProperty::Brush(color), range.clone());
}
if let Some(family) = family {
builder.push(StyleProperty::FontFamily(family.family()), range.clone());
}
if let Some(size) = span.font_size {
builder.push(StyleProperty::FontSize(size * density), range.clone());
}
if span.bold {
builder.push(StyleProperty::FontWeight(FontWeight::BOLD), range.clone());
}
if span.italic {
builder.push(StyleProperty::FontStyle(FontStyle::Italic), range.clone());
}
if span.underline {
builder.push(StyleProperty::Underline(true), range.clone());
}
}
builder.build_into(&mut self.layout, &self.text);
self.layout.break_all_lines(width);
self.layout
.align(Alignment::Start, AlignmentOptions::default());
self.shaped = Some((attrs.clone(), width, density));
}
}
fn hash_coords(coords: &[i16]) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for c in coords {
h ^= *c as u16 as u64;
h = h.wrapping_mul(0x1000_0000_01b3);
}
h
}
/// Cheap to clone and to keep, which is the point -- a widget holds one across
/// frames and re-emits its quads without going near the rasteriser. `color`
/// is the buffer's *base* colour (`TextAttrs::color`) for a caller that wants
/// it as a whole (e.g. tinting a cursor to match); the colour each glyph is
/// actually drawn in is `PlacedGlyph::color`, which a `SpanStyle` can
/// override per range.
#[derive(Clone)]
pub struct RenderedText {
pub glyphs: std::sync::Arc<Vec<PlacedGlyph>>,
pub size: Vec2,
pub color: UiColor,
/// The [`GlyphAtlas::generation`] the glyphs above were placed against.
/// A holder must re-render rather than re-emit these quads once the
/// atlas has moved on (`GlyphAtlas::clear`'s doc says what happens
/// otherwise); `Painter::glyphs` debug-asserts it.
pub generation: u64,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::icon;
/// Every codepoint `icon` names is actually in the subset the script
/// built. This is the failure `build-icon-font.sh`'s own comment warns
/// about -- a constant added on one side and not the other is a glyph
/// that silently isn't there -- and it is invisible at runtime,
/// because a missing glyph draws as nothing rather than as an error.
#[test]
fn every_icon_is_in_the_bundled_font() {
let font = FontRef::from_index(NERD_ICONS, 0).expect("the bundled icon font parses");
@@ -762,8 +599,6 @@ mod tests {
}
}
/// The font registers, so `Family::Icons` resolves to a real family
/// rather than falling through to sans-serif and drawing tofu.
#[test]
fn the_icon_family_registers_and_resolves() {
let data = TextData::default();
-46
View File
@@ -22,10 +22,6 @@ pub enum TextureKind {
},
}
/// What a [`Textures::shared`] texture is a picture of -- exactly, not by
/// hash: `owner` names the widget kind whose description it is, and `id`
/// packs that description's own fields, so two owners cannot collide and
/// a debugger shows which picture a slot holds.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct SharedTextureKey {
pub owner: &'static str,
@@ -46,8 +42,6 @@ pub struct TextureHandle {
pub struct Textures {
free: Vec<u32>,
images: Vec<Option<DynamicImage>>,
/// What each slot is, kept beside the image so a slot can be pushed
/// again without the handle that knows -- see [`Textures::reupload`].
kinds: Vec<TextureKind>,
/// Textures built from a description rather than from a file, one per
/// distinct description: see [`Textures::shared`]. The map holds a
@@ -119,8 +113,6 @@ impl Textures {
}
}
/// Adds a page of the shared glyph atlas array. Only `atlas.rs` should
/// call this -- everything else wants `add`.
pub fn add_page(&mut self, image: impl Into<DynamicImage>) -> TextureHandle {
let image = image.into();
let size = image.dimensions().into();
@@ -152,18 +144,6 @@ impl Textures {
}
}
/// The one texture for `key`, building it on the first ask and handing
/// out a further reference to it every time after.
///
/// **Why this exists**: a texture rasterised from a *description* --
/// `widget::mark`'s triangle, from a direction and a colour -- has as
/// many copies as there are widgets asking for it, and each copy is
/// its own GPU texture, its own bind group and its own draw call. A
/// transcript screen with a folded card per tool call built one per
/// card: hundreds of 48x48 textures of three distinct pictures,
/// created and freed again as rows recycled. `make` is not called when
/// the key is already known, so the rasterising is paid once too.
///
/// The map keeps its own reference for the life of the `Textures`, so
/// a shared slot is never freed and never reused for something else --
/// which is what makes a handle held by a long-lived widget safe.
@@ -180,21 +160,16 @@ impl Textures {
handle
}
/// The stored image for a handle, to be written into before `patch`.
pub fn image_mut(&mut self, handle: &TextureHandle) -> &mut DynamicImage {
self.images[handle.slot as usize]
.as_mut()
.expect("texture was freed while still held")
}
/// Queue an upload of just `rect`, after writing it with `image_mut`.
pub fn patch(&mut self, handle: &TextureHandle, rect: PatchRect) {
self.updates.push(Update::Patch(handle.slot, rect));
}
/// Queue every live slot for upload again, in slot order -- what a
/// genuinely new GPU device needs, in place of forgetting everything.
///
/// A new device starts with no textures, and the renderer-side mirror
/// of these slots (`render::texture::GpuTextures`) starts empty with
/// it. What it must not do is start empty while the handles widgets
@@ -208,15 +183,6 @@ impl Textures {
/// still holds the images: the slot list is rebuilt identically,
/// including the empty slots, which go across as `PushFree` so the
/// ones after them still land where they were.
///
/// The glyph atlas comes back with it and is deliberately *not*
/// cleared any more: its pages are slots here, this side holds their
/// pixels, and re-uploading them restores exactly the atlas that was
/// there -- so an app switch no longer costs a re-rasterisation of
/// every glyph on screen either.
///
/// Pending updates are dropped rather than kept: each is either a push
/// or a patch of a slot this replays in full.
pub fn reupload(&mut self) {
self.updates.clear();
self.updates
@@ -275,8 +241,6 @@ impl TextureHandle {
}
}
/// The layer this page occupies in the shared atlas array texture.
/// Only valid for a page handle; see `image_index`'s note.
pub fn layer(&self) -> u32 {
match self.kind {
TextureKind::Page { layer } => layer,
@@ -320,9 +284,6 @@ mod tests {
SharedTextureKey { owner: "test", id }
}
/// What `widget::mark` needs: one texture per description, however
/// many widgets ask for it, and a different description is a
/// different texture.
#[test]
fn a_shared_texture_is_built_once_and_handed_out_again() {
let mut textures = Textures::new();
@@ -341,9 +302,6 @@ mod tests {
assert_ne!(first.image_index(), other.image_index());
}
/// The map's own reference is what keeps a shared slot alive: every
/// widget holding one can go away and the slot must not be recycled,
/// because the next widget to ask gets that same index back.
#[test]
fn a_shared_slot_is_not_freed_when_the_last_widget_drops_it() {
let mut textures = Textures::new();
@@ -357,9 +315,6 @@ mod tests {
);
}
/// A new GPU device gets the same slot numbering back, so a handle a
/// widget has been holding all along still names its own texture --
/// the crash `reupload` replaced `reset` to fix.
#[test]
fn reupload_replays_every_slot_in_order_including_the_empty_ones() {
let mut textures = Textures::new();
@@ -373,7 +328,6 @@ mod tests {
);
drop(dropped);
textures.free();
// Drain the updates so far, the way a frame does.
assert!(textures.updates().count() > 0);
textures.reupload();
-78
View File
@@ -1,14 +1,3 @@
//! A glyph atlas: one texture holding many rasterised glyphs, so drawing text
//! is a quad per glyph rather than a texture per string.
//!
//! What this replaces is why it exists. Text used to be rasterised into its own
//! `RgbaImage` and uploaded as a whole texture, per text widget, every time
//! anything about it changed -- so every window resize re-rasterised and
//! re-uploaded every visible string, which is what the TODO meant by "resizing
//! (per frame) is really slow". Here a glyph is rasterised once for a given
//! font, size and subpixel offset and then reused by every string that contains
//! it, and a resize re-emits quads without touching the GPU's copy at all.
use crate::{
PatchRect, TextureHandle, Textures, UiColor,
util::{HashMap, Vec2},
@@ -16,30 +5,16 @@ use crate::{
use image::RgbaImage;
use swash::scale::image::{Content, Image};
/// Side of one atlas page, in pixels. 1024 is 4 MB at RGBA8 -- enough for a
/// few thousand glyphs at UI sizes, and small enough that a page nobody fills
/// is not a big waste. Also the fixed width/height of every layer of the
/// shared array texture in `render::texture` -- `pub(crate)` so that module
/// can size it without a second constant to keep in sync.
pub(crate) const PAGE: u32 = 1024;
/// Transparent margin kept around every glyph, so that sampling one cannot
/// pick up its neighbour along a shared edge.
const PAD: u32 = 1;
/// Identifies a rasterised glyph. Anything that changes the pixels has to be in
/// here, or two different glyphs share one entry and the wrong one is drawn.
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct GlyphKey {
pub font: u64,
pub glyph: u32,
/// Font size in 1/16 px, so sizes that round to the same pixels share a
/// raster instead of filling the atlas with near-duplicates.
pub size: u32,
/// Horizontal subpixel phase, in 1/4 px.
pub subpixel: u8,
/// Hash of the variation coordinates; a variable font at two weights is two
/// different sets of pixels from one glyph id.
pub coords: u64,
}
@@ -47,13 +22,11 @@ pub struct GlyphKey {
pub struct GlyphEntry {
pub uv_min: [f32; 2],
pub uv_max: [f32; 2],
/// Offset from the glyph's pen position to the top-left of its pixels.
pub left: i32,
pub top: i32,
pub width: u32,
pub height: u32,
pub is_color: bool,
/// The atlas array layer this glyph's page occupies.
pub layer: u32,
}
@@ -71,12 +44,7 @@ struct Page {
#[derive(Default)]
pub struct GlyphAtlas {
pages: Vec<Page>,
/// Bumped by [`GlyphAtlas::clear`], so anything holding placed glyphs
/// from an earlier atlas can tell that its coordinates are stale --
/// see that method's doc for what goes wrong without it.
generation: u64,
/// `None` for a glyph that rasterised to nothing -- a space, say. Cached
/// too, so it is not re-rasterised on every layout.
entries: HashMap<GlyphKey, Option<GlyphEntry>>,
}
@@ -138,7 +106,6 @@ impl GlyphAtlas {
Some(entry)
}
/// A free `w`x`h` spot, opening a shelf or a page as needed.
fn allocate(&mut self, w: u32, h: u32, textures: &mut Textures) -> (usize, u32, u32) {
let need_w = w + PAD;
let need_h = h + PAD;
@@ -165,14 +132,10 @@ impl GlyphAtlas {
(self.pages.len() - 1, PAD, PAD)
}
/// Record that a glyph has no pixels, so it is not re-rasterised.
pub fn insert_empty(&mut self, key: GlyphKey) {
self.entries.insert(key, None);
}
/// Which atlas the entries handed out right now belong to. A
/// [`crate::RenderedText`] records this when it is built and is only
/// reusable while it still matches.
pub fn generation(&self) -> u64 {
self.generation
}
@@ -185,30 +148,6 @@ impl GlyphAtlas {
self.entries.len()
}
/// Forget every page and every rasterised entry -- what a genuinely new
/// GPU device needs (`android::view::IrisViewPeer::surface_changed`'s
/// "not already live" branch, e.g. after backgrounding): the pages this
/// atlas remembers are `TextureHandle`s into the *old* device's
/// textures, which no longer exist, and every `GlyphEntry`'s `uv_min`/
/// `uv_max`/`layer` point into them. Without this, a glyph already
/// cached here is treated as "already placed" and never re-inserted
/// into the fresh (empty) atlas the new renderer actually has --
/// exactly the "rectangles stay, glyphs disappear" bug the resize path
/// (`AndroidRenderer::resize`) was built to avoid for the reuse case;
/// this is its counterpart for the case where the renderer really is
/// new. Dropping `pages` also drops its `TextureHandle`s, which send a
/// free message back through their `Textures`; see `Textures::reset`'s
/// doc for why that is harmless here.
/// Bumping `generation` here is the other half of the same
/// invalidation: emptying this atlas does nothing about the
/// `RenderedText`s widgets are *already holding*
/// (`iris::widget::TextView`'s `tex` cache), whose `PlacedGlyph`s carry
/// `uv_min`/`uv_max`/`layer` into the atlas that has just been thrown
/// away. Those redraw perfectly happily and sample whatever now sits at
/// those coordinates -- the fragments-of-other-glyphs Iris photographed
/// after resuming the app on 2026-09-06. One counter, checked where the
/// cache is read, is what makes a cached render un-reusable across a
/// renderer rebuild.
pub fn clear(&mut self) {
self.pages.clear();
self.entries.clear();
@@ -217,15 +156,10 @@ impl GlyphAtlas {
}
fn fits(page: &Page, need_w: u32, need_h: u32) -> bool {
// On the current shelf, or on a new one above it.
(page.x + need_w <= PAGE && page.y + need_h <= PAGE)
|| (need_w + PAD <= PAGE && page.y + page.shelf_height + need_h <= PAGE)
}
/// Copy one rasterised glyph into the page image at `(x, y)`.
///
/// A mask glyph keeps its coverage in alpha with the colour left to the shader,
/// so one raster serves text of any colour; a colour glyph carries its own.
fn write_glyph(page: &mut RgbaImage, image: &Image, x: u32, y: u32) {
let w = image.placement.width;
let h = image.placement.height;
@@ -253,10 +187,6 @@ fn write_glyph(page: &mut RgbaImage, image: &Image, x: u32, y: u32) {
}
}
Content::SubpixelMask => {
// Not asked for: `Format::Alpha` is what the renderer requests, so
// reaching here means the request changed and this needs writing.
// Drawn as a plain mask from the green channel rather than dropped,
// so the text is readable rather than absent.
for row in 0..h {
for col in 0..w {
let i = ((row * w + col) * 4) as usize;
@@ -268,14 +198,6 @@ fn write_glyph(page: &mut RgbaImage, image: &Image, x: u32, y: u32) {
}
}
/// Where a glyph goes on screen, in pixels relative to the text's origin.
///
/// `color` is per-glyph (read from the parley run's own `Brush`, since
/// `UiColor` is parley's brush type here) rather than a single colour for
/// the whole `RenderedText`, so that a span pushed with its own
/// `StyleProperty::Brush` (I5's inline rich text: a link, a diff of colour
/// inside one wrapped paragraph) actually renders in that colour instead of
/// the buffer's base one.
#[derive(Clone, Copy)]
pub struct PlacedGlyph {
pub entry: GlyphEntry,
-54
View File
@@ -8,15 +8,6 @@ pub struct WindowUniform {
pub height: f32,
}
/// One primitive's placement and what to draw there, in the one arena
/// every layer shares (`Primitives`). Read from a storage buffer by
/// **both** shader stages: the vertex stage for the corners of the
/// primitive it is drawing, the fragment stage for the corners of a
/// *mask's* primitive, which is generally a different one and often in
/// another layer. A layer's vertex buffer carries only the slot
/// ([`instance_slot_layout`]), so there is exactly one copy of a
/// placement and a mask cannot disagree with what was drawn. See
/// LAYOUT.md's "Masks with a shape".
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct PrimitiveInstance {
@@ -27,11 +18,6 @@ pub struct PrimitiveInstance {
pub move_idx: MoveIdx,
}
/// The vertex layout of a layer's draw order: one `u32` slot into the
/// global instance arena per instance, stepped per instance. Everything a
/// primitive is made of used to be here as eight vertex attributes; it
/// moved into the storage buffer above so the fragment stage can read it
/// too.
pub fn instance_slot_layout() -> VertexBufferLayout<'static> {
const ATTRIBS: [VertexAttribute; 1] = vertex_attr_array![0 => Uint32];
VertexBufferLayout {
@@ -49,38 +35,9 @@ impl MaskIdx {
pub type MoveIdx = Id<u32>;
/// A clip, as a reference to a primitive already written plus the mask it
/// nests inside. The fragment stage evaluates that primitive's coverage
/// *at the masked pixel* -- for a rect, the same `rounded_rect_coverage`
/// from the same SDF the rect itself is drawn with -- and multiplies it
/// into the pixel's alpha, so a rounded container's corner and its
/// children's clipped corner are the same arithmetic and cannot disagree.
/// See LAYOUT.md's "Masks with a shape".
///
/// **No `kind` and no `flags`**, which the design sketched: the referenced
/// instance already carries its own `binding`, and a copy of it here is a
/// second thing to keep in step; alpha-only is the only mode there is, so
/// there is nothing to select. Both are a field away if a second mode
/// appears.
#[repr(C)]
#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct Mask {
/// The slot in `UiRenderState::primitives` of the primitive whose
/// coverage this mask is. Today always a `RectPrimitive`: a glyph or
/// a standalone image would need, respectively, a CPU-side alpha
/// plane for the hit test to agree with the shader, and a bind-group
/// switch the fragment stage cannot make -- `Painter::set_mask`
/// rejects both by name rather than leaving the shader to read a rect
/// that is not there.
///
/// Who owns it depends on which way the mask was set. A plain
/// `.masked()` writes its own undrawn rect, so the primitive is in
/// the masking widget's `ActiveData::primitives` and lives exactly as
/// long as the mask. `.masked_by(shape)` points at a *child's*
/// primitive, which that child can free on any redraw of its own --
/// so `UiRenderState::remask_shape_users` marks the mask's owner for
/// redraw whenever a referenced slot is freed, since that widget's
/// own `set_mask` is the only thing that resolves the slot again.
pub primitive: u32,
/// The mask this one was set *inside* (`MaskIdx::NONE` at the top), so
/// clipping nests: the fragment stage walks the chain and multiplies
@@ -90,20 +47,9 @@ pub struct Mask {
/// fence inside a transcript row carries the row's scroll, the list's
/// own box does not, and one region resolved when the fence was last
/// drawn gets the second of those wrong as soon as the row moves.
///
/// A child holds one ref on its parent's slot (`Painter::set_mask`),
/// released when the child's own slot goes
/// (`UiRenderState::remove`), so the chain cannot outlive what it
/// points at.
pub parent: MaskIdx,
}
/// One widget's cumulative on-screen translation, and the slot of the
/// ancestor to add on top of it. `parent == u32::MAX` ends the chain. A
/// pure abs-pixel delta, not a general `UiRegion` remap -- sufficient for
/// every call site that moves a widget (`ScrollArea`, `Offset`) since both are
/// translations of an already-drawn subtree. See LAYOUT.md section 2.
///
/// `_pad` matches WGSL's storage-buffer layout for `MoveOffset`: `delta` is
/// a `vec2<f32>`, which gives the struct an 8-byte alignment and rounds its
/// WGSL size up to 16 bytes even though `delta` + `parent` only total 12 --
-247
View File
@@ -9,58 +9,20 @@ use std::time::{Duration, Instant};
/// "late" that merely met its own, faster budget.
pub const JANK_THRESHOLD: Duration = Duration::from_nanos(16_666_667);
/// Enough frames for several minutes of scrolling before the oldest ones
/// start being overwritten -- the same "diagnostic, not a log" sizing
/// `FrameStats.kt`'s `CAP` uses on the Compose side, chosen independently
/// here since a `Duration` is smaller than the six `Long` arrays it keeps.
/// Bumped from 4096 for RUST.md's "Benchmark v2": a fling+stream+type+
/// keyboard run is ~6,500+ frames on the Compose side, comfortably under
/// this so `phase_stats` never has to report a phase as partially evicted.
const RING_CAPACITY: usize = 16384;
/// How many measurable frame-to-frame gaps [`FrameReport::
/// sustained_frame_hz`] needs before it will answer at all. A tenth of a
/// second's worth at any plausible rate -- enough for a rate to mean
/// something, and little enough that any real phase has it.
const MIN_CADENCE_SAMPLES: usize = 12;
/// One `mark_phase` call: the wall-clock instant and the (0-based,
/// never-reset-by-`reset`-except-at-`reset`-time) absolute frame index at
/// which a phase began -- `phase_stats` slices `index_ring` against this to
/// find which recorded samples belong to which phase, since the ring
/// itself only keeps the most recent `RING_CAPACITY` samples' *values*,
/// not which phase they were in.
struct PhaseMark {
name: String,
start_index: u64,
start_at: Instant,
}
/// One phase's own slice of a report -- RUST.md's "Benchmark v2" spec's
/// "per-phase blocks in `FrameReport`... frames, late count/percent...
/// p50/p90/p99, worst, duration". `Display` matches the shape
/// `docs/bench/compose-phone-v2-2026-09-06.md`'s report already uses, so
/// the two apps' reports read the same way side by side.
pub struct PhaseStats {
pub name: String,
/// How many frames were recorded during this phase in total -- may
/// exceed `late + (samples counted)` if some of this phase's frames
/// have since been evicted from the ring by a very long run; that
/// case is named in the `Display` rather than silently under-counted.
pub frames: u64,
pub duration: Duration,
/// Frames whose **work** exceeded the budget -- `total` minus the
/// swapchain wait, since a frame held back by the display was ready
/// on time and the display was not.
///
/// Judging the total instead is what this did until 2026-09-09, and
/// it does not survive the app being *well* paced: a loop that draws
/// in 0.4ms and then waits its turn measures one whole refresh period
/// per frame, so every frame sits exactly on the budget and `late`
/// becomes a coin toss on noise. See [`Self::missed`] for the
/// question "did a frame fail to arrive", which is the one a reader
/// actually sees.
///
/// On a backend that blocks in `present()` rather than in the
/// acquire -- GLES, and so this repo's emulator -- the wait lands in
/// `submit` instead and this over-counts. Named rather than
@@ -81,25 +43,10 @@ pub struct PhaseStats {
/// say that.
/// Vsyncs that went by with no frame produced for them, counted from
/// the gap between consecutive frames rather than from their cost.
///
/// **`late` and this are different questions and the second is the
/// one a reader sees.** A frame can be over budget and still be shown
/// on the next vsync; a frame that is never produced leaves the
/// previous one on screen for two refreshes, which is the stutter.
/// Nothing in a report could say this before 2026-09-09 -- the two
/// were folded together under `late`, so "we drew every frame, some
/// slowly" and "we skipped 1 frame in 8" read identically.
///
/// Zero on the first frame of a run, whose gap is unknowable.
pub missed: u64,
pub build_p50: Duration,
pub acquire_p50: Duration,
pub submit_p50: Duration,
/// `false` if this phase's frame count exceeds how many samples of it
/// are still in the ring -- the percentiles above are then computed
/// over whatever survived, not the whole phase. UI_RULES.md: this is
/// the "we don't fully know" state, named rather than folded silently
/// into a number that looks exact.
pub complete: bool,
}
@@ -142,29 +89,10 @@ impl std::fmt::Display for PhaseStats {
/// The parts one frame's wall time divides into, measured rather than
/// inferred: what a caller hands [`FrameReport::record`].
///
/// The three are consecutive and together they are `total`, so whatever
/// is left after `acquire` and `submit` is the frame's own work -- laying
/// out, shaping text, building primitives and recording the render pass.
/// That leftover is what a report calls `build`.
///
/// **`acquire` is the one that is not work.** It is the wait inside
/// `Surface::get_current_texture` for a swapchain image to come free,
/// which is the display pacing the app: an app that draws faster than the
/// screen refreshes spends *most* of every frame there, and that is the
/// healthy state rather than a slow one. It was inside the CPU half until
/// 2026-09-09, which made a fling's frames read as several milliseconds
/// of iris being slow when they were milliseconds of iris waiting its
/// turn -- UI_RULES.md's rule against presenting an inferred value as a
/// measured one, arriving in a diagnostic.
#[derive(Clone, Copy, Default, Debug)]
pub struct FrameParts {
/// Redraw start to after `present()` was called -- the span the whole
/// report is about.
pub total: Duration,
/// The wait for a swapchain image (`get_current_texture`).
pub acquire: Duration,
/// `queue.submit` plus `present()`.
pub submit: Duration,
}
@@ -201,24 +129,11 @@ impl FrameParts {
.saturating_sub(self.submit)
}
/// Everything that was not waiting for the display's permission to
/// draw -- `build` plus `submit`. What a frame had to finish before
/// it could be shown, and so what a budget is meaningfully compared
/// against; see [`PhaseStats::late`].
pub fn work(&self) -> Duration {
self.total.saturating_sub(self.acquire)
}
}
/// A per-frame wall-time report iris keeps of itself, because `dumpsys
/// gfxinfo` cannot see a `SurfaceView`'s own GPU-drawn frames at all
/// (RUST.md's I5 box, "Measurements taken" (b)): it instruments Android's
/// ordinary Skia/HWUI View-drawing pipeline, which a `wgpu`-rendered
/// `SurfaceView` bypasses entirely. `record` is meant to be called once per
/// frame, wrapping the same span Compose's own render report and `gfxinfo`
/// count -- from the frame's redraw/update start to after the frame is
/// handed to the platform to present.
///
/// **What this does not measure**: wgpu's `present()` call queues the frame
/// with the compositor and returns; it is not fenced against the GPU
/// actually finishing the frame or the compositor actually showing it, the
@@ -228,17 +143,8 @@ impl FrameParts {
/// [`FrameStats`]'s own `Display` line rather than presented as the latter,
/// per the standing rule against showing an inferred number as a measured
/// one where the two differ.
///
/// Fixed-size ring, no allocation on the hot path -- `report()` is the only
/// place that allocates (a sort over the current ring), and it is only
/// ever called from a button tap, not once per frame.
pub struct FrameReport {
ring: Box<[Duration; RING_CAPACITY]>,
/// The `submit_to_present` half of each sample in `ring`, same index,
/// same lifetime -- kept as a second ring rather than a ring of pairs so
/// the existing `ring`/percentile code above is untouched (RUST.md's I5
/// "Where iris's frame time goes" CPU/GPU split, added 2026-09-05).
/// See [`FrameParts`] for how the three rings divide a frame up.
submit_ring: Box<[Duration; RING_CAPACITY]>,
/// The `acquire` half of each sample in `ring`, same index, same
/// lifetime -- see [`FrameParts::acquire`], which is the part that is
@@ -247,47 +153,16 @@ pub struct FrameReport {
/// How long before each sample the *previous* frame was, same index,
/// same lifetime -- the frame's own cadence rather than its cost. See
/// [`PhaseStats::missed`] for why a report needs both.
///
/// **`Duration::ZERO` means "no cadence information", not "no gap".**
/// Two frames say nothing about the display's rhythm unless the app
/// was actually trying to draw between them: the first frame after a
/// `reset` has nothing before it, and a frame that follows an *idle*
/// one is separated by however long nobody wanted anything drawn.
/// Counting those was this counter's first version, and it reported
/// a bench's own deliberate pauses as stutter -- 276 "missed" frames
/// for sixteen 300ms rests between flings, and 2410 for twelve
/// hundred 50ms gaps between keystrokes (Iris's phone, 2026-09-09).
gap_ring: Box<[Duration; RING_CAPACITY]>,
/// When the last recorded frame was and whether it had asked for
/// another -- `None` until the first frame since a `reset`. The flag
/// is what makes the next frame's gap a measurement rather than a
/// record of how long the app sat idle.
last_frame: Option<(Instant, bool)>,
/// The absolute (0-based, since the last `reset`) frame index each
/// `ring`/`submit_ring` slot's sample belongs to -- what `phase_stats`
/// slices against `PhaseMark::start_index` to tell which recorded
/// frames fall in which phase.
index_ring: Box<[u64; RING_CAPACITY]>,
/// How many of `ring`'s slots hold a real sample -- saturates at
/// `RING_CAPACITY`, unlike `total_frames` below which keeps counting.
len: usize,
pos: usize,
/// All frames recorded since the last `reset`, even past `RING_CAPACITY`
/// -- what `janky_percent` divides by, so a long run's percentage stays
/// correct even once the ring itself only holds the most recent frames.
total_frames: u64,
janky_frames: u64,
/// `mark_phase` calls since the last `reset`, oldest first -- see
/// `phase_stats`. Empty on an ordinary run that never calls
/// `mark_phase`, so `phase_stats` returns an empty `Vec` and a caller
/// prints no "per phase:" section at all, matching RUST.md's "empty/
/// absent on an ordinary 'Copy' press, which never marks a phase."
phases: Vec<PhaseMark>,
}
/// One resolved reading. `Display` is the log line both the "Frame report"
/// button and `transcript-bench.sh`-style scripts read, grep-able on
/// `"iris frame report"`.
pub struct FrameStats {
pub total_frames: u64,
pub janky_percent: f64,
@@ -295,25 +170,8 @@ pub struct FrameStats {
pub p90: Duration,
pub p99: Duration,
pub worst: Duration,
/// Median of [`FrameParts::build`] -- iris's own CPU work per frame:
/// laying out, shaping text, building primitives and recording the
/// render pass. RUST.md's I5 "Where iris's frame time goes" split,
/// added 2026-09-05 to answer "CPU or GPU?" with a number rather than
/// a guess, and corrected on 2026-09-09 to stop counting the
/// swapchain wait below as iris's own work.
pub cpu_p50: Duration,
/// Median of [`FrameParts::acquire`]: the wait for a swapchain image.
/// **Not work** -- see that field's doc. A large number here beside a
/// small `cpu_p50` is an app comfortably ahead of the display, which
/// is what it should look like.
pub acquire_p50: Duration,
/// Median of `submit_to_present` -- the `queue.submit` call itself plus
/// `present()`, i.e. wherever the driver/GPU/compositor wait actually
/// happens. Same caveat as the type's own doc: `present()` is not
/// fenced against the GPU actually finishing, so this is "how long the
/// CPU was blocked handing the frame off", not the frame's true GPU
/// time -- still enough to separate "iris is slow building the frame"
/// from "iris is slow handing it to the driver".
pub gpu_wait_p50: Duration,
}
@@ -359,8 +217,6 @@ impl FrameReport {
}
}
/// Record one frame, split into [`FrameParts`]. O(1), no allocation.
///
/// One entry point rather than one per shape of measurement: a caller
/// with nothing but a total passes `FrameParts::whole(total)`, which
/// says so in the type instead of leaving the report to guess from a
@@ -368,8 +224,6 @@ impl FrameReport {
pub fn record(&mut self, at: Instant, parts: FrameParts, animating: bool) {
self.gap_ring[self.pos] = match self.last_frame {
Some((last, true)) => at.saturating_duration_since(last),
// Nothing was moving, so the distance to this frame is idle
// time rather than cadence -- see `gap_ring`'s own doc.
Some((_, false)) | None => Duration::ZERO,
};
self.last_frame = Some((at, animating));
@@ -385,12 +239,6 @@ impl FrameReport {
}
}
/// Clears every counter and every sample -- what the "Reset frame
/// report" control calls, so a report covers only what was scrolled
/// after the button was pressed (the same reason `FrameStats.kt`'s
/// `reset()` exists on the Compose side). Also clears every phase
/// mark, so a fresh run starts with no "per phase:" section until it
/// marks one of its own.
pub fn reset(&mut self) {
self.len = 0;
self.pos = 0;
@@ -400,8 +248,6 @@ impl FrameReport {
self.phases.clear();
}
/// One recorded slot's three parts, back as the type they were
/// recorded in.
fn parts(&self, slot: usize) -> FrameParts {
FrameParts {
total: self.ring[slot],
@@ -410,16 +256,7 @@ impl FrameReport {
}
}
/// Marks the start of a named phase at the current moment -- every
/// frame recorded from here until the next `mark_phase` (or `reset`)
/// belongs to it. RUST.md's "Benchmark v2": a scripted bench run calls
/// this once per phase (fling/stream/type/keyboard) so `phase_stats`
/// can slice one whole run's frames by what was happening during each.
pub fn mark_phase(&mut self, name: &str) {
// `phase_stats`'s slicing (`idx >= phase.start_index && idx <
// end_index`) silently produces an empty or nonsensical slice for
// a phase pushed out of order rather than surfacing the misuse
// (review, 2026-09-06).
debug_assert!(
self.phases
.last()
@@ -432,12 +269,6 @@ impl FrameReport {
});
}
/// One [`PhaseStats`] per `mark_phase` call since the last `reset`,
/// oldest first. `now` closes the last phase's wall-clock span (there
/// is no "next phase" instant to use for it); `refresh_hz` is what
/// each phase's own `late`/`late_percent` is judged against, read from
/// the display rather than assumed -- RUST.md's "Benchmark v2": "late
/// count/% against the display's refresh rate."
pub fn phase_stats(&self, now: Instant, refresh_hz: f32) -> Vec<PhaseStats> {
if self.phases.is_empty() || refresh_hz <= 0.0 {
return Vec::new();
@@ -478,18 +309,11 @@ impl FrameReport {
complete,
};
}
// Each part gets its own sort: medians do not distribute
// over subtraction, so `build`'s median is not `total`'s
// minus the other two.
let part_p50 = |part: &dyn Fn(usize) -> Duration| {
let mut v: Vec<Duration> = slots.iter().map(|&j| part(j)).collect();
v.sort_unstable();
v[v.len() / 2]
};
// A gap of more than one and a half budgets means at
// least one vsync came and went unanswered; the count is
// how many, so a frame arriving three periods late says 2.
//
// **The phase's own first frame is skipped**: its gap
// reaches back into the previous phase, across whatever
// the run did between the two -- a bench pausing a second
@@ -532,10 +356,6 @@ impl FrameReport {
.collect()
}
/// The rate frames were actually **sustained** at, in Hz, over the
/// stretches where the app was animating -- measurable gaps divided
/// into their own total, so idle time is excluded by construction.
///
/// **This is a floor on the display's refresh rate, never a reading
/// of it.** You cannot observe a cadence faster than you draw, so an
/// app that never keeps up says nothing about the panel; a caller
@@ -571,9 +391,6 @@ impl FrameReport {
samples.sort_unstable();
let pct = |p: usize| samples[(samples.len() * p / 100).min(samples.len() - 1)];
// Separate arrays rather than subtracting the two medians above:
// medians do not distribute over subtraction, and each needs its
// own sort.
let submit_samples: Vec<Duration> = self.submit_ring[..self.len].to_vec();
let acquire_samples: Vec<Duration> = self.acquire_ring[..self.len].to_vec();
let cpu_samples: Vec<Duration> = (0..self.len).map(|j| self.parts(j).build()).collect();
@@ -595,24 +412,11 @@ impl FrameReport {
})
}
/// `(late count, late percent)` over every sample still in the ring,
/// judged against `refresh_hz`'s own frame budget rather than the
/// fixed 60Hz `JANK_THRESHOLD` -- RUST.md's "Benchmark v2": "late
/// count/% against the display's refresh rate... print 'at N Hz (X ms
/// budget)' like Compose does." A separate method from `report()`
/// rather than a parameter on it, so `report()`'s own `janky_percent`
/// (and the exact-boundary test pinned to `JANK_THRESHOLD`) is
/// unaffected for every existing caller that never measured a real
/// refresh rate. `(0, 0.0)` with nothing recorded or a non-positive
/// `refresh_hz`.
pub fn late_at_hz(&self, refresh_hz: f32) -> (u64, f64) {
if self.len == 0 || refresh_hz <= 0.0 {
return (0, 0.0);
}
let budget = Duration::from_secs_f64(1.0 / refresh_hz as f64);
// The frame's work, not its total -- the same rule and the same
// reason as `PhaseStats::late`, which this is the run-wide half
// of.
let late = (0..self.len)
.filter(|&j| self.parts(j).work() > budget)
.count() as u64;
@@ -654,8 +458,6 @@ mod tests {
#[test]
fn percentiles_and_worst_over_a_known_set() {
let mut r = FrameReport::new();
// 100 samples, 1ms..=100ms, fed out of order so the ring's own
// order is not what gives the right answer -- the sort has to.
for ms in (1..=100).rev() {
r.record(
Instant::now(),
@@ -690,8 +492,6 @@ mod tests {
#[test]
fn janky_percent_is_over_all_time_frames_not_just_the_ring() {
// Fewer than RING_CAPACITY frames, all janky, then a fresh reset --
// the percentage must reset to 0, not divide by a stale count.
let mut r = FrameReport::new();
for _ in 0..10 {
r.record(
@@ -713,9 +513,6 @@ mod tests {
#[test]
fn record_without_a_split_reports_the_whole_frame_as_cpu() {
// A caller that never measured the split (plain `record`) should
// not fabricate a GPU-wait number -- it reads as zero, and the CPU
// half reads as the whole frame.
let mut r = FrameReport::new();
r.record(
Instant::now(),
@@ -730,10 +527,6 @@ mod tests {
#[test]
fn each_part_reports_its_own_median_and_build_excludes_the_wait() {
let mut r = FrameReport::new();
// Three frames of the same 30ms total, with the split moving:
// each part needs its own sort, and `build` is what is left after
// both waits -- not the total, which is the bug this replaced
// (the swapchain wait used to be counted as iris's own work).
for (acquire, submit) in [(5, 2), (10, 3), (20, 4)] {
r.record(
Instant::now(),
@@ -749,21 +542,15 @@ mod tests {
assert_eq!(stats.p50, Duration::from_millis(30));
assert_eq!(stats.acquire_p50, Duration::from_millis(10));
assert_eq!(stats.gpu_wait_p50, Duration::from_millis(3));
// 30-5-2=23, 30-10-3=17, 30-20-4=6 -> median 17.
assert_eq!(stats.cpu_p50, Duration::from_millis(17));
}
#[test]
fn a_gap_of_more_than_one_vsync_is_counted_as_a_missed_frame() {
// Cost and cadence are separate questions: every frame here is
// well inside its budget, so `late` is zero, and the run still
// skipped three vsyncs -- which is what a reader sees as a
// stutter and what nothing in a report could say before.
let mut r = FrameReport::new();
let base = Instant::now();
let budget = Duration::from_nanos(16_666_667);
r.mark_phase("fling");
// Frames at 0, 1, 2, 4 (one skipped), 5, 8 (two skipped) budgets.
for step in [0u32, 1, 2, 4, 5, 8] {
r.record(
base + budget * step,
@@ -778,18 +565,10 @@ mod tests {
#[test]
fn an_idle_gap_is_not_a_missed_frame() {
// What the first version of this counter got wrong on Iris's
// phone: a bench rests 300ms between flings and types one
// character per 50ms, and every one of those gaps was reported as
// stutter (276 and 2410 "missed" frames, which is exactly the
// rests). A frame that did not ask for another one is idle, and
// the distance to whatever comes next says nothing.
let mut r = FrameReport::new();
let base = Instant::now();
let budget = Duration::from_nanos(16_666_667);
r.mark_phase("fling");
// Two frames of real animation, then one that stops animating,
// then a long rest before the next burst.
r.record(base, FrameParts::whole(Duration::ZERO), true);
r.record(base + budget, FrameParts::whole(Duration::ZERO), true);
r.record(base + budget * 2, FrameParts::whole(Duration::ZERO), false);
@@ -807,9 +586,6 @@ mod tests {
#[test]
fn a_sustained_120hz_run_measures_120_whatever_the_platform_says() {
// Iris's phone, 2026-09-09: the display reported 60Hz for a run
// that drew at 120, so every phase was judged against twice the
// budget it should have been.
let mut r = FrameReport::new();
let base = Instant::now();
let period = Duration::from_nanos(8_333_333);
@@ -826,19 +602,10 @@ mod tests {
#[test]
fn an_app_that_cannot_keep_up_does_not_claim_a_faster_display() {
// The other direction, and the one the first version of this got
// wrong: this repo's emulator draws about 51fps on a 60Hz
// display, and taking the fastest tenth of the gaps reported
// 88Hz -- a budget no frame there could meet, invented out of the
// app's best moments. A sustained rate cannot do that, which is
// what makes a caller's `max` against the platform's own answer
// safe in both directions.
let mut r = FrameReport::new();
let base = Instant::now();
let mut at = base;
for step in 0..120u32 {
// Mostly slow with an occasional quick pair -- the shape that
// fooled the percentile.
at += if step % 10 == 0 {
Duration::from_millis(8)
} else {
@@ -855,10 +622,6 @@ mod tests {
#[test]
fn a_frame_held_back_by_the_display_is_not_late() {
// The signature of a well-paced loop: 0.4ms of work and the rest
// of the refresh period spent waiting its turn. Judging the total
// calls every one of those frames late; judging the work calls
// none of them late, which is what they are.
let mut r = FrameReport::new();
let base = Instant::now();
let period = Duration::from_nanos(8_333_333);
@@ -881,10 +644,6 @@ mod tests {
#[test]
fn a_phase_does_not_inherit_the_pause_before_it() {
// The half the fix above had no reason to touch: a bench rests
// between phases, and that rest reaches the next phase's first
// frame as its gap. Charging it there would open every phase with
// a large invented `missed`.
let mut r = FrameReport::new();
let base = Instant::now();
let budget = Duration::from_nanos(16_666_667);
@@ -896,7 +655,6 @@ mod tests {
true,
);
}
// A second of rest, then the next phase starts clean.
let after = base + Duration::from_secs(1);
r.mark_phase("type");
for step in [0u32, 1, 2] {
@@ -925,11 +683,7 @@ mod tests {
);
}
let stats = r.report().unwrap();
// total_frames keeps the full count even once the ring has wrapped.
assert_eq!(stats.total_frames, (RING_CAPACITY * 2) as u64);
// but every sample the ring can report on is still one of the five
// values fed in, since a wrap can only overwrite with more of the
// same pattern here.
assert!(stats.worst <= Duration::from_millis(5));
}
@@ -1011,7 +765,6 @@ mod tests {
#[test]
fn late_at_hz_uses_the_given_refresh_rate_not_the_fixed_60hz_constant() {
let mut r = FrameReport::new();
// 10ms is under 60Hz's 16.7ms budget but over 120Hz's 8.3ms one.
r.record(
Instant::now(),
FrameParts::whole(Duration::from_millis(10)),
-137
View File
@@ -28,42 +28,8 @@ pub use frame_report::{FrameParts, FrameReport, FrameStats, JANK_THRESHOLD};
pub use primitive::*;
pub use sdf::{distance_from_rect, rounded_rect_coverage};
/// The one shader every primitive is drawn with. Public so a test can run
/// a function out of it against the CPU transliteration in [`sdf`] --
/// `iris/tests/mask_sdf.rs`, which LAYOUT.md's "Masks with a shape" turns
/// on: a masked corner that cannot be tapped and a masked corner that is
/// not drawn are only the same corner while the two agree.
pub const SHAPE_SHADER: &str = include_str!("./shader.wgsl");
/// The `wgpu::Limits` both platform backends (`android::render::
/// AndroidRenderer::new`, `default::render::UiRenderer::new`) ask
/// `Adapter::request_device` for -- shared so the two copies cannot drift,
/// per AGENTS.md's "write the logic once."
///
/// Built from `Limits::default()`, **not** a downlevel variant: the shader
/// (`shader.wgsl`) reads four `var<storage>` buffers (rects, glyphs, masks,
/// move_offsets) from the vertex stage, and `Limits::downlevel_webgl2_defaults()`
/// zeroes `max_storage_buffers_per_shader_stage` along with the compute
/// limits below -- switching to it would trade one `request_device` crash
/// for a bind-group-layout one on the same downlevel hardware this is meant
/// to support. `max_buffer_size` is raised for the growing instance/atlas
/// buffers (`ArrBuf`, `GpuTextures`); everything else is `default()`'s
/// desktop-tier value, unchanged.
///
/// The six `max_compute_*` fields are zeroed because nothing in this crate
/// creates a `ComputePipeline` or writes a `@compute` shader stage --
/// grepped for both across `iris`/`iris-core` before writing this, found
/// none. `Limits::default()` requests desktop-tier compute limits
/// unconditionally (`max_compute_workgroups_per_dimension: 65535`) even
/// though nothing asks a device to actually support compute, which is what
/// crashed `request_device` on the Android emulator's software GL path
/// (`EMU_GPU=software`, `force-gles`): SwiftShader's GL reports itself as
/// OpenGL ES 3.0, which has no compute shaders, so the adapter's real limit
/// is 0 and the unconditional request fails outright
/// (`RUST.md`'s "Software mode ... crashes for a third, different reason").
/// The same would happen on a real GLES-3.0-only Android device. If a
/// future change adds a compute pass, request the specific limits it needs
/// here rather than reverting to the desktop-tier default for everything.
pub fn device_limits() -> Limits {
Limits {
max_buffer_size: 1 << 30,
@@ -77,31 +43,11 @@ pub fn device_limits() -> Limits {
}
}
/// A capped log of wgpu's *uncaptured* errors -- everything that reaches
/// `Device::on_uncaptured_error` rather than one of `UiRenderNode::new`'s
/// own error scopes, i.e. every wgpu error raised outside device/pipeline
/// creation: a validation failure during an ordinary frame's `update`/
/// `draw`, for instance. wgpu's default handler for these is `panic!` with
/// no caller able to intervene -- exactly what aborted the P0 bench APK
/// once already (this file's `UiRenderNode::new` doc comment) -- so both
/// platform backends install a handler here instead of leaving the default
/// in place, per RUST.md's P0 box ("every wgpu uncaptured error ... it
/// must never panic in release").
///
/// Cheap to `Clone` (an `Arc` around the real storage) rather than a
/// process-wide static, so a caller builds one alongside its `Device`,
/// hands one clone to `on_uncaptured_error`'s closure and keeps the other
/// for the Diagnostics page to read -- context passed explicitly, per
/// AGENTS.md/CODE_RULES.md's "no globals" rather than reached for through a
/// `OnceLock`.
#[derive(Clone)]
pub struct WgpuErrorLog {
errors: std::sync::Arc<std::sync::Mutex<std::collections::VecDeque<String>>>,
}
/// How many uncaptured errors the log keeps -- old ones drop off the front
/// rather than being trimmed on read, so a build spraying errors every
/// frame doesn't grow this without bound.
const WGPU_ERROR_LOG_CAP: usize = 20;
impl Default for WgpuErrorLog {
@@ -131,9 +77,6 @@ impl WgpuErrorLog {
pub struct UiRenderNode {
uniform_group: BindGroup,
primitive_layout: BindGroupLayout,
/// Group 1: `rects` and `glyphs`. Global and bound once per frame,
/// not per layer -- a mask referencing a rect drawn in another layer
/// has to be able to read it (see `Primitives`).
primitives: PrimitiveBuffers,
primitive_group: BindGroup,
rsc_layout: BindGroupLayout,
@@ -145,28 +88,13 @@ pub struct UiRenderNode {
active: Vec<usize>,
window_buffer: Buffer,
textures: GpuTextures,
/// Every primitive's placement, read by the vertex stage for the
/// primitive being drawn and by the fragment stage for a mask's.
instances: ArrBuf<PrimitiveInstance>,
masks: ArrBuf<Mask>,
move_offsets: ArrBuf<MoveOffset>,
/// Group 3: the masks and move-offsets storage buffers, on their own --
/// see IRIS_TODO.md's "Appending one image ... rebuilds every other
/// image's bind group". These used to live in group 2 alongside each
/// standalone image's own texture view, so an image's bind group named
/// the masks/move_offsets buffer directly; the moment either buffer
/// resized (which a widget getting its *first* move slot can trigger,
/// unrelated to any image), `ArrBuf::update` handed back a new `Buffer`
/// identity and every image's bind group -- one per live image -- had
/// to be rebuilt to reference it. Pulling both buffers into their own
/// group, bound once per frame rather than once per draw call, means a
/// buffer resize now rebuilds exactly this one group instead of N.
masks_layout: BindGroupLayout,
masks_group: BindGroup,
}
/// One layer's vertex buffers: the slots it draws, in order. The
/// primitives themselves are in `UiRenderNode::instances`.
struct RenderLayer {
order: ArrBuf<u32>,
/// A standalone image's slots, kept apart from `order` because each
@@ -182,14 +110,7 @@ impl UiRenderNode {
pub fn draw<'a>(&'a self, pass: &mut RenderPass<'a>) {
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.uniform_group, &[]);
// Group 1 is global now, so it is set here rather than per layer.
pass.set_bind_group(1, &self.primitive_group, &[]);
// Set once, not per layer or per image: masks/move_offsets are read
// by every primitive and every standalone image alike, and living
// in their own group (rather than folded into group 2 alongside the
// per-image texture view) is what keeps an image's own bind group
// from naming a buffer that changes size on an unrelated widget's
// first draw -- see the comment on `masks_group` below.
pass.set_bind_group(3, &self.masks_group, &[]);
for i in &self.active {
let layer = &self.layers[i];
@@ -307,31 +228,12 @@ impl UiRenderNode {
queue.write_buffer(&self.window_buffer, 0, bytemuck::cast_slice(slice));
}
/// Builds every bind group layout, the pipeline, and the two storage
/// buffers this needs -- fallibly, since this is exactly the call that
/// aborted the process on Iris's phone in a release build with no
/// message beyond "wgpu error: Validation Error" (RUST.md's P0 box,
/// "iris bench crash on the phone, 2026-09-06"). wgpu's own default
/// behaviour for an uncaptured error is `panic!` with no caller able to
/// intervene, so every `create_bind_group_layout`/`create_render_pipeline`
/// call below runs inside three nested error scopes (one per
/// `ErrorFilter`) instead: whichever scope catches something, its
/// `wgpu::Error`'s `Display` is wgpu-core's own `format_error` output
/// (`"Validation Error\n\nCaused by:\n ..."`, the same text the panic
/// would have printed before Android's crash reporter truncated it) and
/// becomes this function's `Err`. Both callers
/// (`android::render::AndroidRenderer::new`, `default::render::
/// UiRenderer::new`) already call `Device`-creation with
/// `pollster::block_on`, so returning a plain `Result` here rather than
/// making this `async fn` keeps that same synchronous shape.
pub fn new(
device: &Device,
queue: &Queue,
config: &SurfaceConfiguration,
window_size: impl Into<Vec2>,
) -> Result<Self, String> {
// Popped in reverse of this order, once every creation call below
// has run -- `Device::push_error_scope`'s own contract.
let oom_scope = device.push_error_scope(ErrorFilter::OutOfMemory);
let validation_scope = device.push_error_scope(ErrorFilter::Validation);
let internal_scope = device.push_error_scope(ErrorFilter::Internal);
@@ -341,29 +243,6 @@ impl UiRenderNode {
source: ShaderSource::Wgsl(SHAPE_SHADER.into()),
});
// Seeded from the caller's own reported size, not
// `WindowUniform::default()` (0, 0): the vertex shader divides by
// `window.dim` to reach clip space, so a window this buffer
// disagrees with means every primitive's position is NaN/Inf and is
// dropped before rasterization -- the clear colour still reaches
// the screen (the pass runs regardless) while nothing drawn on top
// of it ever does. winit's backend gets away with the old default
// because winit fires an initial `WindowEvent::Resized` that calls
// `resize()` before the first frame; android-view has no such
// automatic event, so `AndroidRenderer::new` built a node whose
// window buffer was never corrected -- this is I2's "nothing draws"
// bug (RUST.md).
//
// **Deliberately not `config.width`/`config.height`**: those are
// the surface's *physical* pixel size, which the swapchain needs,
// but everything downstream of this uniform (layout, hit-testing,
// glyph/rect positions) works in the caller's own units -- on
// Android that's *logical* (physical / density) since RUST.md's P0
// box ("text is far too small"), on desktop it's whatever
// `default::render::UiRenderer::new` already divides by
// `window.scale_factor()`. Passing it in explicitly, rather than
// deriving it from `config` here, is what keeps this crate from
// needing to know either platform's notion of density at all.
let window_uniform = {
let size = window_size.into();
WindowUniform {
@@ -610,12 +489,6 @@ impl UiRenderNode {
})
}
/// Group 3: the masks and move_offsets storage buffers, shared by the
/// main draw and every standalone image alike (see the field comment on
/// `masks_group`). Bound once per frame in `draw()` rather than folded
/// into group 2, so a resize of either buffer -- which an unrelated
/// widget's first move slot can trigger -- rebuilds this one group
/// instead of every image's.
fn masks_layout(device: &Device) -> BindGroupLayout {
device.create_bind_group_layout(&BindGroupLayoutDescriptor {
entries: &[
@@ -685,20 +558,10 @@ impl UiRenderNode {
self.textures.view_count()
}
/// Standalone-image bind groups built since the last call -- see
/// `GpuTextures::take_bind_group_creates`. Call once per frame before
/// `update()` to measure exactly that frame.
pub fn take_image_bind_group_creates(&mut self) -> u64 {
self.textures.take_bind_group_creates()
}
/// Atlas-array `grow_array` calls since the last call -- same calling
/// convention as `take_image_bind_group_creates` (call once per frame,
/// before `update()`, to read exactly the previous frame's tally). Part
/// of the Diagnostics page's per-frame report (RUST.md's P0 box, "the
/// first input frame" investigation): if a report ever shows a grow
/// landing on the same frame the glyphs vanished, that is the
/// coincidence to chase first.
pub fn take_atlas_pages_grown(&mut self) -> u64 {
self.textures.take_pages_grown()
}
-137
View File
@@ -21,9 +21,6 @@ pub const IMAGE_BINDING: u32 = 1;
pub trait Primitive: Pod {
const BINDING: u32;
fn vec(data: &mut PrimitiveData) -> &mut PrimitiveVec<Self>;
/// The read-only half of [`Self::vec`], for a caller that wants to
/// look one entry up rather than write one -- a mask reading the
/// radius of the rect it clips to ([`Primitives::data`]).
fn vec_ref(data: &PrimitiveData) -> &PrimitiveVec<Self>;
}
@@ -63,13 +60,6 @@ macro_rules! primitives {
impl PrimitiveBuffers {
pub const LEN: usize = primitives!(@count $($name)*);
/// The group-1 binding number each primitive's storage buffer
/// sits at, in declaration order. Not `0..LEN`: a primitive's
/// `BINDING` also tags its instances for the shader's dispatch
/// switch, and a removed primitive (as `TEXTURE` was, once
/// images stopped needing a per-instance storage entry) can
/// leave a gap, so the pipeline layout has to ask for these
/// exact numbers rather than assuming they are contiguous.
pub const BINDINGS: [u32; Self::LEN] = [$(<$ty>::BINDING,)*];
pub fn buffers(&self) -> [(u32, &Buffer); Self::LEN] {
[
@@ -126,59 +116,14 @@ macro_rules! primitives {
(@count $t:tt) => { 1 };
}
/// Every primitive instance in the tree, in one arena that all layers
/// share, plus the per-primitive data (`rects`, `glyphs`) they index.
///
/// **Why one arena rather than one per layer**, which is what this was:
/// the fragment stage evaluates a *mask's* primitive at the masked pixel
/// (LAYOUT.md's "Masks with a shape"), and the widget that owns a mask is
/// routinely in a different layer from the content it clips -- a rounded
/// container in one layer, a `Stack`'s child content in the layer below.
/// A per-layer buffer cannot answer that lookup at all: only one layer's
/// group is bound at a time, so the mask would silently read another
/// layer's rect. Both buffers are therefore global and bound once per
/// frame, and a layer keeps only its draw *order* ([`LayerOrder`]).
///
/// Slots are stable for a primitive's whole life: nothing here is
/// compacted, so a `Mask` can hold a slot across frames.
pub struct Primitives {
instances: Vec<PrimitiveInstance>,
/// The value a slot held before its first rewrite since the last
/// upload. Layout may place a widget provisionally and restore it in
/// the same frame; remembering the pre-frame value lets `set_instance`
/// clear that dirty bit instead of uploading a change the GPU never
/// needs to observe. Entries are overwritten on the next clean-to-dirty
/// transition, so no separate end-of-frame sweep is needed.
original_instances: Vec<Option<PrimitiveInstance>>,
assoc: Vec<WidgetId>,
/// Where each slot's [`PrimitiveHandle`] sits in its owner's
/// `ActiveData::primitives` -- the index that makes
/// `UiRenderState::apply_free` O(1) per renumbered primitive instead
/// of a scan of everything the owner drew. Written by
/// [`Self::set_handle_index`] from the one place a handle is taken
/// into that vec (`Painter::own`), and dead alongside its `assoc`
/// entry, which is what keeps the two in step.
///
/// Without it a text widget that is freed and redrawn in one frame
/// costs O(glyphs^2): every one of its glyphs is renumbered, and each
/// renumbering scanned all of them. Measured 2026-09-08 at 1.37s for a
/// 51,200-glyph block on this machine, against 20ms for the shaping
/// and rasterising of the same text.
handle_idx: Vec<u32>,
/// Slots freed since the last [`Self::apply_free`]. Deliberately not
/// reusable yet: the layer that drew one still names it in its draw
/// order until that call compacts the order, so handing it out again
/// first would draw the new primitive twice -- once through the stale
/// order entry and once through the new one.
freed: Vec<usize>,
/// Slots [`Self::apply_free`] released, which is what [`Self::alloc`]
/// hands out.
reusable: Vec<usize>,
data: PrimitiveData,
/// Which instance slots have changed since the last upload. Was a
/// single `bool` covering the instances **and** the per-primitive
/// data until 2026-09-09, so rewriting one rect's region re-uploaded
/// every glyph as well; each array carries its own now.
pub dirty: Dirty,
}
@@ -198,9 +143,6 @@ impl Default for Primitives {
}
impl Primitives {
/// A slot whose handle has not been recorded yet -- see
/// [`Self::handle_idx`]. No owner draws four billion primitives, so
/// the sentinel cannot collide with a real index.
const NO_HANDLE: u32 = u32::MAX;
/// Writes a primitive into the arena and hands back its slot and its
@@ -232,10 +174,6 @@ impl Primitives {
(slot, data_idx)
}
/// A standalone image, which has no `PrimitiveData` entry to allocate
/// -- its bind group already picks the texture, so `texture_idx` rides
/// in the otherwise-unused `idx` field and names the bind group the
/// draw call selects.
pub fn alloc_image(
&mut self,
id: WidgetId,
@@ -312,9 +250,6 @@ impl Primitives {
);
}
/// The image half of [`Self::recycle`] -- no `PrimitiveData` entry, so
/// `texture_idx` rides in `idx` exactly as [`Self::alloc_image`] puts
/// it there.
pub fn recycle_image(
&mut self,
h: &PrimitiveHandle,
@@ -378,23 +313,14 @@ impl Primitives {
self.instances[slot].mask_idx
}
/// Hands this frame's freed slots back for reuse. Called once per
/// frame from `UiRenderState::update`, **after** every layer has
/// compacted its draw order, since that order is the only thing still
/// naming them.
pub fn release_freed(&mut self) {
self.reusable.append(&mut self.freed);
}
/// Which widget drew the primitive in `slot` -- how a draw-order
/// change finds the handle it has to renumber.
pub fn owner(&self, slot: u32) -> WidgetId {
self.assoc[slot as usize]
}
/// Records that `slot`'s handle is `idx` entries into its owner's
/// `ActiveData::primitives`. Called once per primitive, by the one
/// place that puts a handle into that vec.
pub fn set_handle_index(&mut self, slot: u32, idx: u32) {
self.handle_idx[slot as usize] = idx;
}
@@ -420,18 +346,10 @@ impl Primitives {
self.data.clear();
}
/// How many instances are still live -- the O(1) half of the orphan
/// check, so the O(primitives) walk below only runs on a frame that
/// already looks wrong. See
/// [`crate::UiRenderState::orphaned_primitives`].
pub fn live_count(&self) -> usize {
self.instances.len() - self.freed.len() - self.reusable.len()
}
/// Every live instance as `(slot, owner, is_image)` -- everything
/// except the freed and the reusable. Only
/// [`crate::UiRenderState::orphaned_primitives`] uses this, to check
/// that every live primitive still belongs to a live widget.
pub fn live_instances(&self) -> impl Iterator<Item = (u32, WidgetId, bool)> + '_ {
let dead: HashSet<usize> = self.freed.iter().chain(&self.reusable).copied().collect();
(0..self.instances.len())
@@ -453,8 +371,6 @@ impl Primitives {
&self.instances
}
/// The instance arena and its dirty set together -- see
/// [`PrimitiveVec::for_upload`].
pub fn instances_for_upload(&mut self) -> (&[PrimitiveInstance], &mut Dirty) {
(&self.instances, &mut self.dirty)
}
@@ -465,8 +381,6 @@ impl Primitives {
&mut self.data
}
/// Whether anything at all needs uploading -- the instances or any of
/// the per-primitive arrays.
pub fn needs_upload(&self) -> bool {
!self.dirty.is_clean() || self.data.needs_upload()
}
@@ -496,9 +410,6 @@ impl Primitives {
}
}
/// One layer's draw order: the slots of the global arena it draws, in the
/// order they were written. The vertex buffer of a layer is exactly this.
///
/// Both lists free with `swap_remove`, so a layer's draw order was already
/// undefined before this split: nothing here may assume one primitive
/// stays adjacent to another once anything in the layer has been freed.
@@ -529,9 +440,6 @@ impl LayerOrder {
list.len() - 1
}
/// Marks a position for removal. Deferred to [`Self::apply_free`] like
/// the arena's own, so that a position is only renumbered once per
/// frame however many were dropped.
pub fn free(&mut self, pos: usize, is_image: bool) {
self.updated = true;
if is_image {
@@ -541,8 +449,6 @@ impl LayerOrder {
}
}
/// Compacts both lists, answering every primitive whose position
/// moved so its handle can be corrected.
pub fn apply_free(&mut self) -> Vec<OrderChange> {
let mut changes = Self::apply_free_list(
&mut self.free,
@@ -559,8 +465,6 @@ impl LayerOrder {
changes
}
/// The draw order and its dirty set together -- see
/// [`PrimitiveVec::for_upload`].
pub fn order_for_upload(&mut self) -> (&[u32], &mut Dirty) {
(&self.order, &mut self.order_dirty)
}
@@ -575,8 +479,6 @@ impl LayerOrder {
dirty: &mut Dirty,
is_image: bool,
) -> Vec<OrderChange> {
// Descending, so removing a contiguous tail costs no renumbering
// at all -- which is what freeing one widget's primitives is.
free.sort_by(|a, b| b.cmp(a));
free.drain(..)
.filter_map(|pos| {
@@ -584,9 +486,6 @@ impl LayerOrder {
if pos == list.len() {
return None;
}
// `swap_remove` moved the tail entry here; nothing else in
// the list changed, which is why compacting an order is
// two dirty entries rather than the whole buffer.
dirty.mark(pos);
Some(OrderChange {
slot: list[pos],
@@ -606,14 +505,8 @@ impl LayerOrder {
}
}
/// A primitive whose position in a layer's draw order moved when
/// something before it was freed -- `slot` names which primitive, so its
/// owner's handle can be found and pointed at `pos`.
pub struct OrderChange {
pub slot: u32,
/// Which of the layer's two lists moved: their positions are
/// independent index spaces, so a handle matching on position alone
/// could take an image's renumbering for a rect's.
pub is_image: bool,
pub pos: usize,
}
@@ -628,13 +521,8 @@ pub enum Drawn {
No,
}
/// The `pos` of a [`Drawn::No`] primitive: it is in no layer's order, so
/// there is no position to renumber or free.
pub const NOT_DRAWN: usize = usize::MAX;
/// Where one primitive lives: its stable slot in the global arena, and
/// where in a layer's draw order it currently sits ([`NOT_DRAWN`] if it is
/// only referenced).
#[derive(Debug)]
pub struct PrimitiveHandle {
pub layer: usize,
@@ -683,11 +571,6 @@ impl RectPrimitive {
}
}
/// One glyph, drawn as a sub-rectangle of the glyph atlas array.
///
/// `color` is the text colour and is multiplied by the atlas's alpha for an
/// ordinary mask glyph; a colour glyph (emoji) carries its own colour and
/// takes the atlas texel unchanged, which is what `IS_COLOR` selects.
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct GlyphPrimitive {
@@ -699,10 +582,6 @@ pub struct GlyphPrimitive {
pub layer: u32,
pub color: Color<u8>,
pub flags: u32,
/// Pads this struct's Rust size to match WGSL's storage-buffer layout for
/// `GlyphInfo`: two `vec2<f32>` members give the struct an 8-byte
/// alignment, which rounds the WGSL size up to 32 bytes even though the
/// fields above only total 28. `bytemuck` does not check this for us.
_pad: u32,
}
@@ -759,20 +638,6 @@ impl<T> PrimitiveVec<T> {
self.dirty.mark(i);
i
}
/// Overwrites an entry already allocated -- the recycle path
/// ([`Primitives::recycle`]) -- and marks it dirty **only if the
/// value actually differs**.
///
/// That check is not an optimisation of the comparison; it is what
/// makes the dirty set mean "changed" rather than "written". A row
/// that moves, or is re-laid-out at a new width, rewrites every glyph
/// it owns with the same `uv`, `layer`, `colour` and `flags` -- what
/// moved is the *instance's* region, which is a different array. Over
/// the bench fixture's streamed reply the glyph array was being
/// marked at 73% per frame against 0.6% genuinely changed, a 122x
/// over-upload, entirely from this (`scripts/rigs/ui-profile`'s
/// `arena_churn`, which prints both numbers side by side so the gap
/// cannot reopen unnoticed).
pub fn set(&mut self, i: usize, t: T)
where
T: Pod,
@@ -845,8 +710,6 @@ mod tests {
"the GPU never observes the provisional position"
);
// A subsequent frame takes its baseline from the value currently in
// the arena, rather than reusing the now-stale original above.
primitives.set_instance(0, moved, owner);
assert!(!primitives.dirty.is_clean());
primitives.dirty.clear();
-27
View File
@@ -1,28 +1,7 @@
//! The rounded-rect coverage function, on the CPU.
//!
//! `shader.wgsl`'s `distance_from_rect`/`rounded_rect_coverage` are a
//! transliteration of these two, line for line, and
//! `mask_sdf_matches_the_shader` in `iris`'s layout tests compares the two
//! at a grid of points against values the shader itself produced. They are
//! kept together here, in the crate both a renderer and a hit test can
//! reach, because LAYOUT.md's "Masks with a shape" turns on the two
//! agreeing: a masked corner that cannot be tapped and a masked corner
//! that is not drawn have to be the same corner, and they are only the
//! same corner while one function decides both.
//!
//! Window pixels throughout, matching the shader's `pos` -- not `UiRegion`
//! units, which the shader has already resolved by the time it evaluates
//! this.
use crate::util::Vec2;
/// The signed distance from `pos` to a rounded rect given by its centre,
/// its corner offset (half its size) and its corner `radius`. Negative
/// inside.
pub fn distance_from_rect(pos: Vec2, center: Vec2, corner: Vec2, radius: f32) -> f32 {
// vec from center to pixel
let p = pos - center;
// vec from inner rect corner to pixel
let q = Vec2::new(
p.x.abs() - (corner.x - radius),
p.y.abs() - (corner.y - radius),
@@ -31,12 +10,6 @@ pub fn distance_from_rect(pos: Vec2, center: Vec2, corner: Vec2, radius: f32) ->
(clamped.x * clamped.x + clamped.y * clamped.y).sqrt() - radius
}
/// How much of the pixel at `pos` a rounded rect covers, anti-aliased over
/// the half-pixel either side of its edge: 1 well inside, 0 well outside.
///
/// The half-pixel feather is why a hit test asks for **more than a half**
/// rather than "any coverage at all": half is where the geometric edge is,
/// so the two answer the same question the drawn shape does.
pub fn rounded_rect_coverage(pos: Vec2, top_left: Vec2, bot_right: Vec2, radius: f32) -> f32 {
let edge: f32 = 0.5;
let corner = (bot_right - top_left) / 2.0;
+17 -69
View File
@@ -1,7 +1,5 @@
const RECT: u32 = 0u;
// TEXTURE has no entry in group 1: a standalone image draws with its own
// bind group (see UiRenderNode::draw), so there is nothing per-instance left
// to look up here -- the bind group already picked the texture.
// Standalone images select their texture through their own bind group.
const TEXTURE: u32 = 1u;
const GLYPH: u32 = 2u;
@@ -22,24 +20,19 @@ struct Rect {
struct GlyphInfo {
uv_min: vec2<f32>,
uv_max: vec2<f32>,
// Layer of the shared atlas array texture, not a view or bind-group
// index -- a page never gets its own bind group. See TEXTURES.md's
// "Recommended shape".
// A layer in the shared atlas array, not a bind-group index.
layer: u32,
color: u32,
flags: u32,
}
/// Mirrors `Mask` in data.rs: the slot of the primitive whose coverage
/// clips this mask's subtree, and the mask it nests inside
/// (`4294967295u` at the top).
/// Mirrors `Mask` in data.rs. `parent` is u32::MAX at the root.
struct Mask {
primitive: u32,
parent: u32,
}
/// One widget's cumulative on-screen translation and the slot of the
/// ancestor to add on top of it. Mirrors `MoveOffset` in data.rs.
/// Mirrors `MoveOffset` in data.rs.
struct MoveOffset {
delta: vec2<f32>,
parent: u32,
@@ -55,50 +48,27 @@ struct UiScalar {
abs: f32,
}
// The shared glyph atlas: every page is one layer. Growing it recreates this
// texture with headroom and copies the old layers across -- see
// GpuTextures::grow_array -- rather than the binding_array<texture_2d<f32>>
// this replaced, which needed VK_EXT_descriptor_indexing and does not survive
// a real share of Android GPUs (see TEXTURES.md).
// One array texture avoids descriptor indexing, which is not universal on Android.
@group(2) @binding(0)
var atlas: texture_2d_array<f32>;
// One standalone image's texture. The main draw (rects and glyphs) binds a
// 1x1 null texture here, since neither samples it; each image draw call
// binds its own -- see UiRenderNode::draw.
// Image draws bind their texture here; other draws bind a 1x1 placeholder.
@group(2) @binding(1)
var image_texture: texture_2d<f32>;
@group(2) @binding(2)
var samp: sampler;
// Their own group, bound once per frame rather than folded into group 2: see
// UiRenderNode::masks_layout for why an image's own bind group must not name
// either buffer.
// Kept outside group 2 so standalone image bind groups need not name these buffers.
@group(3) @binding(0)
var<storage> masks: array<Mask>;
@group(3) @binding(1)
var<storage> move_offsets: array<MoveOffset>;
// Every primitive's placement, in one arena all layers share. The vertex
// stage reads the primitive it is drawing (its slot arrives as the only
// vertex attribute); the fragment stage reads a *mask's* primitive, which
// is generally a different one in a different layer. See LAYOUT.md's
// "Masks with a shape" and `Primitives` in primitive.rs.
// Shared by the vertex stage's drawn primitive and the fragment stage's mask shape.
@group(3) @binding(2)
var<storage> instances: array<PrimitiveInstance>;
// The bound on the parent walk, kept in step with `PARENT_CHAIN_LIMIT` in
// render_state.rs, which walks the identical chain on the CPU side for
// hit-testing. Bounded so a malformed chain (a cyclic `parent`) cannot
// hang the GPU -- not a claim about how deep a real tree gets. It was 16
// and that was too small: the transcript screen's composer field sits 17
// slots below the root, measured 2026-09-07 on this checkout's emulator
// by tapping it (the CPU walk's own debug assert names the chain now).
// Past the bound both walks simply stop summing, so the widget draws and
// hit-tests short by whatever the outer slots held, with nothing on
// screen to say so.
// Keep synchronized with render_state.rs. The bound prevents a malformed
// parent cycle from hanging the GPU; real widget trees have exceeded 16.
const PARENT_CHAIN_LIMIT: u32 = 64u;
/// Sums the pixel delta along the parent chain starting at `idx`, shared by
/// the vertex stage (a primitive's own corners) and the fragment stage (its
/// mask's corners) so the walk is written once. See LAYOUT.md section 2b.
fn resolve_move(idx: u32) -> vec2<f32> {
var total = vec2<f32>(0.0, 0.0);
var i = idx;
@@ -117,8 +87,7 @@ struct WindowUniform {
dim: vec2<f32>,
};
/// Mirrors `PrimitiveInstance` in data.rs -- the placement and what to
/// draw there. `x`/`y` are the `UiRegion`'s two spans.
/// Mirrors `PrimitiveInstance` in data.rs.
struct PrimitiveInstance {
x: UiSpan,
y: UiSpan,
@@ -128,7 +97,6 @@ struct PrimitiveInstance {
move_idx: u32,
}
/// A layer's draw order: one slot into `instances` per instance drawn.
struct InstanceInput {
@location(0) slot: u32,
}
@@ -137,8 +105,7 @@ struct VertexOutput {
@location(0) top_left: vec2<f32>,
@location(1) bot_right: vec2<f32>,
@location(2) uv: vec2<f32>,
// `flat` is the only interpolation an integer can have, and naga
// (wgpu 30) now requires saying so rather than inferring it.
// Naga requires integer varyings to declare flat interpolation.
@location(3) @interpolate(flat) binding: u32,
@location(4) @interpolate(flat) idx: u32,
@location(5) @interpolate(flat) mask_idx: u32,
@@ -152,10 +119,7 @@ struct Region {
bot_right: vec2<f32>,
}
/// One primitive's on-screen corners in window pixels. Written once and
/// used by both stages: the vertex stage for the primitive it is drawing,
/// the fragment stage for a mask's -- so the shape a mask clips to and the
/// shape that was drawn cannot be computed two different ways.
/// Shared by drawing and mask coverage so their geometry cannot diverge.
struct Corners {
top_left: vec2<f32>,
bot_right: vec2<f32>,
@@ -224,10 +188,7 @@ fn fs_main(
color = vec4(1.0, 0.0, 1.0, 1.0);
}
}
// Every mask on the chain, not just the innermost: a widget that set
// its own mask inside another is clipped by both, and the coverages
// multiply -- so a pixel inside two feathered corners is dimmed by
// both, which is what a compositor does (`Mask::parent` in data.rs).
// Nested masks multiply coverage, matching the CPU hit test.
var mask_idx = in.mask_idx;
for (var step = 0u; step < PARENT_CHAIN_LIMIT; step++) {
if mask_idx == 4294967295u {
@@ -240,17 +201,11 @@ fn fs_main(
return color;
}
/// How much of `pos` one mask lets through: the referenced primitive's
/// own coverage at that pixel, from the same SDF the primitive is drawn
/// with. Nothing about the shape is copied into the mask, so a rounded
/// container's corner and its children's clipped corner are the same
/// arithmetic.
/// Uses the referenced primitive itself so its drawn and clipped edges agree.
fn mask_coverage(pos: vec2<f32>, mask: Mask) -> f32 {
let inst = instances[mask.primitive];
if inst.binding != RECT {
// Unreachable: `Painter::set_mask` rejects a glyph or an image
// shape by name (see `Mask::primitive`). Letting the pixel
// through rather than reading a `rects` entry that is not there.
// Painter::set_mask rejects non-rect shapes; fail open if that invariant breaks.
return 1.0;
}
let c = corners_of(inst);
@@ -272,11 +227,7 @@ fn draw_glyph(region: Region, g: GlyphInfo) -> vec4<f32> {
return color;
}
/// The anti-aliased coverage of a rounded rect at one pixel -- the one
/// function both a drawn rect and a mask go through, and the
/// transliteration of `iris_core::rounded_rect_coverage` on the CPU,
/// which the hit test uses so a corner that cannot be tapped and a corner
/// that is not drawn are the same corner.
/// Keep synchronized with the CPU hit-test implementation in render::sdf.
fn rounded_rect_coverage(
pos: vec2<f32>,
top_left: vec2<f32>,
@@ -309,10 +260,7 @@ fn draw_rounded_rect(region: Region, rect: Rect) -> vec4<f32> {
}
fn distance_from_rect(pixel_pos: vec2<f32>, rect_center: vec2<f32>, rect_corner: vec2<f32>, radius: f32) -> f32 {
// vec from center to pixel
let p = pixel_pos - rect_center;
// vec from inner rect corner to pixel
let q = abs(p) - (rect_corner - radius);
return length(max(q, vec2(0.0))) - radius;
}
-62
View File
@@ -9,12 +9,7 @@ use super::atlas::PAGE;
/// one, for the GLES reason written on `create_array_texture`.
const MIN_ARRAY_LAYERS: u32 = 2;
/// What one texture slot is, GPU-side. Parallel to `Textures`' own slot
/// numbering (`TextureKind`'s `Image`/`Page`), so a slot's index means the
/// same thing on both sides without a second map to keep in sync.
enum Slot {
/// A slot that was freed, or pushed and freed within the same batch
/// before ever reaching here.
Empty,
Image(ImageGpu),
/// The array layer a page occupies. Pages are never freed (see
@@ -23,16 +18,12 @@ enum Slot {
}
struct ImageGpu {
/// Kept alive alongside `view`/`bind_group`, which borrow from it only in
/// the sense that dropping this drops the GPU resource they point to.
#[allow(dead_code)]
texture: Texture,
view: TextureView,
bind_group: BindGroup,
}
/// Owns the two kinds of texture iris draws:
///
/// - **The glyph atlas**, one `texture_2d_array` whose layers are pages
/// (`Slot::Page`), grown by recreating the array with headroom and
/// `copy_texture_to_texture`-ing the old layers across. No feature beyond
@@ -41,11 +32,6 @@ struct ImageGpu {
/// - **Standalone images** (`Slot::Image`), each its own `Texture` and
/// `BindGroup`, drawn one `draw()` call at a time with that bind group
/// bound -- see `UiRenderNode::draw`.
///
/// See TEXTURES.md's "Recommended shape" for why, and RUST.md's
/// "iris's binding array does not survive real Android hardware" for what
/// this replaced (one giant `binding_array<texture_2d<f32>>` needing
/// `VK_EXT_descriptor_indexing`, which a real share of Android GPUs lack).
pub struct GpuTextures {
device: Device,
queue: Queue,
@@ -55,7 +41,6 @@ pub struct GpuTextures {
array_texture: Texture,
array_view: TextureView,
array_capacity: u32,
/// Layers actually written. Only grows -- see `Slot::Page`.
page_count: u32,
sampler: Sampler,
@@ -64,18 +49,7 @@ pub struct GpuTextures {
/// but the layout requires something bound regardless.
null_view: TextureView,
/// Standalone-image bind groups actually built (`create_image`'s own
/// build, or one per slot touched by `rebuild_image_bind_groups`) since
/// the last `take_bind_group_creates`. IRIS_TODO.md's "many images"
/// benchmark reads this to prove the steady-state cost of an
/// unchanging image list is zero, the same way `UiRenderState`'s
/// `draw_count`/`region_mut_count` prove the layout side.
bind_group_creates: u64,
/// `grow_array` calls since the last `take_pages_grown` -- the
/// Diagnostics page's per-frame report (RUST.md's P0 box, "the first
/// input frame" investigation) reads this alongside `bind_group_creates`
/// to say whether *this* frame's glyph disappearance, if any, coincided
/// with the atlas array being recreated.
pages_grown: u64,
}
@@ -161,7 +135,6 @@ impl GpuTextures {
if let Some(slot) = self.slots.get_mut(i as usize) {
*slot = Slot::Empty;
}
// A page's layer is not reclaimed here either -- see `Slot::Page`.
}
fn patch(&mut self, i: u32, rect: PatchRect, image: &DynamicImage) {
@@ -171,9 +144,6 @@ impl GpuTextures {
if rect.width == 0 || rect.height == 0 {
return;
}
// Cropped rather than written straight from the atlas, because
// write_texture wants tightly packed rows and the atlas rows are as
// wide as the atlas. A glyph is small, so the copy is too.
let sub = image
.view(rect.x, rect.y, rect.width, rect.height)
.to_image();
@@ -231,10 +201,6 @@ impl GpuTextures {
);
}
/// Doubles the array's layer capacity (headroom, so this is rare) and
/// copies the old layers across GPU-side -- no readback. Recreates the
/// array's view, which invalidates every bind group that referenced it,
/// so this also rebuilds all of them before returning.
fn grow_array(&mut self, rsc_layout: &BindGroupLayout) {
self.pages_grown += 1;
let new_capacity = self.array_capacity * 2;
@@ -275,10 +241,6 @@ impl GpuTextures {
self.rebuild_image_bind_groups(rsc_layout);
}
/// Called only from `grow_array`: the atlas array's view identity is the
/// one thing an image's bind group (group 2) still names that can
/// change out from under it. Masks/move_offsets resizing no longer
/// reaches here at all -- see `UiRenderNode::masks_group`.
fn rebuild_image_bind_groups(&mut self, rsc_layout: &BindGroupLayout) {
for slot in &mut self.slots {
if let Slot::Image(gpu) = slot {
@@ -332,11 +294,6 @@ impl GpuTextures {
}
}
/// Builds group 2 for one standalone image: the shared atlas array, this
/// image's own view and the shared sampler -- the same layout the main
/// draw uses with a null view in the image slot. Deliberately does not
/// touch masks/move_offsets (group 3, `UiRenderNode::masks_group`): see
/// that field's comment for why folding them in here was the bug.
fn make_image_bind_group(
device: &Device,
rsc_layout: &BindGroupLayout,
@@ -364,20 +321,6 @@ impl GpuTextures {
})
}
/// The atlas is sampled as a `texture_2d_array`, and **a one-layer
/// array is not one on the GLES backend**: wgpu-hal picks the GL
/// texture target from the descriptor alone
/// (`gles::Texture::get_info_from_desc`, `(false, 1) => TEXTURE_2D`),
/// so a capacity of 1 creates a `GL_TEXTURE_2D` and binds it to the
/// shader's `sampler2DArray`. GL then treats that unit as incomplete
/// and every `textureSample` returns (0, 0, 0, 1) -- which, through
/// `draw_glyph`'s `color.a *= texel.a`, draws every glyph as a solid
/// filled box. That was iris's appearance on the emulator's GLES for
/// two days (RUST.md, "the emulator cannot draw iris's glyphs"), and
/// it is a real defect on any device whose adapter is GL rather than
/// Vulkan, not an emulator artifact. So the array never has fewer than
/// `MIN_ARRAY_LAYERS` layers; the second layer costs one page of
/// texture memory and is used by the next atlas page anyway.
fn create_array_texture(device: &Device, capacity: u32) -> Texture {
debug_assert!(
capacity >= MIN_ARRAY_LAYERS,
@@ -426,15 +369,10 @@ impl GpuTextures {
}
}
/// Reads and zeroes the standalone-image bind-group creation counter --
/// call once per frame before `update()`, mirroring
/// `UiRenderState::take_counters`.
pub fn take_bind_group_creates(&mut self) -> u64 {
std::mem::take(&mut self.bind_group_creates)
}
/// Reads and zeroes the atlas-array-grow counter -- see `pages_grown`'s
/// field comment.
pub fn take_pages_grown(&mut self) -> u64 {
std::mem::take(&mut self.pages_grown)
}
-26
View File
@@ -4,8 +4,6 @@ use crate::util::Dirty;
use bytemuck::Pod;
use wgpu::*;
/// A GPU array whose `Buffer` outlives the data in it.
///
/// **The buffer has a capacity, and shrinking never reallocates.** That
/// is not only about allocation cost: a fresh `Buffer`'s contents are
/// undefined, so a reallocation is the one event after which a *partial*
@@ -13,23 +11,11 @@ use wgpu::*;
/// is therefore the precondition for uploading only what changed, and
/// [`Self::update`] says which of the two happened so a caller can force
/// the whole range dirty.
///
/// It reallocated on every length change until 2026-09-09, which made the
/// streaming path pay a full rewrite of every arena on nearly every
/// frame -- adding one glyph changes a length. Measured over the bench
/// fixture's 401 streamed deltas (`scripts/rigs/ui-profile`'s
/// `arena_churn`): the glyph buffer's *changed* bytes were 3.0% of its
/// size, but 95% of it had to be re-uploaded anyway because the buffer
/// underneath had just been replaced.
pub struct ArrBuf<T: Pod> {
label: &'static str,
usage: BufferUsages,
pub buffer: Buffer,
/// Entries the caller last wrote -- what a draw call reads.
len: usize,
/// Entries the buffer has room for. Grows geometrically and never
/// shrinks, so a list that oscillates in length (every frame of a
/// fling adds and drops rows) settles on one allocation.
capacity: usize,
_pd: PhantomData<T>,
}
@@ -52,11 +38,6 @@ impl<T: Pod> ArrBuf<T> {
}
}
/// Grows to hold `len` entries if it does not already, answering
/// whether that meant a new `Buffer`. Doubling rather than exact, so a
/// buffer that grows by one entry per frame -- which is what a
/// streamed reply does to the glyph arena -- reallocates a logarithmic
/// number of times rather than every frame.
pub fn reserve(&mut self, device: &Device, len: usize) -> bool {
if len <= self.capacity {
return false;
@@ -76,9 +57,6 @@ impl<T: Pod> ArrBuf<T> {
usage: BufferUsages,
label: &'static str,
) -> Buffer {
// A storage binding of size 0 is a validation error, and an empty
// arena is the ordinary state of a buffer nothing has drawn into
// yet.
let size = (entries.max(1) * std::mem::size_of::<T>()) as u64;
device.create_buffer(&BufferDescriptor {
label: Some(label),
@@ -123,10 +101,6 @@ impl<T: Pod> ArrBuf<T> {
reallocated
}
/// How far apart two dirty runs may be and still be uploaded as one
/// -- in entries, so a wider entry merges across fewer of them and
/// the *byte* cost of merging is the same either way. See
/// [`Dirty::ranges`] for the measurement behind 1 KiB.
const MERGE_GAP: usize = 1024 / std::mem::size_of::<T>();
#[allow(clippy::len_without_is_empty)]
-27
View File
@@ -1,23 +1,3 @@
//! I4 (RUST.md): an AccessKit tree built from iris's own widget tree,
//! shared by both backends -- `android/view.rs` pushes its `TreeUpdate`s
//! through `accesskit_android::Adapter`, `default/mod.rs` through
//! `accesskit_winit::Adapter`. Kept modular the way input's sense registry
//! is: `Widgets::named()` is a side set populated only by `.label()`, so a
//! widget nobody named is never visited here at all, not even to decide it
//! has no name.
//!
//! The tree itself is deliberately flat -- one synthetic `Role::Window`
//! root with every named widget as a direct child, in no particular order.
//! iris's actual widget nesting (a label three `Span`s deep inside a
//! `ScrollArea`) carries no accessibility meaning of its own here: nothing
//! upstream of a named leaf needs a node, since a screen reader's own
//! traversal (and uiautomator's tap-by-name, the pass condition this was
//! built for) works from each node's on-screen bounds rather than from
//! tree structure. Mirroring the real widget tree exactly would also mean
//! rebuilding intermediate nodes whenever *any* container above a named
//! widget resizes, which is most frames -- the flat shape is what keeps
//! rebuilds tied to "a name, a role or a position actually changed".
use crate::{PixelRegion, UiRenderState, UiRsc, WidgetId, Widgets, util::HashMap};
use accesskit::{Node, NodeId, Rect, Role, TreeId, TreeInfo, TreeUpdate};
@@ -57,11 +37,6 @@ fn entry_node(entry: &Entry) -> Node {
#[derive(Default)]
pub struct AccessTree {
known: HashMap<WidgetId, Entry>,
/// `TreeUpdate`s actually produced since the last `take_rebuilds` --
/// the AccessKit-tree twin of `UiRenderState::take_counters`. Should
/// stay at 0 across an unchanged frame and move by exactly 1 when a
/// named widget's position, name or role changes, however many other
/// widgets are on screen; see `iris/src/access_tests.rs`.
rebuilds: u64,
}
@@ -128,8 +103,6 @@ impl AccessTree {
build_update(&Self::collect(widgets, render, rsc))
}
/// Reads and zeroes the rebuild counter, the same call shape as
/// `UiRenderState::take_counters`.
pub fn take_rebuilds(&mut self) -> u64 {
std::mem::take(&mut self.rebuilds)
}
-11
View File
@@ -2,7 +2,6 @@ use crate::{
LayerId, MaskIdx, MoveIdx, PrimitiveHandle, Size, TextureHandle, UiRegion, WidgetId, util::Vec2,
};
/// important non rendering data for retained drawing
#[derive(Debug)]
pub struct ActiveData {
pub id: WidgetId,
@@ -11,23 +10,13 @@ pub struct ActiveData {
pub textures: Vec<TextureHandle>,
pub primitives: Vec<PrimitiveHandle>,
pub children: Vec<WidgetId>,
/// Direct children whose reported size this widget used during its
/// latest draw. Dirtiness propagates across these edges before layout
/// starts, so the resulting draw still travels only parent to child.
pub size_dependencies: Vec<WidgetId>,
/// The inherited mask, not `own_mask`.
pub mask: MaskIdx,
/// The widget's retained mask slot, or `MaskIdx::NONE`.
pub own_mask: MaskIdx,
pub layer: LayerId,
/// The size recorded by the last `Widget::draw` through its painter.
pub size: Size,
/// Retained so descendants' parent links stay valid across redraws.
pub move_slot: MoveIdx,
/// The optional coordinate boundary between this widget and its direct
/// children. Descendants retain links to it across redraws, just as they
/// do to `move_slot`.
pub child_move_slot: Option<MoveIdx>,
/// The part of this widget's move delta already folded into `region`.
pub move_applied: Vec2,
}
-21
View File
@@ -18,20 +18,7 @@ pub struct UiData {
pub textures: Textures,
pub text: TextData,
pub masks: TrackedArena<Mask, u32>,
/// One entry per widget ever drawn, plus optional child-coordinate
/// boundaries owned by containers. Together they form the parent-linked
/// chain `resolve_move` walks in both shader stages. A widget's ordinary
/// entry is allocated once on its first draw and reused for every later
/// redraw of the same id, so a retained descendant's `parent` index never
/// goes stale -- see LAYOUT.md section 2.
pub move_offsets: TrackedArena<MoveOffset, u32>,
/// Every widget whose [`crate::Widget::tick`] should run before the
/// next frame -- today, a `LazySpan` coasting through a fling. Added by
/// [`Self::animate`] when the animation starts and removed by
/// [`Self::tick_animations`] the frame its `tick` answers `false`, so
/// a stopped animation costs nothing and a dropped widget cannot be
/// ticked (`get_dyn_mut` answers `None` and it is dropped the same
/// way).
animating: Vec<WidgetId>,
}
@@ -46,15 +33,7 @@ impl UiData {
}
}
/// Tick every registered widget to `now`, drop the ones that finished,
/// and say whether any is still going -- which is a backend's cue to
/// ask for another frame. Called once per frame *before* the draw, so
/// what the frame draws is this instant's position rather than the
/// previous one's.
pub fn tick_animations(&mut self, now: std::time::Instant) -> bool {
// Taken out and put back rather than iterated in place: `tick`
// needs `&mut` on the widget arena this list lives beside, and a
// widget is free to register another one while ticking.
let mut registered = std::mem::take(&mut self.animating);
registered.retain(|&id| match self.widgets.get_dyn_mut(id) {
Some(widget) => widget.tick(now),
-84
View File
@@ -18,11 +18,9 @@ pub struct Painter<'a> {
pub(super) mask: MaskIdx,
pub(super) move_slot: MoveIdx,
pub(super) child_move_slot: Option<MoveIdx>,
/// This widget's retained mask slot.
pub(super) own_mask: MaskIdx,
pub(super) textures: Vec<TextureHandle>,
pub(super) primitives: Vec<PrimitiveHandle>,
/// Previous handles, consumed in draw order and freed if left over.
pub(super) recycle: std::iter::Peekable<std::vec::IntoIter<PrimitiveHandle>>,
pub(super) children: Vec<WidgetId>,
pub(super) size_dependencies: Vec<WidgetId>,
@@ -37,16 +35,12 @@ pub struct Painter<'a> {
pub(super) id: WidgetId,
}
/// A child draw whose size has not necessarily been observed by its parent.
/// Holding this value keeps the painter borrowed, so `.size()` can only name
/// the child from the immediately preceding draw.
pub struct DrawResult<'p, 'a> {
painter: &'p mut Painter<'a>,
child: WidgetId,
}
impl DrawResult<'_, '_> {
/// Return the child's reported size and record the layout dependency.
pub fn size(self) -> Size {
if !self.painter.size_dependencies.contains(&self.child) {
self.painter.size_dependencies.push(self.child);
@@ -56,8 +50,6 @@ impl DrawResult<'_, '_> {
}
impl<'a> Painter<'a> {
/// Record the size this widget used. Every `Widget::draw` calls this
/// exactly once; parents observe it through [`DrawResult::size`].
pub fn set_size(&mut self, size: Size) {
assert!(
self.size.replace(size).is_none(),
@@ -69,10 +61,6 @@ impl<'a> Painter<'a> {
self.write_primitive(primitive, region, Drawn::Yes);
}
/// The next handle from the previous draw, if it can hold what is
/// about to be written: same kind of primitive, same layer, and the
/// same answer to "does a layer's draw order name it".
///
/// **Consumed strictly in order, and one mismatch ends recycling for
/// the rest of the draw.** A widget's `draw` is a function of its own
/// state, so a redraw writes the same sequence of primitives in the
@@ -90,8 +78,6 @@ impl<'a> Painter<'a> {
self.recycle.next()
}
/// The one path every primitive this widget owns goes through --
/// drawn or, for a mask's shape, only referenced.
fn write_primitive<P: Primitive>(
&mut self,
primitive: P,
@@ -121,13 +107,6 @@ impl<'a> Painter<'a> {
}
/// Take ownership of a handle this widget just wrote.
///
/// The one place a `PrimitiveHandle` enters `self.primitives`, and so
/// the one place that can keep `Primitives::handle_index` in step with
/// where it lands -- which is what `UiRenderState::apply_free` reads
/// instead of scanning this vec. Anything that writes a primitive
/// without coming through here leaves that index unset, and its
/// position in a layer's draw order stops being renumbered.
fn own(&mut self, h: PrimitiveHandle) {
self.state
.primitives
@@ -135,7 +114,6 @@ impl<'a> Painter<'a> {
self.primitives.push(h);
}
/// Writes a primitive to be rendered
pub fn primitive<P: Primitive>(&mut self, primitive: P) {
self.primitive_at(primitive, self.region)
}
@@ -144,18 +122,6 @@ impl<'a> Painter<'a> {
self.primitive_at(primitive, region.within(&self.region));
}
/// Clip everything this widget draws, itself and its descendants, to
/// `region`. One call per widget; a widget drawn inside another
/// widget's mask nests instead -- the new mask chains to the inherited
/// one (`Mask::parent`) and the fragment stage multiplies both
/// coverages, which is what lets a transcript row's code fence clip
/// to itself *and* to the list it scrolls inside.
///
/// The clip is a **primitive**, not a rectangle copied into the mask:
/// this writes an undrawn `RectPrimitive` at `region` and points the
/// mask at it, so the fragment stage evaluates the same rounded-rect
/// coverage a drawn rect gets. See LAYOUT.md's "Masks with a shape".
///
/// The slot is allocated once and **rewritten in place** on every
/// later draw rather than pushed again, because a descendant whose own
/// region did not change is not redrawn (`draw_inner`'s fast path) and
@@ -184,24 +150,12 @@ impl<'a> Painter<'a> {
self.set_mask_to(slot);
}
/// Points this widget's mask at a primitive that has already been
/// written -- the shared half of [`Self::set_mask`].
fn set_mask_to(&mut self, shape: u32) {
// `assert!`, not `debug_assert!`: one comparison per widget draw,
// and the second call silently *replacing* the first is a widget
// drawn unclipped -- which reaches the screen and nothing says so.
// Every build anybody runs here is release
// (review, 2026-09-07).
assert!(
self.own_mask == MaskIdx::NONE || self.mask != self.own_mask,
"set_mask called twice while drawing one widget: the second would replace the first \
rather than nest inside it",
);
// A glyph would need a CPU-side alpha plane for the hit test to
// agree with the shader, and a standalone image a bind-group
// switch the fragment stage cannot make -- see `Mask::primitive`.
// Named here rather than left to the shader, which would read a
// rect that is not there and clip to nothing.
let binding = self.state.primitives.instance(shape).binding;
assert_eq!(
binding,
@@ -215,9 +169,6 @@ impl<'a> Painter<'a> {
};
let old_parent = if self.own_mask == MaskIdx::NONE {
let slot = self.rsc.ui_mut().masks.push(mask);
// The one ref this widget holds on its own slot, so the slot
// outlives any single frame's primitives; released in
// `UiRenderState::remove`'s `undraw` branch.
self.rsc.ui_mut().masks.push_ref(slot);
self.own_mask = slot;
MaskIdx::NONE
@@ -226,10 +177,6 @@ impl<'a> Painter<'a> {
*self.rsc.ui_mut().masks.get_mut(self.own_mask) = mask;
old
};
// The chain link's own ref, taken before the old one is dropped so
// that re-chaining to the same slot cannot free it in between.
// Released here when the link changes, and in
// `UiRenderState::remove` when this widget's slot goes.
if old_parent != parent {
if parent != MaskIdx::NONE {
self.rsc.ui_mut().masks.push_ref(parent);
@@ -241,14 +188,10 @@ impl<'a> Painter<'a> {
self.mask = self.own_mask;
}
/// Draw a widget within this widget's region. Reading the result's size
/// records that this widget's layout depends on the child.
pub fn widget<'p, W: ?Sized>(&'p mut self, id: &StrongWidget<W>) -> DrawResult<'p, 'a> {
self.widget_at(id, self.region)
}
/// Draws a widget somewhere within this one.
/// Useful for drawing child widgets in select areas.
pub fn widget_within<'p, W: ?Sized>(
&'p mut self,
id: &StrongWidget<W>,
@@ -263,11 +206,6 @@ impl<'a> Painter<'a> {
/// Once retained, it may be updated later in a redraw (for example after
/// measuring a changed child). All deeper descendants inherit it and the
/// CPU hit-test walk resolves the same translation as the shader.
///
/// This offsets the child coordinate space, not this widget: its own
/// primitives and hit region remain fixed. Once allocated, the boundary
/// stays in the chain across redraws; set it to zero to return children to
/// their unshifted positions.
pub fn set_child_offset(&mut self, offset: Vec2) {
let slot = match self.child_move_slot {
Some(slot) => slot,
@@ -322,13 +260,6 @@ impl<'a> Painter<'a> {
region: UiRegion,
) -> DrawResult<'p, 'a> {
self.children.push(id.id());
// Passed directly rather than looked up from `self.active`: this
// widget's own `ActiveData` (which would carry its `move_slot`) is
// not inserted there until *after* its own `Widget::draw` returns,
// so a lookup here -- for a child drawn partway through that same
// call -- would always find nothing. `self.move_slot` is this
// widget's own slot, already known, and always correct regardless
// of insertion order. See `UiRenderState::move_parent_of`.
let parent_move_slot = self.child_move_slot.unwrap_or(self.move_slot);
self.state.draw_inner(
self.layer,
@@ -346,7 +277,6 @@ impl<'a> Painter<'a> {
}
}
/// Place an already-drawn child's used area, redrawing only if its size changes.
pub fn place<'p, W: ?Sized>(
&'p mut self,
id: &StrongWidget<W>,
@@ -436,9 +366,6 @@ impl<'a> Painter<'a> {
self.write_image(handle.image_index(), region);
}
/// A standalone image draws with its own bind group rather than sharing
/// the layer's one instanced draw, so it goes through
/// `Primitives::write_image` instead of `primitive_at`/`Primitive::vec`.
fn write_image(&mut self, texture_idx: u32, region: UiRegion) {
let h = match self.take_recycled(IMAGE_BINDING, Drawn::Yes) {
Some(h) => {
@@ -474,27 +401,16 @@ impl<'a> Painter<'a> {
width: Option<f32>,
) -> RenderedText {
let density = self.state.density;
// Counted here rather than in `TextView::render`, which returns
// its memoized layout without reaching this -- so this counts
// shapes, not requests. `UiRenderState::take_counters`.
self.state.shape_count += 1;
let ui = self.rsc.ui_mut();
ui.text
.render(buffer, attrs, width, &mut ui.textures, density)
}
/// Which glyph atlas the glyphs handed out right now belong to --
/// what a widget caching a [`RenderedText`] across frames has to
/// compare against before re-emitting it (`GlyphAtlas::clear`).
pub fn atlas_generation(&mut self) -> u64 {
self.rsc.ui_mut().text.atlas.generation()
}
/// Draw a laid-out string: one quad per glyph, all sampling the atlas.
///
/// `origin` is where the text's top-left goes; every glyph is placed at an
/// absolute pixel offset from it, so re-drawing after a resize is this loop
/// and nothing else.
pub fn glyphs(&mut self, text: &RenderedText, origin: UiRegion) {
// A caller re-emitting quads placed against an atlas that has since
// been cleared draws every glyph from coordinates now holding
+1 -252
View File
@@ -11,27 +11,16 @@ use crate::{
util::{HashMap, HashSet, Id, Vec2},
};
/// What [`UiRenderState::update`] did on its last call -- read back by the
/// `iris::frame` diagnostic (`iris::diagnostics::log_frame` in the `iris`
/// crate) so a report can tell a full relayout from a frame that only
/// redrew a handful of dirty widgets from one that drew nothing at all.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RedrawKind {
/// Neither the root nor any widget changed -- `update` did nothing.
None,
/// [`UiRenderState::redraw_all`]: a new root, or a resize.
All,
/// [`UiRenderState::redraw_updates`]: only the widgets `needs_redraw`
/// named.
Updates,
}
pub struct UiRenderState {
pub active: HashMap<WidgetId, ActiveData>,
/// Every primitive in the tree, in one arena -- see [`Primitives`] for
/// why it is not per layer.
pub primitives: Primitives,
/// What each layer draws, in order: slots into `primitives`.
pub layers: PrimitiveLayers,
pub(super) output_size: Vec2,
/// Physical pixels per `dp` -- see `Len::dp`'s field doc. `1.0` (an
@@ -43,14 +32,6 @@ pub struct UiRenderState {
old_root: Option<WidgetId>,
resized: bool,
/// The widgets whose `Widget::draw` is on the stack right now -- so
/// [`Self::redraw`] can tell "this widget needs drawing again" from
/// "an ancestor is drawing it at this very moment", where a second
/// draw would leave the first one's primitives behind with nothing
/// owning them. An id is inserted immediately before `draw` is called
/// and removed the moment it returns (both in `draw_inner`), so this
/// is empty between frames -- asserted at the end of `update`.
///
/// It used to only ever be inserted into, and `redraw` removed the id
/// *before* testing for it, which made the test constant `false`: the
/// guard could never fire and the set grew by one entry per widget
@@ -65,14 +46,8 @@ pub struct UiRenderState {
draw_count: u64,
region_mut_count: u64,
mov_count: u64,
/// Text layouts actually computed -- bumped by `Painter::render_text`,
/// which `TextView::render` only reaches on a cache miss.
pub(super) shape_count: u64,
/// `Instant::now()` at construction -- the zero every `iris::frame` line
/// dates itself from, so a report's `now=` is comparable to a harness's
/// own `t_ms` (`Harness::new` builds its `base` the same way, in the
/// same constructor call) without either side needing the wall clock.
epoch: Instant,
/// How many times [`Self::update`] has run -- the `iris::frame` line's
/// frame number. Counts every call, including one that found nothing to
@@ -80,9 +55,6 @@ pub struct UiRenderState {
/// was never asked to run at all (a stalled event loop), not one that
/// ran and did nothing.
frame_no: u64,
/// How long the redraw phase of the last [`Self::update`] took --
/// [`Self::redraw_all`] or [`Self::redraw_updates`], whichever ran, or
/// zero if neither did. Read back by `iris::diagnostics::log_frame`.
last_layout: Duration,
last_redraw_kind: RedrawKind,
/// When the sensor dispatch (`SensorUi::run_sensors`, in the `iris`
@@ -94,7 +66,6 @@ pub struct UiRenderState {
last_input_at: Mutex<Option<Instant>>,
}
/// State retained while replacing one draw with another.
pub(crate) struct Retained {
pub region: Option<UiRegion>,
pub children: Vec<WidgetId>,
@@ -117,21 +88,6 @@ impl Default for Retained {
}
}
/// The bound on the parent walk -- see `resolve_move` in shader.wgsl,
/// which walks the identical chain and must be kept in step with this
/// constant. It exists so a cyclic `parent` link cannot hang either walk,
/// not as a statement about how deep a real tree gets: it was 16, and the
/// transcript screen's composer field turned out to sit **17** slots below
/// the root (measured 2026-09-07 on this checkout's emulator, by tapping
/// the composer in a debug build -- the assert in `resolve_move_chain`
/// prints the chain). A chain past the bound is not reported anywhere at
/// run time; both walks just stop summing, so the widget is drawn and hit
/// tested short by whatever the outer slots held.
///
/// Named for the walk rather than for one of its two subjects: it bounds
/// the move-offset chain *and* the mask chain (`Mask::parent`, walked in
/// the fragment stage), and `MOVE_CHAIN_LIMIT` said only the first
/// (review, 2026-09-07).
pub const PARENT_CHAIN_LIMIT: usize = 64;
impl UiRenderState {
@@ -157,15 +113,6 @@ impl UiRenderState {
}
}
/// Reads and zeroes the (draws, region_mut rewrites, move_offsets
/// writes, text shapes) counters -- call once per frame before
/// `update()` to measure exactly that frame, per LAYOUT.md section 8.
///
/// The fourth is the one a draw count cannot stand in for: a widget
/// can be redrawn without re-shaping (`TextView::render` memoizes by
/// width) and re-shaped without any extra draw, and it is re-shaping
/// that the per-block transcript row exists to avoid -- see
/// `transcript_ui`'s `a_delta_into_a_long_reply_shapes_one_block`.
pub fn take_counters(&mut self) -> (u64, u64, u64, u64) {
(
std::mem::take(&mut self.draw_count),
@@ -179,8 +126,6 @@ impl UiRenderState {
self.mov_count += 1;
}
/// Writes a primitive into the arena and, unless it is
/// [`Drawn::No`], into `layer`'s draw order.
pub(super) fn write_primitive<P: Primitive>(
&mut self,
layer: usize,
@@ -201,8 +146,6 @@ impl UiRenderState {
}
}
/// A standalone image, which draws with its own bind group rather
/// than sharing the layer's one instanced draw.
pub(super) fn write_image(
&mut self,
layer: usize,
@@ -225,23 +168,9 @@ impl UiRenderState {
}
}
/// Compacts every layer's draw order around the primitives freed
/// this frame, corrects the handles that moved, and only then hands
/// the arena slots back for reuse -- that order is the whole reason
/// `Primitives::freed` exists. Once per frame, at the end of
/// [`Self::update`], so the harness (which has no renderer) applies
/// it exactly as a real backend does.
fn apply_free(&mut self) {
for (layer, order) in self.layers.iter_mut() {
for change in order.apply_free() {
// Straight to the handle, never a scan of everything the
// owner drew: a widget freed and redrawn in one frame has
// *every* one of its primitives renumbered here, so a scan
// makes this pass quadratic in that widget's primitive
// count -- 1.37s for one 51,200-glyph text block, against
// 20ms to shape and rasterise the same text (measured
// 2026-09-08). `Primitives::handle_index` is written where
// the handle is taken, in `Painter::own`.
let owner = self.primitives.owner(change.slot);
let Some(idx) = self.primitives.handle_index(change.slot) else {
continue;
@@ -274,12 +203,6 @@ impl UiRenderState {
/// different triggers (a surface resize on every rotation or keyboard
/// open; a density change only if the app follows the display to a
/// different screen, which Android surfaces separately).
///
/// Marks the tree for a full redraw when the value actually changes:
/// every `Len::dp` already resolved and every glyph already shaped
/// (`Text::shape` keys its cache on `(attrs, width, density)`) belongs
/// to the old one, and nothing else would ask for them again
/// (review, 2026-09-07).
pub fn set_density(&mut self, density: f32) {
if density != self.density {
self.resized = true;
@@ -334,30 +257,23 @@ impl UiRenderState {
self.last_layout = layout_start.elapsed();
self.last_redraw_kind = kind;
self.frame_no += 1;
// After the redraw and before anything reads the frame: every
// slot freed above is still named by its layer's draw order until
// this runs.
self.apply_free();
#[cfg(debug_assertions)]
debug_assert!(self.primitive_counts_agree(), "{}", self.orphan_report(rsc),);
}
/// `Instant::now()` at construction -- see the field's own doc.
pub fn epoch(&self) -> Instant {
self.epoch
}
/// How many times [`Self::update`] has run, counting from 1.
pub fn frame_number(&self) -> u64 {
self.frame_no
}
/// How long the last [`Self::update`]'s redraw phase took.
pub fn last_layout_duration(&self) -> Duration {
self.last_layout
}
/// What the last [`Self::update`] did -- see [`RedrawKind`].
pub fn last_redraw_kind(&self) -> RedrawKind {
self.last_redraw_kind
}
@@ -384,17 +300,7 @@ impl UiRenderState {
at.map(|at| now.saturating_duration_since(at))
}
/// Primitive instances every currently-active widget owns, summed --
/// what `iris::frame`'s `primitives=` reports. Not a per-frame delta:
/// `redraw_updates` only rewrites what changed, so this is "how much is
/// on screen", which is what a report reads as "did this frame have
/// more to draw than the last one", not "how much work did this frame
/// do" (`take_counters` answers that).
///
/// A mask's shape does not count: it is a [`Drawn::No`] primitive
/// that is never rasterized, so including it would put one extra on
/// the line for every masked widget and make a number Iris reads off
/// a phone report disagree with what is drawn.
/// Excludes undrawn mask shapes so diagnostics match rasterized primitives.
pub fn active_primitive_count(&self) -> usize {
self.active
.values()
@@ -404,7 +310,6 @@ impl UiRenderState {
fn redraw_all(&mut self, root: Option<&StrongWidget>, rsc: &mut dyn UiRsc) {
self.clear(rsc);
// free all resources & cache
if let Some(id) = root {
self.draw_inner(
0,
@@ -419,16 +324,6 @@ impl UiRenderState {
}
}
/// The slot an *already-active* widget's `move_offsets` entry chains
/// to, read back from `self.active`. Only valid where the parent is
/// guaranteed to already be in `self.active` -- true for `redraw()`,
/// which targets a widget that was fully drawn on some earlier update,
/// but **not** for a widget being drawn as part of its own parent's
/// `Widget::draw` call: that parent's `ActiveData` is not inserted
/// until its `draw` returns (below), so a child drawn partway through
/// it would always read back "no parent" here. `Painter::widget_at`
/// avoids that trap by passing its own already-known `move_slot`
/// straight through instead of asking `self.active` to look it up.
fn move_parent_of(&self, parent: Option<WidgetId>) -> u32 {
parent
.and_then(|p| self.active.get(&p))
@@ -535,9 +430,6 @@ impl UiRenderState {
let move_slot = Self::move_slot_for(old_move_slot, parent_move_slot, rsc);
let inherited_mask = mask;
// `Painter::layer` is a cursor widgets advance while assigning
// layers to their children. Retain the layer this widget itself was
// entered on, not wherever that cursor finishes after `draw`.
let inherited_layer = layer;
let reuse_child_sizes = old_region.map_or([false; 2], |old| {
[
@@ -616,16 +508,10 @@ impl UiRenderState {
id,
} = painter;
// Whatever the draw did not claim is genuinely gone: this draw
// wrote fewer primitives than the last one, or stopped matching
// part way. Freeing it here rather than in `remove` is what lets
// the draw in between reuse the slots -- see
// `Primitives::recycle`.
for h in recycle {
self.free_primitive(&h);
}
// add to active
let active = ActiveData {
id,
region,
@@ -643,7 +529,6 @@ impl UiRenderState {
move_applied: Vec2::ZERO,
};
// remove old children that weren't kept
for c in &old_children {
if !active.children.contains(c) {
self.remove_rec(*c, rsc);
@@ -655,9 +540,6 @@ impl UiRenderState {
size
}
/// This widget's slot in `move_offsets`: the one it already had if it
/// is being redrawn, or a fresh one linked to its parent's.
///
/// A redraw **reuses the slot in place with its delta reset**, never
/// reallocates: the geometry this draw is about to write is already
/// at its correct absolute position, so a delta accumulated before it
@@ -687,11 +569,6 @@ impl UiRenderState {
}
}
/// O(1): write the delta for this widget's own slot in
/// `move_offsets`. No primitive is touched and there is no recursion --
/// every descendant's primitive references this slot transitively
/// through the parent chain the shader walks (`resolve_move`), so it
/// picks the new delta up for free. See LAYOUT.md section 2.
fn mov(&mut self, id: WidgetId, from: UiRegion, to: UiRegion, rsc: &mut dyn UiRsc) {
let Some(active) = self.active.get_mut(&id) else {
return;
@@ -772,22 +649,11 @@ impl UiRenderState {
Some(size)
}
/// Retires `id`'s primitives (unless `keep_primitives`, in which case
/// they come back in the returned `ActiveData` for the redraw about to
/// happen to recycle -- see `Painter::take_recycled`), drops the mask
/// refs they held, and takes the widget out of `active`.
///
/// The handles stay in the returned `ActiveData` either way, freed or
/// not: `remask_shape_users` below reads them, and so does the
/// caller. **A caller that passed `keep_primitives: false` must not
/// free them again** -- they name slots that may already have been
/// handed out.
///
/// The mask refs are dropped either way: a recycled slot is rewritten
/// with whatever mask the *new* draw is under, and that draw takes its
/// own ref (`Painter::write_primitive`).
///
/// NOTE: instance textures are cleared and self.textures freed
fn remove(
&mut self,
id: WidgetId,
@@ -862,11 +728,6 @@ impl UiRenderState {
active
}
/// Retires one primitive: its arena slot and, if a layer's draw order
/// names it, its position there. The two go together -- a slot handed
/// out again while its old order entry still names it would be drawn
/// twice -- which is why this is one function rather than two lines
/// repeated at each call site.
fn free_primitive(&mut self, h: &PrimitiveHandle) {
self.primitives.free(h);
if h.pos != NOT_DRAWN {
@@ -874,26 +735,6 @@ impl UiRenderState {
}
}
/// A mask whose shape primitive was just freed clips to a slot that
/// now holds something else, so the widget that owns it is marked for
/// redraw -- its own `set_mask` is the only thing that resolves the
/// slot, and it is the same mechanism a dirty widget already goes
/// through.
///
/// `own` is the mask belonging to the widget being removed and is
/// skipped: this runs in the middle of that widget's own redraw,
/// which sets its mask again on the way out, and a mark left on
/// itself would redraw it every frame from then on. Skipping it is
/// also what keeps the O(active) scan off the ordinary path -- a
/// plain `.masked()` frees exactly its own shape, so `stale` is empty
/// and this returns before touching `active`.
///
/// Both `Vec`s start empty and stay unallocated in that case, and
/// membership is a linear scan of two lists that are a handful long
/// (a widget's own primitives, and the live masks): this runs once
/// per widget removed, which is once per dirty widget per frame, and
/// a set built there would be an allocation on the phone's frame
/// path in exchange for nothing at these sizes.
fn remask_shape_users(
active: &HashMap<WidgetId, ActiveData>,
id: WidgetId,
@@ -944,15 +785,9 @@ impl UiRenderState {
pub fn redraw_updates(&mut self, rsc: &mut dyn UiRsc) {
while rsc.widgets().has_updates() {
// Expand size dependencies before drawing anything. The parent
// links are the retained widget tree already used by hit testing
// and removal; only the direct-child dependency list is new.
let pending: Vec<_> = rsc.widgets().needs_redraw.iter().copied().collect();
for mut child in pending {
for _ in 0..PARENT_CHAIN_LIMIT {
// An exact hint is the child's current answer without a
// draw. If both axes still match the retained size, no
// parent can observe a size change from this mutation.
if self.size_matches_hints(child, rsc) {
break;
}
@@ -972,8 +807,6 @@ impl UiRenderState {
}
}
// A dirty ancestor draws its dirty descendants on the way down;
// starting those descendants separately would duplicate work.
let dirty: Vec<_> = rsc.widgets().needs_redraw.iter().copied().collect();
let mut roots = Vec::new();
for id in dirty {
@@ -1041,20 +874,6 @@ impl UiRenderState {
self.active.len()
}
/// Primitive instances still bound for the GPU whose owner is no
/// longer in `active`, or whose owner's `ActiveData` no longer names
/// them: a copy nothing can move, clip, resize or free, redrawn every
/// frame at whatever position it last had. `(slot, owner)` each --
/// the arena knows which primitive, not which layer's draw order still
/// names it.
///
/// Asserted empty at the end of every [`Self::update`], because this
/// is exactly the shape of the duplicated transcript row on Iris's
/// phone (`docs/bench/iris-phone-v2-2026-09-06.md`): counting
/// `active` alone cannot see it, since the orphan's owner is very
/// much alive -- it is the *earlier* set of primitives that got
/// stranded when the widget was drawn a second time without the first
/// draw being freed. O(primitives), debug builds only.
pub fn orphaned_primitives(&self) -> Vec<(u32, WidgetId)> {
let mut orphans = Vec::new();
for (slot, owner, _) in self.primitives.live_instances() {
@@ -1069,13 +888,6 @@ impl UiRenderState {
orphans
}
/// Whether every primitive still bound for the GPU is owned by a live
/// widget, decided by counting rather than by walking: an orphan is a
/// live instance no `ActiveData` names, so it can only ever make the
/// live count exceed the owned one. O(active widgets) -- a few dozen --
/// against [`Self::orphaned_primitives`]'s O(primitives), which on a
/// transcript is tens of thousands and made a debug build on a phone
/// too slow to finish a benchmark run.
#[cfg(debug_assertions)]
fn primitive_counts_agree(&self) -> bool {
let live: usize = self.primitives.live_count();
@@ -1083,9 +895,6 @@ impl UiRenderState {
live == owned
}
/// The message [`Self::update`]'s orphan assert prints -- built here
/// rather than inline so the (allocating, O(primitives)) work only
/// happens on the failing path.
#[cfg(debug_assertions)]
fn orphan_report(&self, rsc: &dyn UiRsc) -> String {
let orphans = self.orphaned_primitives();
@@ -1130,26 +939,12 @@ impl UiRenderState {
}
}
/// `active[id].region`, corrected by every `move_offsets` delta between
/// `id` and the root -- the CPU-side twin of the vertex shader's chain
/// walk, over the same arena, so the two cannot disagree about where a
/// widget is. O(chain depth), not O(primitives). See LAYOUT.md
/// section 2b.
pub fn resolved_region(&self, id: &impl IdLike, rsc: &dyn UiRsc) -> Option<UiRegion> {
let active = self.active.get(&id.id())?;
// The chain sum is what the shader adds to this widget's
// *primitives*, which were written before any of those moves.
// `region`, unlike them, has already been shifted by whatever
// part of this widget's own slot `mov` put there -- see
// `ActiveData::move_applied`, which is exactly that part.
let delta = self.resolve_move_chain(active.move_slot, rsc) - active.move_applied;
Some(active.region.offset(UiVec2::abs(delta)))
}
/// The plain-Rust twin of `resolve_move` in shader.wgsl: sums the
/// pixel delta along the parent chain starting at `slot`. Both walks
/// share `PARENT_CHAIN_LIMIT` as their bound so the two cannot disagree
/// about where the chain ends.
fn resolve_move_chain(&self, slot: MoveIdx, rsc: &dyn UiRsc) -> Vec2 {
let offsets = &rsc.ui().move_offsets;
let mut delta = Vec2::ZERO;
@@ -1162,10 +957,6 @@ impl UiRenderState {
return delta;
}
at = Id::preset(entry.parent);
// The chain itself, not just the fact that it was too long: a
// cycle and a tree genuinely nested deeper than the shader can
// follow are different faults with different fixes, and the
// slot numbers are the only thing that tells them apart.
debug_assert!(
i + 1 < PARENT_CHAIN_LIMIT,
"move offset chain exceeded PARENT_CHAIN_LIMIT ({PARENT_CHAIN_LIMIT}): {chain} \
@@ -1178,10 +969,6 @@ impl UiRenderState {
delta
}
/// The parent chain from `slot`, as `slot(dx, dy) -> ...`, walked twice
/// `PARENT_CHAIN_LIMIT` so a cycle shows up as a repeated slot number
/// rather than as a chain that merely stops. Only ever called from the
/// failed assertion above.
fn move_chain_debug(slot: MoveIdx, offsets: &[MoveOffset]) -> String {
let mut parts = Vec::new();
let mut at = slot;
@@ -1201,14 +988,6 @@ impl UiRenderState {
parts.join(" -> ")
}
/// One primitive's corners in window pixels -- the transliteration of
/// `shader.wgsl`'s `corners_of`, `floor` for `floor`. The rounding is
/// the whole reason this is not `region.to_px()`: the shader floors
/// each half separately before adding the move delta, and a hit test
/// that skipped it would disagree with the pixels by up to one along
/// each edge -- invisible in every test written against a whole-pixel
/// layout and wrong on the phone, whose 2.55 density makes nothing
/// land on a whole pixel.
pub fn primitive_corners(&self, slot: u32, rsc: &dyn UiRsc) -> PixelRegion {
let inst = self.primitives.instance(slot);
let delta = self.resolve_move_chain(inst.move_idx, rsc);
@@ -1220,27 +999,10 @@ impl UiRenderState {
}
}
/// Where a mask's clip actually is on screen: the box of the
/// primitive it references. Its *shape* within that box is
/// [`Self::mask_coverage`]'s -- this is the bounding box, which is
/// what a test asking "is the clip over the right part of the screen"
/// wants and all a square-cornered mask has ever had.
pub fn mask_region(&self, mask: MaskIdx, rsc: &dyn UiRsc) -> PixelRegion {
self.primitive_corners(rsc.ui().masks[mask.idx()].primitive, rsc)
}
/// How much of the pixel at `pos` (window pixels) survives `mask` and
/// every mask it nests inside: the referenced primitives' own
/// coverage, multiplied along the chain. The CPU half of
/// `shader.wgsl`'s `fs_main` mask loop -- same order, same bound, same
/// `rounded_rect_coverage` -- so a corner that cannot be tapped and a
/// corner that is not drawn are the same corner (LAYOUT.md's "Masks
/// with a shape", point 4).
///
/// A mask whose shape is not a rect covers everything, exactly as the
/// shader's own `mask_coverage` does: `Painter::set_mask_to` rejects
/// those by name, so this is the unreachable half of the same
/// agreement rather than a second policy.
pub fn mask_coverage(&self, mask: MaskIdx, pos: Vec2, rsc: &dyn UiRsc) -> f32 {
let mut coverage = 1.0;
let mut at = mask;
@@ -1264,18 +1026,10 @@ impl UiRenderState {
coverage
}
/// Whether `pos` is inside `mask` at all -- more than half covered,
/// which is where the drawn edge is (`rounded_rect_coverage`'s doc).
/// What a hit test asks.
pub fn mask_admits(&self, mask: MaskIdx, pos: Vec2, rsc: &dyn UiRsc) -> bool {
self.mask_coverage(mask, pos, rsc) > 0.5
}
/// The first primitive `id`'s subtree wrote this frame, depth first
/// in draw order -- what a mask pointed at a widget clips to
/// (`Painter::set_mask_to_widget`). A widget that draws more than one
/// (a bordered rect is one primitive; a card with a stripe is two)
/// gives its first; a widget that wants another names it.
pub fn first_primitive(&self, id: WidgetId) -> Option<u32> {
let active = self.active.get(&id)?;
if let Some(h) = active.primitives.first() {
@@ -1292,13 +1046,8 @@ impl UiRenderState {
Some(region.to_px(self.output_size))
}
/// redraws a widget that's currently active (drawn)
pub fn redraw(&mut self, id: WidgetId, rsc: &mut dyn UiRsc) {
rsc.widgets_mut().needs_redraw.remove(&id);
// An ancestor is drawing this widget right now, and that draw is
// about to write fresh primitives for it. Drawing it a second time
// here would leave one of the two copies on screen with nothing
// owning it -- see `draw_started`'s own doc.
if self.draw_started.contains(&id) {
return;
}
-8
View File
@@ -45,8 +45,6 @@ impl<T, I: IdNum> Default for Arena<T, I> {
pub struct TrackedArena<T, I> {
inner: Arena<T, I>,
refs: Vec<u32>,
/// Which entries changed since the last upload. Was a `bool`, so one
/// widget getting a move offset re-uploaded every other widget's.
pub dirty: Dirty,
}
@@ -73,17 +71,11 @@ impl<T, I: IdNum> TrackedArena<T, I> {
self.refs[i.idx()] += 1;
}
/// Mutable access to an existing entry, for the rare case (the move
/// offset chain) where an already-allocated slot is updated in place
/// rather than replaced. Marks the arena changed so the GPU copy is
/// re-uploaded.
pub fn get_mut(&mut self, id: Id<I>) -> &mut T {
self.dirty.mark(id.idx());
&mut self.inner.data[id.idx()]
}
/// The entries and the dirty set together -- see
/// `PrimitiveVec::for_upload`.
pub fn for_upload(&mut self) -> (&[T], &mut Dirty) {
(&self.inner.data, &mut self.dirty)
}
-28
View File
@@ -1,19 +1,5 @@
//! Which entries of a GPU-bound array changed since the last upload.
use std::ops::Range;
/// A bitset of dirty entries, coalesced into a handful of ranges when it
/// is time to upload.
///
/// **Why a bitset** rather than the two obvious alternatives, both of
/// which were measured against the bench fixture before this was written
/// (`scripts/rigs/ui-profile`'s `arena_churn`). A `min..max` span is far
/// too coarse: a frame's changes land in 5-20 runs scattered across the
/// whole arena, so the span is very nearly the whole buffer. A `Vec` of
/// touched indices is too expensive to *write*: a streaming frame marks
/// several thousand entries, which would mean an allocation and a sort
/// per frame. Marking a bit is O(1), allocation-free and idempotent, and
/// the scan that reads it back is one word per 64 entries.
#[derive(Default)]
pub struct Dirty {
words: Vec<u64>,
@@ -26,7 +12,6 @@ pub struct Dirty {
}
impl Dirty {
/// Nothing uploaded yet, so nothing may be assumed about the buffer.
pub fn new_all() -> Self {
Self {
words: Vec::new(),
@@ -76,14 +61,6 @@ impl Dirty {
!self.all && self.words.iter().all(|w| *w == 0)
}
/// The ranges to upload, in ascending order, merging two runs
/// separated by a gap of fewer than `gap` entries.
///
/// Merging trades bytes for `write_buffer` calls, and the fixture
/// says the trade is very cheap in one direction: over a fling, a
/// 1 KiB gap costs 0.1% more bytes than merging nothing at all and
/// halves the worst-case call count (23 to 13). Past that it stops
/// paying -- 4 KiB is +2% bytes for two fewer calls.
pub fn ranges(&self, len: usize, gap: usize) -> Vec<Range<usize>> {
if self.all {
return Vec::from_iter((len > 0).then_some(0..len));
@@ -93,15 +70,12 @@ impl Dirty {
let mut bits = *word;
while bits != 0 {
let start = w * 64 + bits.trailing_zeros() as usize;
// The run of set bits starting here, within this word.
let run = (bits >> (start - w * 64)).trailing_ones() as usize;
let end = (start + run).min(len);
if start >= len {
break;
}
match ranges.last_mut() {
// `start - last.end` is the gap; equal ends means
// adjacent, which always merges.
Some(last) if start - last.end <= gap => last.end = end,
_ => ranges.push(start..end),
}
@@ -158,8 +132,6 @@ mod tests {
#[test]
fn ranges_stop_at_the_length() {
// Entries marked and then dropped by a shrink must not be
// uploaded past the end of what the caller is writing.
assert_eq!(marked(&[1, 2, 40], 3, 0), vec![1..3]);
}
-2
View File
@@ -27,8 +27,6 @@ impl<I: IdNum> IdTracker<I> {
impl<I: IdNum> Id<I> {
#[allow(dead_code)]
/// for debug purposes; should this be exposed?
/// generally you want to use labels with widgets
pub(crate) fn raw(id: I) -> Self {
Self(id)
}
-3
View File
@@ -20,12 +20,9 @@ const impl<
T: const Add<Output = T> + const Sub<Output = T> + const Mul<Output = T> + const DivOr + Copy,
> LerpUtil for T
{
/// linear interpolation
/// from * (1.0 - self) + to * self
fn lerp(self, from: Self, to: Self) -> Self {
from + (to - from) * self
}
/// inverse of lerp
fn lerp_inv(self, from: Self, to: Self) -> Self {
(self - from).div_or(to - from, from)
}
-1
View File
@@ -28,7 +28,6 @@ impl<Trait: ?Sized> TypeMap<Trait> {
}
fn convert_mut<T: Unsize<Trait>>(entry: &mut Box<Trait>) -> &mut T {
// allegedly this is just what Any does...
unsafe { &mut *(entry.as_mut() as *mut Trait as *mut T) }
}
}
-1
View File
@@ -61,7 +61,6 @@ impl Vec2 {
}
}
// this version looks kinda cool... is it more readable? more annoying to copy and change though
impl_op!(impl Add for Vec2: add x y);
impl_op!(Vec2 Sub sub; x y);
impl_op!(Vec2 Mul mul; x y);
-1
View File
@@ -3,7 +3,6 @@ use crate::Widget;
pub struct WidgetData {
pub widget: Box<dyn Widget>,
pub label: String,
/// dynamic borrow checking
pub borrowed: bool,
}
-7
View File
@@ -7,11 +7,6 @@ use crate::{
pub type WidgetId = SlotId;
/// An identifier for a widget that can index a UI or event ctx to get it.
/// This is a strong handle that does not impl Clone, and when it is dropped,
/// a signal is sent to the owning UI to clean up the resources.
///
/// TODO: ergonomic clones when they get put in rust-analyzer & don't cause ICEs?
pub struct StrongWidget<W: ?Sized = dyn Widget> {
pub(super) id: WidgetId,
counter: RefCounter,
@@ -19,8 +14,6 @@ pub struct StrongWidget<W: ?Sized = dyn Widget> {
ty: *const W,
}
/// A weak handle to a widget.
/// Will not keep it alive, but can still be used for indexing like WidgetHandle.
pub struct WeakWidget<W: ?Sized = dyn Widget> {
pub(super) id: WidgetId,
#[allow(unused)]
-1
View File
@@ -43,7 +43,6 @@ impl<Rsc, const LEN: usize> WidgetArrLike<Rsc, LEN, ArrTag> for WidgetArr<LEN> {
}
}
// variadic generics please save us
macro_rules! impl_widget_arr {
($n:expr;$($W:ident)*) => {
impl_widget_arr!($n;$($W)*;$(${concat($W,Tag)})*);
-7
View File
@@ -18,12 +18,10 @@ pub use widgets::*;
pub trait Widget: Any {
fn draw(&mut self, painter: &mut Painter);
/// An exact, context-free length known without drawing or inspecting children.
fn size_hint(&self, _axis: Axis) -> Option<Len> {
None
}
/// Whether the draw result is independent of the offered region.
fn is_size_independent(&self) -> bool {
false
}
@@ -32,12 +30,10 @@ pub trait Widget: Any {
false
}
/// The AccessKit role for a labelled widget.
fn access_role(&self) -> accesskit::Role {
accesskit::Role::Unknown
}
/// Advance an animation and report whether it needs another frame.
#[allow(unused_variables)]
fn tick(&mut self, now: std::time::Instant) -> bool {
false
@@ -68,9 +64,6 @@ impl dyn Widget {
}
}
/// A function that returns a widget given a UI.
/// Useful for defining trait functions on widgets that create a parent widget so that the children
/// don't need to be IDs yet
pub trait WidgetFn<State, W: Widget + ?Sized>: FnOnce(&mut State) -> W {}
impl<State, W: Widget + ?Sized, F: FnOnce(&mut State) -> W> WidgetFn<State, W> for F {}
-12
View File
@@ -11,10 +11,6 @@ pub struct Widgets {
send: Sender<WidgetId>,
recv: Receiver<WidgetId>,
pub(crate) waiting: HashSet<WidgetId>,
/// Every widget that has ever been given an explicit `.label()` --
/// `ui::access::AccessTree` walks exactly this set, not the whole
/// arena, so a widget nobody named costs it nothing. Symmetric with
/// `free_next` below, which is this set's one removal path.
named: HashSet<WidgetId>,
}
@@ -44,8 +40,6 @@ impl Widgets {
Some(self.vec.get_mut(id)?.widget.as_mut())
}
/// get_dyn but dynamic borrow checking of widgets
/// lets you do recursive (tree) operations, like the painter does
pub(crate) fn get_dyn_dynamic<'a>(&self, id: WidgetId) -> WidgetWrapper<'a> {
// SAFETY: must guarantee no other mutable references to this widget exist
// done through the borrow variable
@@ -101,18 +95,12 @@ impl Widgets {
&self.data(id.id()).unwrap().label
}
/// Also the one place a widget opts into `ui::access`'s AccessKit tree
/// (RUST.md's I4) -- see `named`'s doc comment.
pub fn set_label(&mut self, id: impl IdLike, label: String) {
let id = id.id();
self.data_mut(id).unwrap().label = label;
self.named.insert(id);
}
/// Every widget with an explicit name, for `ui::access::AccessTree` to
/// walk. Order is unspecified; `AccessTree` doesn't need one; a screen
/// reader's own traversal is worked out by uiautomator from each
/// node's on-screen bounds instead.
pub fn named(&self) -> impl Iterator<Item = WidgetId> + '_ {
self.named.iter().copied()
}