iris is the framework alone; the app is one crate in app-rust/

Iris: "the organization of the rust rewrite is a mess right now... there
shouldn't be anything related to the app inside of iris. Iris is supposed
to be the UI framework alone." And, on the crate count: "I'm confused why
the app only code needs more than one crate though."

Nine cargo workspaces become three, and the port's project code -- which
sat in five places, four of them inside the framework -- becomes one crate,
`ai-app`, in `app-rust/`:

  client-core                -> app-rust/src/client
  iris/transcript-ui         -> app-rust/src/ui
  iris/transcript-fixture    -> app-rust/src/ui/fixture.rs + tests/ + touch/
  iris/desktop-app           -> app-rust/src/desktop + src/bin_desktop.rs
  iris/android-app           -> app-rust/src/android + android-project/
  android-shell              -> app-rust/src/shell

iris/ keeps core, macro, the iris crate, tabs-ui and rig-input, and now
mentions no session, transcript, setup or server anywhere.

Only two of the old splits had a reason that survived reading. event-model
stays a crate at the repo root because server/ depends on it too, so a
crate is what makes the backend and the app agree by construction. The two
Android .so names looked like a hard constraint -- a package produces one
library artifact -- until P2 turned out to already plan merging those two
Android apps into one; both faces now come out of libai_app.so, picked
apart by features so `--no-default-features --features shell` keeps wgpu,
parley and iris out of the Compose app's APK. docs/RUST.md's "One app
crate" has the rest, including what each remaining feature is for.

DECISIONS.md and SUBAGENTS.md move into docs/ with everything else.

Verified: ./run-tests.sh and `cd iris && cargo test` green, clippy and fmt
clean in all five workspaces, `cargo ndk -t x86_64` links libai_app.so,
build-apk.sh produces an APK that installs and launches on this checkout's
emulator (Gl ... virgl, as expected), and the phone-sized headless
screenshot renders the transcript unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-08 23:36:38 -04:00
1 parent 7b54aaf3c4
commit a9312e9431
113 files changed
+23221 -2992

No files matched your search

+9 -2
View File
@@ -4,9 +4,16 @@ version.workspace = true
edition.workspace = true
[dependencies]
winit = { workspace = true }
wgpu = { workspace = true }
# Only for `UiRenderNode::new`'s `push_error_scope`/`pop_error_scope` pair
# (renderer-creation error reporting, RUST.md's P0 phone-crash box) --
# `block_on` turns that one async pop into the same synchronous call shape
# `device_limits()`'s two callers already use for `request_adapter`/
# `request_device`, rather than making this crate's one entry point async.
pollster = { workspace = true }
bytemuck ={ workspace = true }
image = { workspace = true }
cosmic-text = { workspace = true }
parley = { workspace = true }
swash = { workspace = true }
fxhash = { workspace = true }
accesskit = { workspace = true }
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014 Ryan L McIntyre
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Binary file not shown.
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Rebuilds iris/core/assets/fonts/nerd_icons.ttf.
#
# iris draws its icons as glyphs in a Nerd Fonts subset it ships, rather
# than as ordinary Unicode out of whatever the platform resolved. Unicode's
# own geometric shapes are what this replaced: `tool.rs` set its disclosure
# mark with U+25B8/25BE/25B4, and once iris stopped bundling fonts
# (2026-09-07) Iris's phone drew an empty box for them and this VM drew a
# dot. UI_RULES: "don't rely on characters the platform might not have --
# ship the glyph or the asset rather than hoping."
#
# The whole symbols font is 3 MB for the handful below, so what is
# committed is a subset. Add a codepoint to GLYPHS *and* to
# `iris/core/src/icon.rs` (the two lists have to agree -- a codepoint in
# the Rust that this script did not subset is a glyph that silently isn't
# there), then run this and commit the result.
#
# Needs python3 and network access; fontTools is fetched into a temporary
# venv, so nothing has to be installed on the machine.
#
# The same arrangement as the Compose app's `app/build-icon-font.sh`, which
# this is copied from -- including the Mono face and the Material Design
# family, so an icon means the same thing in both apps. Copied rather than
# shared because most of it is the GLYPHS list, which has to differ: the
# point of subsetting is to ship only the codepoints one app draws.
set -euo pipefail
# Codepoint, then the Nerd Fonts glyph name it came from. Material Design
# Icons, as in the Compose app.
GLYPHS=(
U+F035D # md-menu_down -- a card that is open
U+F035F # md-menu_right -- a card that opens
U+F0360 # md-menu_up -- collapse this group again
)
url=https://github.com/ryanoasis/nerd-fonts/releases/latest/download/NerdFontsSymbolsOnly.zip
here="$(cd "$(dirname "$0")" && pwd)"
out="$here/assets/fonts/nerd_icons.ttf"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
echo "Fetching $url"
curl -fsSL -o "$work/nf.zip" "$url"
python3 -c 'import sys,zipfile; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])' "$work/nf.zip" "$work"
python3 -m venv "$work/venv"
"$work/venv/bin/pip" -q install fonttools
unicodes="$(IFS=,; echo "${GLYPHS[*]}")"
mkdir -p "$(dirname "$out")"
# The Mono face, where every glyph is one em wide and one em tall, so two
# icons at the same font size are the same size without either being given
# one -- the same reason the Compose app's script takes it. It is also what
# makes an icon's box predictable beside a line of text.
"$work/venv/bin/pyftsubset" "$work/SymbolsNerdFontMono-Regular.ttf" \
--unicodes="$unicodes" \
--layout-features= \
--drop-tables+=DSIG \
--output-file="$out"
cp "$work/LICENSE" "$here/assets/fonts/NERD_FONTS_LICENSE.txt"
echo "Wrote $out ($(stat -c %s "$out") bytes) with ${#GLYPHS[@]} glyphs"
+1 -1
View File
@@ -1,4 +1,4 @@
use crate::{UiRsc, WidgetIdFn, WidgetLike, WeakWidget};
use crate::{UiRsc, WeakWidget, WidgetIdFn, WidgetLike};
pub trait WidgetAttr<Rsc, W: ?Sized> {
type Input;
+15
View File
@@ -79,6 +79,8 @@ 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>>>,
}
@@ -107,6 +109,7 @@ impl<Rsc: HasEvents, E: Event> Default for TypeEventManager<Rsc, E> {
fn default() -> Self {
Self {
active: Default::default(),
global: Default::default(),
map: Default::default(),
}
}
@@ -135,6 +138,18 @@ impl<Rsc: HasEvents + 'static, E: Event> TypeEventManager<Rsc, E> {
));
}
/// The event lists this widget was registered with (`register`'s
/// `event` argument, one per call), without running anything. Lets a
/// caller ask "would this widget's registrations match the current
/// state" separately from actually dispatching to it -- used by
/// `sense.rs` to decide whether a widget genuinely consumes a scroll
/// or press this frame (so a lower layer can still receive it if not)
/// without that decision being conflated with "the cursor happens to
/// be over it," which is all `run_fn` running something tells you.
pub fn registered(&self, id: WidgetId) -> impl Iterator<Item = &E> {
self.map.get(&id).into_iter().flatten().map(|(e, _)| e)
}
pub fn run_fn<'a>(
&mut self,
id: impl IdLike,
+14
View File
@@ -9,6 +9,20 @@ 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>> {
Some(data.clone())
+39
View File
@@ -0,0 +1,39 @@
//! 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 (DECISIONS.md,
//! 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}";
+1 -3
View File
@@ -2,12 +2,9 @@
#![feature(const_ops)]
#![feature(const_trait_impl)]
#![feature(const_convert)]
#![feature(map_try_insert)]
#![feature(unboxed_closures)]
#![feature(fn_traits)]
#![feature(const_cmp)]
#![feature(const_destruct)]
#![feature(portable_simd)]
#![feature(associated_type_defaults)]
#![feature(unsize)]
#![feature(coerce_unsized)]
@@ -22,6 +19,7 @@ mod render;
mod ui;
mod widget;
pub mod icon;
pub mod util;
pub use attr::*;
+5 -5
View File
@@ -5,19 +5,19 @@ pub const trait UiNum {
fn to_f32(self) -> f32;
}
impl const UiNum for f32 {
const impl UiNum for f32 {
fn to_f32(self) -> f32 {
self
}
}
impl const UiNum for u32 {
const impl UiNum for u32 {
fn to_f32(self) -> f32 {
self as f32
}
}
impl const UiNum for i32 {
const impl UiNum for i32 {
fn to_f32(self) -> f32 {
self as f32
}
@@ -27,7 +27,7 @@ pub const fn vec2(x: impl const UiNum, y: impl const UiNum) -> Vec2 {
Vec2::new(x.to_f32(), y.to_f32())
}
impl<T: const UiNum + Copy> const From<T> for Vec2 {
const impl<T: const UiNum + Copy> From<T> for Vec2 {
fn from(v: T) -> Self {
Self {
x: v.to_f32(),
@@ -36,7 +36,7 @@ impl<T: const UiNum + Copy> const From<T> for Vec2 {
}
}
impl<T: const UiNum, U: const UiNum> const From<(T, U)> for Vec2
const impl<T: const UiNum, U: const UiNum> From<(T, U)> for Vec2
where
(T, U): const Destruct,
{
+1 -1
View File
@@ -187,7 +187,7 @@ impl From<CardinalAlign> for Align {
}
}
impl const From<RegionAlign> for UiVec2 {
const impl From<RegionAlign> for UiVec2 {
fn from(align: RegionAlign) -> Self {
Self::rel(align.rel())
}
+3 -3
View File
@@ -1,6 +1,6 @@
use super::*;
#[derive(Copy, Clone, Eq, PartialEq)]
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub enum Axis {
X,
Y,
@@ -74,14 +74,14 @@ pub const trait AxisT {
}
pub struct XAxis;
impl const AxisT for XAxis {
const impl AxisT for XAxis {
fn get() -> Axis {
Axis::X
}
}
pub struct YAxis;
impl const AxisT for YAxis {
const impl AxisT for YAxis {
fn get() -> Axis {
Axis::Y
}
+87 -7
View File
@@ -9,7 +9,31 @@ pub struct Size {
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Len {
/// Physical pixels -- a raw device pixel, unaffected by the display's
/// density. Rare to want directly (a hairline border is the usual
/// case); most sizes should be `dp` instead. See `dp`'s own doc for why
/// 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,
}
@@ -67,10 +91,10 @@ impl Size {
}
}
pub fn to_uivec2(self) -> UiVec2 {
pub fn to_uivec2(self, density: f32) -> UiVec2 {
UiVec2 {
x: self.x.apply_rest(),
y: self.y.apply_rest(),
x: self.x.apply_rest(density),
y: self.y.apply_rest(density),
}
}
@@ -98,26 +122,66 @@ impl Size {
impl Len {
pub const ZERO: Self = Self {
abs: 0.0,
dp: 0.0,
rel: 0.0,
rest: 0.0,
};
pub const REST: Self = Self {
abs: 0.0,
dp: 0.0,
rel: 0.0,
rest: 1.0,
};
pub fn apply_rest(&self) -> UiScalar {
/// Resolves to a `UiScalar`, folding `dp` into `abs` pixels against
/// `density` (physical pixels per dp -- 1.0 on a desktop or an
/// unscaled display, `content_scale` on Android; see `dp`'s field
/// doc). Every other component of `Len` is already resolution-
/// independent (`rel` is a fraction of the parent; `rest` becomes a
/// fraction too, below), so `density` only ever touches this one term.
pub fn apply_rest(&self, density: f32) -> UiScalar {
UiScalar {
rel: self.rel + if self.rest > 0.0 { 1.0 } else { 0.0 },
abs: self.abs,
abs: self.abs + self.dp * density,
}
}
/// The same fold as [`Self::apply_rest`] but staying a `Len`, so
/// `rest` survives: `dp` becomes physical pixels and every other
/// component is left alone.
///
/// **A `Len` a widget *reports* must have been through this.** `dp` is
/// an input unit -- a number the widget author wrote -- and the
/// containers that consume a reported length read `abs`/`rel`/`rest`
/// directly (`Span::draw`'s placement arithmetic, `Pad`'s addition),
/// so a reported `dp` is silently worth zero. That is what made the
/// composer's bar collapse to nothing the moment its content grew past
/// `MaxSize`'s cap: the cap was `dp(168)` and was returned unresolved,
/// so the bar was given a slot of 0 and the field inside it was panned
/// out of a container measured at -63px. `UiRenderState::draw_inner`
/// debug-asserts the invariant after every `Widget::draw`.
pub fn fold_dp(&self, density: f32) -> Self {
Self {
abs: self.abs + self.dp * density,
dp: 0.0,
rel: self.rel,
rest: self.rest,
}
}
pub fn abs(abs: impl UiNum) -> Self {
Self {
abs: abs.to_f32(),
dp: 0.0,
rel: 0.0,
rest: 0.0,
}
}
pub fn dp(dp: impl UiNum) -> Self {
Self {
abs: 0.0,
dp: dp.to_f32(),
rel: 0.0,
rest: 0.0,
}
@@ -125,6 +189,7 @@ impl Len {
pub fn rel(rel: impl UiNum) -> Self {
Self {
abs: 0.0,
dp: 0.0,
rel: rel.to_f32(),
rest: 0.0,
}
@@ -132,6 +197,7 @@ impl Len {
pub fn rest(ratio: impl UiNum) -> Self {
Self {
abs: 0.0,
dp: 0.0,
rel: 0.0,
rest: ratio.to_f32(),
}
@@ -144,6 +210,15 @@ pub mod len_fns {
pub fn abs(abs: impl UiNum) -> Len {
Len {
abs: abs.to_f32(),
dp: 0.0,
rel: 0.0,
rest: 0.0,
}
}
pub fn dp(dp: impl UiNum) -> Len {
Len {
abs: 0.0,
dp: dp.to_f32(),
rel: 0.0,
rest: 0.0,
}
@@ -151,6 +226,7 @@ pub mod len_fns {
pub fn rel(rel: impl UiNum) -> Len {
Len {
abs: 0.0,
dp: 0.0,
rel: rel.to_f32(),
rest: 0.0,
}
@@ -158,14 +234,15 @@ pub mod len_fns {
pub fn rest(ratio: impl UiNum) -> Len {
Len {
abs: 0.0,
dp: 0.0,
rel: 0.0,
rest: ratio.to_f32(),
}
}
}
impl_op!(Len Add add; abs rel rest);
impl_op!(Len Sub sub; abs rel rest);
impl_op!(Len Add add; abs dp rel rest);
impl_op!(Len Sub sub; abs dp rel rest);
impl_op!(Size Add add; x y);
impl_op!(Size Sub sub; x y);
@@ -187,6 +264,9 @@ impl std::fmt::Display for Len {
if self.abs != 0.0 {
write!(f, "{} abs;", self.abs)?;
}
if self.dp != 0.0 {
write!(f, "{} dp;", self.dp)?;
}
if self.rel != 0.0 {
write!(f, "{} rel;", self.rel)?;
}
+3 -3
View File
@@ -124,13 +124,13 @@ impl Display for UiVec2 {
impl_op!(UiVec2 Add add; x y);
impl_op!(UiVec2 Sub sub; x y);
impl const From<Vec2> for UiVec2 {
const impl From<Vec2> for UiVec2 {
fn from(abs: Vec2) -> Self {
Self::abs(abs)
}
}
impl<T: const UiNum, U: const UiNum> const From<(T, U)> for UiVec2
const impl<T: const UiNum, U: const UiNum> From<(T, U)> for UiVec2
where
(T, U): const Destruct,
{
@@ -421,7 +421,7 @@ impl Display for UiRegion {
}
}
#[derive(Debug)]
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PixelRegion {
pub top_left: Vec2,
pub bot_right: Vec2,
+11 -2
View File
@@ -10,6 +10,15 @@ pub struct Color<T> {
pub a: T,
}
/// Required by parley's `Brush`, which every text style is generic over. Opaque
/// black rather than transparent: a brush that was never set should be visible
/// and obviously unstyled, not invisible.
impl<T: ColorNum> Default for Color<T> {
fn default() -> Self {
Self::BLACK
}
}
impl<T: ColorNum> Color<T> {
pub const BLACK: Self = Self::rgb(T::MIN, T::MIN, T::MIN);
pub const WHITE: Self = Self::rgb(T::MAX, T::MAX, T::MAX);
@@ -144,7 +153,7 @@ impl ColorNum for f32 {
unsafe impl bytemuck::Pod for Color<u8> {}
impl const F32Conversion for f32 {
const impl F32Conversion for f32 {
fn to(self) -> f32 {
self
}
@@ -153,7 +162,7 @@ impl const F32Conversion for f32 {
}
}
impl const F32Conversion for u8 {
const impl F32Conversion for u8 {
fn to(self) -> f32 {
self as f32 / 255.0
}
+5 -19
View File
@@ -1,9 +1,6 @@
use std::ops::{Index, IndexMut};
use crate::{
render::{MaskIdx, Primitive, PrimitiveHandle, PrimitiveInst, Primitives},
util::to_mut,
};
use crate::{render::LayerOrder, util::to_mut};
pub type LayerId = usize;
@@ -39,7 +36,10 @@ struct Child {
tail: usize,
}
pub type PrimitiveLayers = Layers<Primitives>;
/// 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> {
pub fn new() -> Layers<T> {
@@ -119,20 +119,6 @@ impl<T: Default> Layers<T> {
}
}
impl PrimitiveLayers {
pub fn write<P: Primitive>(
&mut self,
layer: LayerId,
info: PrimitiveInst<P>,
) -> PrimitiveHandle {
self[layer].write(layer, info)
}
pub fn free(&mut self, h: &PrimitiveHandle) -> MaskIdx {
self[h.layer].free(h)
}
}
impl<T: Default> Default for Layers<T> {
fn default() -> Self {
Self::new()
+725 -139
View File
@@ -1,60 +1,444 @@
use crate::{Align, RegionAlign, TextureHandle, Textures, UiColor, util::Vec2};
use cosmic_text::{
Attrs, AttrsList, Buffer, CacheKey, Color, Family, FontSystem, Metrics, Placement, SwashCache,
SwashContent,
use crate::{Align, GlyphAtlas, GlyphKey, PlacedGlyph, RegionAlign, Textures, UiColor, util::Vec2};
use parley::{
Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, FontStyle, FontWeight,
GenericFamily, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty,
fontique::Blob,
};
use std::ops::Range;
use std::sync::Arc;
use swash::{
FontRef,
scale::{Render, ScaleContext, Source, StrikeWith},
zeno::{Format, Vector},
};
use image::{DynamicImage, GenericImageView, RgbaImage};
use std::simd::{Simd, num::SimdUint};
/// TODO: properly wrap this
pub mod text_lib {
pub use cosmic_text::*;
/// 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
/// (DECISIONS.md, 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>,
pub mono_resolved: Option<String>,
/// The family the bundled icon font registered under, or `None` if
/// registering it failed. Reported rather than assumed: it is the one
/// font iris ships, so `None` is a broken build and must not look
/// like a device that happens to lack a face.
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_system: FontSystem,
pub swash_cache: SwashCache,
glyph_cache: Vec<(Placement, CacheKey, Color)>,
pub font_cx: FontContext,
pub layout_cx: LayoutContext<UiColor>,
scale_cx: ScaleContext,
pub atlas: GlyphAtlas,
/// Physical pixels per dp -- a second copy of
/// `UiRenderState::density`, kept here too because `TextEditCtx::layout`
/// (cursor movement and hit-testing, `widget/text/edit.rs`) shapes text
/// from an event callback that has a `TextData` but no `Painter`, so it
/// has nowhere else to read the display's density from. Both copies are
/// set together, from the one place either backend learns the real
/// value (`android::view::new_peer`); this is the same accepted
/// duplication as `AndroidRenderer::content_scale`; a single source of
/// truth would mean carrying a `Painter` (or output size) into every
/// input handler for the sake of one field.
pub density: f32,
/// The family name [`NERD_ICONS`] registered under, which is what
/// [`Family::Icons`] resolves to. `None` only if registering the
/// bundled font failed, which is a broken build rather than a
/// platform difference -- said in the startup diagnostics rather than
/// silently drawn as tofu.
pub icon_family: Option<String>,
}
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 DECISIONS.md's 2026-09-07 entry 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);
let icon_family = register_icon_font(&mut font_cx);
Self {
font_system: FontSystem::new(),
swash_cache: SwashCache::new(),
glyph_cache: Default::default(),
font_cx,
layout_cx: LayoutContext::new(),
scale_cx: ScaleContext::new(),
atlas: GlyphAtlas::default(),
density: 1.0,
icon_family,
}
}
}
#[derive(Clone, Copy)]
/// 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
.collection
.register_fonts(blob, None)
.into_iter()
.map(|(id, _)| id)
.next()?;
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 DECISIONS.md's 2026-09-07 entry,
/// "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
/// *actually* scanned families (from `/system/fonts`, which do carry real
/// font data, just under whatever name the font's own metadata gives it --
/// "Droid Sans Mono" here, but that name is never hardcoded) owns a font
/// file with that name, and registers that family as the `Monospace`
/// generic the way the backend itself would have if its parser had reified
/// the declaration. A no-op if the family is somehow already resolved
/// (future fontique) or nothing matches (no `fonts.xml`, e.g. a headless
/// test, or a device that names it some other way).
#[cfg(target_os = "android")]
fn patch_android_monospace(font_cx: &mut FontContext) {
use parley::fontique::SourceKind;
let already_resolved = font_cx
.collection
.generic_families(GenericFamily::Monospace)
.next()
.is_some();
if already_resolved {
return;
}
let Some(target_file) = android_monospace_font_filename() else {
return;
};
let names: Vec<String> = font_cx
.collection
.family_names()
.map(str::to_string)
.collect();
for name in names {
let Some(id) = font_cx.collection.family_id(&name) else {
continue;
};
let Some(info) = font_cx.collection.family(id) else {
continue;
};
let Some(font) = info.default_font() else {
continue;
};
let SourceKind::Path(path) = font.source().kind() else {
continue;
};
if path.file_name().and_then(|f| f.to_str()) == Some(target_file.as_str()) {
font_cx
.collection
.append_generic_families(GenericFamily::Monospace, std::iter::once(id));
return;
}
}
}
/// 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());
let xml =
std::fs::read_to_string(std::path::Path::new(&android_root).join("etc/fonts.xml")).ok()?;
let family_start = xml.find("<family name=\"monospace\">")?;
let block = &xml[family_start..];
let block = &block[..block.find("</family>")?];
let font_tag = block.find("<font")?;
let after_tag = &block[font_tag..];
let content_start = after_tag.find('>')? + 1;
let content = &after_tag[content_start..];
let filename = content[..content.find('<')?].trim();
(!filename.is_empty()).then(|| filename.to_string())
}
#[cfg(not(target_os = "android"))]
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.
pub fn resolve_family(&self, family: &Family) -> Family {
match family {
Family::Icons => self
.icon_family
.clone()
.map_or(Family::Icons, Family::Named),
other => other.clone(),
}
}
/// 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();
let default_family_id = self
.font_cx
.collection
.generic_families(GenericFamily::SansSerif)
.next();
let default_family = default_family_id
.and_then(|id| self.font_cx.collection.family_name(id).map(str::to_string));
let default_mono_family_id = self
.font_cx
.collection
.generic_families(GenericFamily::Monospace)
.next();
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;
{
let mut query = self
.font_cx
.collection
.query(&mut self.font_cx.source_cache);
query.set_families([generic]);
query.set_attributes(Attributes {
width: FontWidth::NORMAL,
style,
weight,
});
query.matches_with(|font| {
family_id = Some(font.family.0);
QueryStatus::Stop
});
}
family_id.and_then(|id| self.font_cx.collection.family_name(id).map(str::to_string))
};
let regular_resolved = resolve_family(
GenericFamily::SansSerif,
FontWeight::NORMAL,
FontStyle::Normal,
);
let bold_resolved = resolve_family(
GenericFamily::SansSerif,
FontWeight::BOLD,
FontStyle::Normal,
);
let italic_resolved = resolve_family(
GenericFamily::SansSerif,
FontWeight::NORMAL,
FontStyle::Italic,
);
let mono_resolved = resolve_family(
GenericFamily::Monospace,
FontWeight::NORMAL,
FontStyle::Normal,
);
FontDiagnostics {
families_found,
default_family,
default_mono_family,
regular_resolved,
bold_resolved,
italic_resolved,
mono_resolved,
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<'static>,
pub family: Family,
pub wrap: bool,
/// inner alignment of text region (within where it's drawn)
pub align: RegionAlign,
}
impl TextAttrs {
pub fn apply(&self, font_system: &mut FontSystem, buf: &mut Buffer, width: Option<f32>) {
buf.set_metrics_and_size(
font_system,
Metrics::new(self.font_size, self.line_height),
width,
None,
);
let attrs = Attrs::new().family(self.family);
let list = AttrsList::new(&attrs);
for line in &mut buf.lines {
line.set_attrs_list(list.clone());
}
}
}
pub type TextBuffer = Buffer;
pub const LINE_HEIGHT_MULT: f32 = 1.1;
impl Default for TextAttrs {
fn default() -> Self {
@@ -70,122 +454,324 @@ impl Default for TextAttrs {
}
}
pub const LINE_HEIGHT_MULT: f32 = 1.1;
/// 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 {
pub fn draw(
/// 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() {
for item in line.items() {
let PositionedLayoutItem::GlyphRun(run) = item else {
continue;
};
let font = run.run().font();
let font_size = run.run().font_size();
let coords = run.run().normalized_coords();
let run_color = run.style().brush;
let Some(font_ref) = FontRef::from_index(font.data.as_ref(), font.index as usize)
else {
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() {
let subpixel = ((glyph.x.fract() * 4.0).round() as i32).rem_euclid(4) as u8;
let key = GlyphKey {
font: font_id,
glyph: glyph.id,
size: (font_size * 16.0).round() as u32,
subpixel,
coords: coords_hash,
};
let entry = match self.atlas.get(&key) {
Some(entry) => entry,
None => {
let mut scaler = self
.scale_cx
.builder(font_ref)
.size(font_size)
.hint(true)
.normalized_coords(coords)
.build();
let image = Render::new(&[
Source::ColorOutline(0),
Source::ColorBitmap(StrikeWith::BestFit),
Source::Outline,
])
.format(Format::Alpha)
.offset(Vector::new(subpixel as f32 / 4.0, 0.0))
.render(&mut scaler, glyph.id as u16);
match image {
Some(image) => self.atlas.insert(key, &image, textures),
None => {
self.atlas.insert_empty(key);
None
}
}
}
};
let Some(entry) = entry else { continue };
placed.push(PlacedGlyph {
entry,
offset: Vec2::new(
glyph.x.floor() + entry.left as f32,
glyph.y.floor() - entry.top as f32,
),
color: run_color,
});
}
}
}
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,
attrs: &TextAttrs,
width: Option<f32>,
textures: &mut Textures,
density: f32,
) -> RenderedText {
// TODO: either this or the layout stuff (or both) is super slow,
// should probably do texture packing and things if possible.
// very visible if you add just a couple of wrapping texts and resize window
// should also be timed to figure out exactly what points need to be sped up
// let mut pixels = HashMap::<_, [u8; 4]>::default();
let mut min_x = 0;
let mut min_y = 0;
let mut max_x = 0;
let mut max_y = 0;
let text_color = {
let c = attrs.color;
cosmic_text::Color::rgba(c.r, c.g, c.b, c.a)
};
let mut max_width = 0.0f32;
let mut height = 0.0;
for run in buffer.layout_runs() {
for glyph in run.glyphs.iter() {
let physical_glyph = glyph.physical((0., 0.), 1.0);
let glyph_color = match glyph.color_opt {
Some(some) => some,
None => text_color,
};
if let Some(img) = self
.swash_cache
.get_image(&mut self.font_system, physical_glyph.cache_key)
{
let mut pos = img.placement;
pos.left += physical_glyph.x;
pos.top = physical_glyph.y + run.line_y as i32 - pos.top;
min_x = min_x.min(pos.left);
min_y = min_y.min(pos.top);
max_x = max_x.max(pos.left + pos.width as i32);
max_y = max_y.max(pos.top + pos.height as i32);
self.glyph_cache
.push((pos, physical_glyph.cache_key, glyph_color));
}
}
max_width = max_width.max(run.line_w);
height += run.line_height;
}
let img_width = (max_x - min_x + 1) as u32;
let img_height = (max_y - min_y + 1) as u32;
let mut image = RgbaImage::new(img_width, img_height);
for (pos, key, color) in self.glyph_cache.drain(..) {
let img = self
.swash_cache
.get_image(&mut self.font_system, key)
.as_ref()
.unwrap();
let mut merge = |i, color: [u8; 4]| {
let i = i as i32;
let x = (i % pos.width as i32 + pos.left - min_x) as u32;
let y = (i / pos.width as i32 + pos.top - min_y) as u32;
let pixel = &mut image[(x, y)].0;
// TODO: no clue if proper alpha blending should be done
*pixel = Simd::from(color).saturating_add(Simd::from(*pixel)).into();
};
match img.content {
SwashContent::Mask => {
for (i, a) in img.data.iter().enumerate() {
let mut color = color.as_rgba();
color[3] = ((color[3] as u32 * *a as u32) / u8::MAX as u32) as u8;
merge(i, color);
}
}
SwashContent::SubpixelMask => todo!("subpixel mask text rendering"),
SwashContent::Color => {
let (colors, _) = img.data.as_chunks::<4>();
for (i, color) in colors.iter().enumerate() {
merge(i, *color);
}
}
}
}
let max_dim = 8192;
if image.width() > max_dim || image.height() > max_dim {
let width = image.width().min(max_dim);
let height = image.height().min(max_dim);
eprintln!(
"WARNING: image of size {:?} cropped to {:?} (texture too big)",
image.dimensions(),
(width, height)
);
image = image.view(0, 0, width, height).to_image();
}
buffer.shape(self, attrs, width, density);
let glyphs = self.place(buffer, textures);
RenderedText {
handle: textures.add(image),
top_left_offset: Vec2::new(min_x as f32, min_y as f32),
size: Vec2::new(max_width, height),
glyphs: std::sync::Arc::new(glyphs),
size: buffer.size(),
color: attrs.color,
generation: self.atlas.generation(),
}
}
}
#[derive(Clone)]
pub struct RenderedText {
pub handle: TextureHandle,
pub top_left_offset: Vec2,
pub size: Vec2,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::icon;
pub trait HasTextures {
fn add_texture(&mut self, image: DynamicImage) -> TextureHandle;
/// 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");
let charmap = font.charmap();
for (name, glyph) in [
("OPEN", icon::OPEN),
("CLOSED", icon::CLOSED),
("COLLAPSE", icon::COLLAPSE),
] {
let mut chars = glyph.chars();
let ch = chars.next().expect("an icon is one character");
assert!(chars.next().is_none(), "{name} is more than one character");
assert_ne!(
charmap.map(ch),
0,
"{name} (U+{:04X}) is not in nerd_icons.ttf -- add it to \
build-icon-font.sh's GLYPHS and rerun the script",
ch as u32
);
}
}
/// 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();
let family = data.resolve_family(&Family::Icons);
assert!(
matches!(family, Family::Named(_)),
"the bundled icon font did not register: {:?}",
data.icon_family
);
}
}
+294 -35
View File
@@ -1,19 +1,44 @@
use crate::{
render::TexturePrimitive,
util::{RefCounter, Vec2},
};
use crate::util::{RefCounter, Vec2};
use image::{DynamicImage, GenericImageView};
use std::{
collections::HashMap,
ops::Index,
sync::mpsc::{Receiver, Sender, channel},
};
/// Which of the two things a texture slot holds. See TEXTURES.md's
/// "Recommended shape" for why these are drawn so differently: a page is a
/// layer of one shared array texture and never gets its own bind group; a
/// standalone image is the opposite, one texture and one bind group, never a
/// layer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TextureKind {
Image,
/// The array-texture layer this page was assigned. Chosen synchronously
/// by `Textures::add_page` rather than by the renderer, because glyph
/// insertion needs it in the same call, before any GPU sync happens.
Page {
layer: u32,
},
}
/// 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,
pub id: u64,
}
#[derive(Debug, Clone)]
pub struct TextureHandle {
inner: TexturePrimitive,
slot: u32,
kind: TextureKind,
size: Vec2,
counter: RefCounter,
send: Sender<u32>,
send: Sender<(TextureKind, u32)>,
}
/// a texture manager for a ui
@@ -21,22 +46,47 @@ 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
/// reference of its own, so a shared texture outlives every widget
/// drawing it and its slot is never recycled underneath one.
shared: HashMap<SharedTextureKey, TextureHandle>,
/// Next layer to hand out to an atlas page. Pages are never freed (no
/// atlas eviction), so this only grows and `free` never holds one.
next_page_layer: u32,
updates: Vec<Update>,
send: Sender<u32>,
recv: Receiver<u32>,
send: Sender<(TextureKind, u32)>,
recv: Receiver<(TextureKind, u32)>,
}
pub enum TextureUpdate<'a> {
Push(&'a DynamicImage),
Set(u32, &'a DynamicImage),
Push(TextureKind, &'a DynamicImage),
Set(TextureKind, u32, &'a DynamicImage),
/// Overwrite a rectangle of an existing texture, rather than replacing it.
/// The glyph atlas grows a glyph at a time, and re-uploading a whole atlas
/// per glyph is megabytes of copy for a few hundred bytes of change.
/// Only ever issued against a page -- a standalone image is never patched.
Patch(u32, PatchRect, &'a DynamicImage),
Free(u32),
PushFree,
PushFree(TextureKind),
SetFree,
}
#[derive(Debug, Clone, Copy)]
pub struct PatchRect {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
}
enum Update {
Push(u32),
Set(u32),
Push(TextureKind, u32),
Set(TextureKind, u32),
Patch(u32, PatchRect),
Free(u32),
}
@@ -46,58 +96,162 @@ impl Textures {
Self {
free: Vec::new(),
images: Vec::new(),
kinds: Vec::new(),
shared: HashMap::new(),
next_page_layer: 0,
updates: Vec::new(),
send,
recv,
}
}
pub fn add(&mut self, image: impl Into<DynamicImage>) -> TextureHandle {
let image = image.into();
let size = image.dimensions().into();
let view_idx = self.push(image);
// 0 == default in renderer; TODO: actually create samplers here
let sampler_idx = 0;
let kind = TextureKind::Image;
let slot = self.push(kind, image);
TextureHandle {
inner: TexturePrimitive {
view_idx,
sampler_idx,
},
slot,
kind,
size,
counter: RefCounter::new(),
send: self.send.clone(),
}
}
fn push(&mut self, image: DynamicImage) -> u32 {
/// 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();
let layer = self.next_page_layer;
self.next_page_layer += 1;
let kind = TextureKind::Page { layer };
let slot = self.push(kind, image);
TextureHandle {
slot,
kind,
size,
counter: RefCounter::new(),
send: self.send.clone(),
}
}
fn push(&mut self, kind: TextureKind, image: DynamicImage) -> u32 {
if let Some(i) = self.free.pop() {
self.images[i as usize] = Some(image);
self.updates.push(Update::Set(i));
self.kinds[i as usize] = kind;
self.updates.push(Update::Set(kind, i));
i
} else {
let i = self.images.len() as u32;
self.images.push(Some(image));
self.updates.push(Update::Push(i));
self.kinds.push(kind);
self.updates.push(Update::Push(kind, i));
i
}
}
/// 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.
pub fn shared(
&mut self,
key: SharedTextureKey,
make: impl FnOnce() -> DynamicImage,
) -> TextureHandle {
if let Some(handle) = self.shared.get(&key) {
return handle.clone();
}
let handle = self.add(make());
self.shared.insert(key, handle.clone());
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
/// are still holding name slots by *index*: `Textures::reset` used to
/// throw this bookkeeping away, which left every live `TextureHandle`
/// -- one per `widget::mark`, hundreds on a transcript screen --
/// pointing at a slot nothing recognised, and the first frame after an
/// Android surface rebuild panicked in `image_bind_group` ("texture
/// slot 89 is not a live standalone image: None"). Re-uploading
/// instead keeps every index meaning what it meant, because this side
/// 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
.extend((0..self.images.len() as u32).map(|i| Update::Push(self.kinds[i as usize], i)));
}
pub fn free(&mut self) {
for idx in self.recv.try_iter() {
for (kind, idx) in self.recv.try_iter() {
self.images[idx as usize] = None;
self.updates.push(Update::Free(idx));
self.free.push(idx);
// A page's slot is never reclaimed: `GlyphAtlas` never drops the
// handles it holds, and there is no eviction path for a hole in
// the middle of the array's layers. If that ever changes, this
// is where a freed page's layer would need to go on a free list
// of its own, separate from `free`, which only ever holds
// ordinary image slots today.
if kind == TextureKind::Image {
self.free.push(idx);
}
}
}
pub fn updates(&mut self) -> impl Iterator<Item = TextureUpdate<'_>> {
self.updates.drain(..).map(|u| match u {
Update::Push(i) => self.images[i as usize]
Update::Push(kind, i) => self.images[i as usize]
.as_ref()
.map(TextureUpdate::Push)
.unwrap_or(TextureUpdate::PushFree),
Update::Set(i) => self.images[i as usize]
.map(|img| TextureUpdate::Push(kind, img))
.unwrap_or(TextureUpdate::PushFree(kind)),
Update::Set(kind, i) => self.images[i as usize]
.as_ref()
.map(|img| TextureUpdate::Set(i, img))
.map(|img| TextureUpdate::Set(kind, i, img))
.unwrap_or(TextureUpdate::SetFree),
Update::Patch(i, rect) => self.images[i as usize]
.as_ref()
.map(|img| TextureUpdate::Patch(i, rect, img))
.unwrap_or(TextureUpdate::SetFree),
Update::Free(i) => TextureUpdate::Free(i),
})
@@ -105,18 +259,36 @@ impl Textures {
}
impl TextureHandle {
pub fn primitive(&self) -> TexturePrimitive {
self.inner
}
pub fn size(&self) -> Vec2 {
self.size
}
/// The bind-group index this handle draws with. Only valid for a
/// standalone image; an atlas page has no bind group of its own -- it
/// samples the shared array via `layer()` instead. Getting this wrong is
/// a caller bug (the wrong kind of handle reached the wrong draw path),
/// not a recoverable condition, so it panics rather than drawing garbage.
pub fn image_index(&self) -> u32 {
match self.kind {
TextureKind::Image => self.slot,
TextureKind::Page { .. } => panic!("image_index() called on an atlas page handle"),
}
}
/// 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,
TextureKind::Image => panic!("layer() called on a standalone image handle"),
}
}
}
impl Drop for TextureHandle {
fn drop(&mut self) {
if self.counter.drop() {
let _ = self.send.send(self.inner.view_idx);
let _ = self.send.send((self.kind, self.slot));
}
}
}
@@ -125,7 +297,7 @@ impl Index<&TextureHandle> for Textures {
type Output = DynamicImage;
fn index(&self, index: &TextureHandle) -> &Self::Output {
self.images[index.inner.view_idx as usize].as_ref().unwrap()
self.images[index.slot as usize].as_ref().unwrap()
}
}
@@ -134,3 +306,90 @@ impl Default for Textures {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use image::RgbaImage;
fn image(n: u32) -> DynamicImage {
RgbaImage::new(n, n).into()
}
fn key(id: u64) -> SharedTextureKey {
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();
let built = std::cell::Cell::new(0);
let make = |textures: &mut Textures, id: u64| {
textures.shared(key(id), || {
built.set(built.get() + 1);
image(4)
})
};
let first = make(&mut textures, 1);
let again = make(&mut textures, 1);
let other = make(&mut textures, 2);
assert_eq!(built.get(), 2, "the second ask for key 1 rasterised again");
assert_eq!(first.image_index(), again.image_index());
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();
let slot = textures.shared(key(1), || image(4)).image_index();
textures.free();
let plain = textures.add(image(4));
assert_ne!(
plain.image_index(),
slot,
"an ordinary texture was handed the shared mark's slot"
);
}
/// 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();
let keep_a = textures.add(image(4));
let dropped = textures.add(image(4));
let keep_b = textures.add(image(4));
let (a, gone, b) = (
keep_a.image_index(),
dropped.image_index(),
keep_b.image_index(),
);
drop(dropped);
textures.free();
// Drain the updates so far, the way a frame does.
assert!(textures.updates().count() > 0);
textures.reupload();
let kinds: Vec<String> = textures
.updates()
.map(|u| match u {
TextureUpdate::Push(..) => "push".to_string(),
TextureUpdate::PushFree(..) => "push-free".to_string(),
_ => "other".to_string(),
})
.collect();
assert_eq!(
kinds,
["push", "push-free", "push"],
"slots {a}, {gone} (freed) and {b} must replay in order, so the \
indices after a hole still land where they were"
);
}
}
+284
View File
@@ -0,0 +1,284 @@
//! 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},
};
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,
}
#[derive(Clone, Copy)]
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,
}
struct Page {
handle: TextureHandle,
/// Shelf packing: glyphs are placed left to right along a shelf whose
/// height is the tallest glyph on it, and a new shelf starts above when the
/// row runs out. Chosen over a real packer because glyphs at one size are
/// close to the same height, which is the case shelves are good at.
x: u32,
y: u32,
shelf_height: u32,
}
#[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>>,
}
impl GlyphAtlas {
pub fn get(&self, key: &GlyphKey) -> Option<Option<GlyphEntry>> {
self.entries.get(key).copied()
}
/// Rasterised pixels in, a place in the atlas out. `None` means the glyph
/// has no pixels, which is a normal answer rather than a failure.
pub fn insert(
&mut self,
key: GlyphKey,
image: &Image,
textures: &mut Textures,
) -> Option<GlyphEntry> {
let w = image.placement.width;
let h = image.placement.height;
if w == 0 || h == 0 {
self.entries.insert(key, None);
return None;
}
if w + PAD * 2 > PAGE || h + PAD * 2 > PAGE {
// A single glyph larger than a page. Refusing is better than
// silently drawing a cropped one; the caller draws nothing.
self.entries.insert(key, None);
return None;
}
let (page_idx, x, y) = self.allocate(w, h, textures);
let page = &self.pages[page_idx];
let img = textures.image_mut(&page.handle);
let rgba = img.as_mut_rgba8().expect("atlas page is rgba8");
write_glyph(rgba, image, x, y);
let handle = page.handle.clone();
let rect = PatchRect {
x,
y,
width: w,
height: h,
};
textures.patch(&handle, rect);
let page = &self.pages[page_idx];
let scale = 1.0 / PAGE as f32;
let entry = GlyphEntry {
uv_min: [x as f32 * scale, y as f32 * scale],
uv_max: [(x + w) as f32 * scale, (y + h) as f32 * scale],
left: image.placement.left,
top: image.placement.top,
width: w,
height: h,
is_color: matches!(image.content, Content::Color),
layer: page.handle.layer(),
};
self.entries.insert(key, Some(entry));
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;
if let Some(i) = self.pages.iter().position(|p| fits(p, need_w, need_h)) {
let page = &mut self.pages[i];
if page.x + need_w > PAGE {
page.y += page.shelf_height;
page.x = PAD;
page.shelf_height = 0;
}
let (x, y) = (page.x, page.y);
page.x += need_w;
page.shelf_height = page.shelf_height.max(need_h);
return (i, x, y);
}
let handle = textures.add_page(RgbaImage::new(PAGE, PAGE));
self.pages.push(Page {
handle,
x: PAD + w + PAD,
y: PAD,
shelf_height: h + PAD,
});
(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
}
pub fn page_count(&self) -> usize {
self.pages.len()
}
pub fn glyph_count(&self) -> usize {
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();
self.generation += 1;
}
}
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;
match image.content {
Content::Mask => {
for row in 0..h {
for col in 0..w {
let a = image.data[(row * w + col) as usize];
page.put_pixel(x + col, y + row, image::Rgba([255, 255, 255, a]));
}
}
}
Content::Color => {
for row in 0..h {
for col in 0..w {
let i = ((row * w + col) * 4) as usize;
let px = [
image.data[i],
image.data[i + 1],
image.data[i + 2],
image.data[i + 3],
];
page.put_pixel(x + col, y + row, image::Rgba(px));
}
}
}
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;
let a = image.data[i + 1];
page.put_pixel(x + col, y + row, image::Rgba([255, 255, 255, a]));
}
}
}
}
}
/// 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,
pub offset: Vec2,
pub color: UiColor,
}
+100 -18
View File
@@ -8,6 +8,15 @@ 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 {
@@ -15,25 +24,20 @@ pub struct PrimitiveInstance {
pub binding: u32,
pub idx: u32,
pub mask_idx: MaskIdx,
pub move_idx: MoveIdx,
}
impl PrimitiveInstance {
const ATTRIBS: [VertexAttribute; 7] = vertex_attr_array![
0 => Float32x2,
1 => Float32x2,
2 => Float32x2,
3 => Float32x2,
4 => Uint32,
5 => Uint32,
6 => Uint32,
];
pub fn desc() -> VertexBufferLayout<'static> {
VertexBufferLayout {
array_stride: std::mem::size_of::<Self>() as BufferAddress,
step_mode: VertexStepMode::Instance,
attributes: &Self::ATTRIBS,
}
/// 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 {
array_stride: std::mem::size_of::<u32>() as BufferAddress,
step_mode: VertexStepMode::Instance,
attributes: &ATTRIBS,
}
}
@@ -43,8 +47,86 @@ impl MaskIdx {
pub const NONE: Self = Self::preset(u32::MAX);
}
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 {
pub region: UiRegion,
/// 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
/// every coverage on it, which is what makes a pixel inside two
/// feathered corners dimmed by both. Chained rather than intersected
/// on the CPU because each mask moves with its own widget -- a code
/// 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 --
/// the same trap `GlyphPrimitive` documents below. `bytemuck` does not
/// check this for us, and getting it wrong is a wgpu validation panic at
/// draw time ("buffer bound ... with size 12 where the shader expects 16"),
/// not a compile error.
#[repr(C)]
#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct MoveOffset {
pub delta: [f32; 2],
pub parent: u32,
_pad: u32,
}
impl MoveOffset {
pub const NONE_PARENT: u32 = u32::MAX;
pub fn new(delta: [f32; 2], parent: u32) -> Self {
Self {
delta,
parent,
_pad: 0,
}
}
}
+557
View File
@@ -0,0 +1,557 @@
use std::time::{Duration, Instant};
/// The frame budget `dumpsys gfxinfo` also uses to call a frame "janky": the
/// 60Hz vsync period. Kept as the same threshold so a percentage from this
/// report and a percentage from `gfxinfo` mean the same thing. Only a
/// fallback now that a caller can read the display's real refresh rate
/// (`report_at_hz`/`mark_phase`'s callers) -- most devices are 60Hz, but a
/// 90Hz or 120Hz phone judged against this constant would call every frame
/// "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;
/// 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,
pub late: u64,
pub late_percent: f64,
pub p50: Duration,
pub p90: Duration,
pub p99: Duration,
pub worst: 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,
}
impl std::fmt::Display for PhaseStats {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(
f,
" {}: {} frames over {:.1}s{}",
self.name,
self.frames,
self.duration.as_secs_f64(),
if self.complete {
""
} else {
" (ring evicted some of this phase)"
},
)?;
writeln!(f, " late: {} ({:.1}%)", self.late, self.late_percent)?;
writeln!(
f,
" total p50 {:.1}ms p90 {:.1}ms p99 {:.1}ms",
self.p50.as_secs_f64() * 1000.0,
self.p90.as_secs_f64() * 1000.0,
self.p99.as_secs_f64() * 1000.0,
)?;
write!(f, " worst {:.1}ms", self.worst.as_secs_f64() * 1000.0)
}
}
/// 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
/// way `gfxinfo`'s own `GPU_DURATION`/vsync accounting is. So a sample here
/// is "how long the CPU took to build and submit this frame", not
/// "how long the frame took to reach the screen" -- named in
/// [`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).
/// `ring[i] - submit_ring[i]` is that frame's `redraw_to_submit` half.
submit_ring: Box<[Duration; RING_CAPACITY]>,
/// 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,
pub p50: Duration,
pub p90: Duration,
pub p99: Duration,
pub worst: Duration,
/// Median of `redraw_to_submit` -- iris's own CPU work (layout, text,
/// primitive building) up to and including building the `queue.submit`
/// call, per frame. 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.
pub cpu_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,
}
impl std::fmt::Display for FrameStats {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"frames={} janky%={:.2} p50={:.1}ms p90={:.1}ms p99={:.1}ms worst={:.1}ms \
(measures redraw-start to after present() is called, not GPU/compositor \
completion)",
self.total_frames,
self.janky_percent,
self.p50.as_secs_f64() * 1000.0,
self.p90.as_secs_f64() * 1000.0,
self.p99.as_secs_f64() * 1000.0,
self.worst.as_secs_f64() * 1000.0,
)?;
write!(
f,
" cpu_p50={:.1}ms gpu_wait_p50={:.1}ms (redraw-start-to-submit vs. \
submit-to-after-present)",
self.cpu_p50.as_secs_f64() * 1000.0,
self.gpu_wait_p50.as_secs_f64() * 1000.0,
)
}
}
impl FrameReport {
pub fn new() -> Self {
Self {
ring: Box::new([Duration::ZERO; RING_CAPACITY]),
submit_ring: Box::new([Duration::ZERO; RING_CAPACITY]),
index_ring: Box::new([0; RING_CAPACITY]),
len: 0,
pos: 0,
total_frames: 0,
janky_frames: 0,
phases: Vec::new(),
}
}
/// Record one frame's elapsed wall time, with no CPU/GPU split (the
/// `submit_to_present` half is recorded as zero, so `cpu_p50` reads as
/// the whole frame and `gpu_wait_p50` as nothing -- honest for a caller
/// that never measured the split, rather than fabricating one). O(1),
/// no allocation.
pub fn record(&mut self, elapsed: Duration) {
self.record_split(elapsed, Duration::ZERO);
}
/// Record one frame's elapsed wall time, split at `queue.submit`:
/// `submit_to_present` is the `queue.submit()` call plus `present()`;
/// `total - submit_to_present` is everything before it (layout, text,
/// primitive building). RUST.md's I5 "Where iris's frame time goes"
/// CPU/GPU split, added 2026-09-05. O(1), no allocation.
pub fn record_split(&mut self, total: Duration, submit_to_present: Duration) {
self.ring[self.pos] = total;
self.submit_ring[self.pos] = submit_to_present;
self.index_ring[self.pos] = self.total_frames;
self.pos = (self.pos + 1) % RING_CAPACITY;
self.len = (self.len + 1).min(RING_CAPACITY);
self.total_frames += 1;
if total > JANK_THRESHOLD {
self.janky_frames += 1;
}
}
/// 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;
self.total_frames = 0;
self.janky_frames = 0;
self.phases.clear();
}
/// 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
// (docs/REVIEW-2026-09-06.md finding 5).
debug_assert!(
self.phases
.last()
.is_none_or(|p| self.total_frames >= p.start_index)
);
self.phases.push(PhaseMark {
name: name.to_string(),
start_index: self.total_frames,
start_at: Instant::now(),
});
}
/// 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();
}
let budget = Duration::from_secs_f64(1.0 / refresh_hz as f64);
self.phases
.iter()
.enumerate()
.map(|(i, phase)| {
let (end_index, end_at) = match self.phases.get(i + 1) {
Some(next) => (next.start_index, next.start_at),
None => (self.total_frames, now),
};
let frames = end_index.saturating_sub(phase.start_index);
let mut samples: Vec<Duration> = (0..self.len)
.filter(|&j| {
let idx = self.index_ring[j];
idx >= phase.start_index && idx < end_index
})
.map(|j| self.ring[j])
.collect();
let complete = samples.len() as u64 >= frames;
if samples.is_empty() {
return PhaseStats {
name: phase.name.clone(),
frames,
duration: end_at.saturating_duration_since(phase.start_at),
late: 0,
late_percent: 0.0,
p50: Duration::ZERO,
p90: Duration::ZERO,
p99: Duration::ZERO,
worst: Duration::ZERO,
complete,
};
}
samples.sort_unstable();
let pct = |p: usize| samples[(samples.len() * p / 100).min(samples.len() - 1)];
let late = samples.iter().filter(|&&d| d > budget).count() as u64;
PhaseStats {
name: phase.name.clone(),
frames,
duration: end_at.saturating_duration_since(phase.start_at),
late,
late_percent: 100.0 * late as f64 / samples.len() as f64,
p50: pct(50),
p90: pct(90),
p99: pct(99),
worst: *samples.last().expect("checked not empty above"),
complete,
}
})
.collect()
}
/// `None` if nothing has been recorded since the last reset -- the
/// "no frames recorded, scroll first" case, not a zeroed report that
/// would read as a real (perfect) measurement.
pub fn report(&self) -> Option<FrameStats> {
if self.len == 0 {
return None;
}
let mut samples: Vec<Duration> = self.ring[..self.len].to_vec();
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 cpu_samples: Vec<Duration> = self.ring[..self.len]
.iter()
.zip(self.submit_ring[..self.len].iter())
.map(|(&total, &submit_to_present)| total.saturating_sub(submit_to_present))
.collect();
let median = |mut v: Vec<Duration>| {
v.sort_unstable();
v[v.len() / 2]
};
Some(FrameStats {
total_frames: self.total_frames,
janky_percent: 100.0 * self.janky_frames as f64 / self.total_frames as f64,
p50: pct(50),
p90: pct(90),
p99: pct(99),
worst: *samples.last().expect("len > 0 checked above"),
cpu_p50: median(cpu_samples),
gpu_wait_p50: median(submit_samples),
})
}
/// `(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);
let late = self.ring[..self.len]
.iter()
.filter(|&&d| d > budget)
.count() as u64;
(late, 100.0 * late as f64 / self.len as f64)
}
}
impl Default for FrameReport {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn no_frames_reports_none() {
assert!(FrameReport::new().report().is_none());
}
#[test]
fn one_frame_is_every_percentile_and_the_worst() {
let mut r = FrameReport::new();
r.record(Duration::from_millis(10));
let stats = r.report().unwrap();
assert_eq!(stats.total_frames, 1);
assert_eq!(stats.p50, Duration::from_millis(10));
assert_eq!(stats.p99, Duration::from_millis(10));
assert_eq!(stats.worst, Duration::from_millis(10));
assert_eq!(stats.janky_percent, 0.0);
}
#[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(Duration::from_millis(ms));
}
let stats = r.report().unwrap();
assert_eq!(stats.total_frames, 100);
assert_eq!(stats.p50, Duration::from_millis(51));
assert_eq!(stats.p90, Duration::from_millis(91));
assert_eq!(stats.p99, Duration::from_millis(100));
assert_eq!(stats.worst, Duration::from_millis(100));
}
#[test]
fn jank_threshold_matches_gfxinfos_60hz_budget() {
let mut r = FrameReport::new();
r.record(Duration::from_nanos(16_666_667)); // exactly on budget: not janky
r.record(Duration::from_nanos(16_666_668)); // one ns over: janky
let stats = r.report().unwrap();
assert_eq!(stats.janky_percent, 50.0);
}
#[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(Duration::from_millis(50));
}
assert_eq!(r.report().unwrap().janky_percent, 100.0);
r.reset();
assert!(r.report().is_none());
r.record(Duration::from_millis(1));
assert_eq!(r.report().unwrap().janky_percent, 0.0);
}
#[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(Duration::from_millis(20));
let stats = r.report().unwrap();
assert_eq!(stats.cpu_p50, Duration::from_millis(20));
assert_eq!(stats.gpu_wait_p50, Duration::ZERO);
}
#[test]
fn record_split_reports_each_halfs_own_median() {
let mut r = FrameReport::new();
// Three frames: total is always 30ms, but the CPU/GPU-wait split
// moves, so the two medians must be independent of each other and
// of `total`'s own median.
r.record_split(Duration::from_millis(30), Duration::from_millis(5));
r.record_split(Duration::from_millis(30), Duration::from_millis(10));
r.record_split(Duration::from_millis(30), Duration::from_millis(20));
let stats = r.report().unwrap();
assert_eq!(stats.p50, Duration::from_millis(30));
assert_eq!(stats.gpu_wait_p50, Duration::from_millis(10));
assert_eq!(stats.cpu_p50, Duration::from_millis(20));
}
#[test]
fn ring_wraps_without_growing_past_capacity() {
let mut r = FrameReport::new();
for i in 0..(RING_CAPACITY * 2) {
r.record(Duration::from_millis(1 + (i % 5) as u64));
}
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));
}
#[test]
fn no_marks_means_no_phases() {
let mut r = FrameReport::new();
r.record(Duration::from_millis(5));
assert!(r.phase_stats(Instant::now(), 60.0).is_empty());
}
#[test]
fn phases_slice_frames_by_when_they_were_marked() {
let mut r = FrameReport::new();
r.mark_phase("a");
for _ in 0..5 {
r.record(Duration::from_millis(10)); // 10ms: late at 60Hz (16.7ms budget)... no, 10<16.7, not late
}
r.mark_phase("b");
for _ in 0..3 {
r.record(Duration::from_millis(20)); // 20ms: late at 60Hz
}
let now = Instant::now();
let phases = r.phase_stats(now, 60.0);
assert_eq!(phases.len(), 2);
assert_eq!(phases[0].name, "a");
assert_eq!(phases[0].frames, 5);
assert_eq!(phases[0].late, 0);
assert_eq!(phases[0].worst, Duration::from_millis(10));
assert_eq!(phases[1].name, "b");
assert_eq!(phases[1].frames, 3);
assert_eq!(phases[1].late, 3);
assert_eq!(phases[1].late_percent, 100.0);
assert_eq!(phases[1].worst, Duration::from_millis(20));
assert!(phases[0].complete);
assert!(phases[1].complete);
}
#[test]
fn the_last_phase_runs_until_now() {
let mut r = FrameReport::new();
r.mark_phase("only");
r.record(Duration::from_millis(1));
std::thread::sleep(Duration::from_millis(20));
let now = Instant::now();
let phases = r.phase_stats(now, 60.0);
assert_eq!(phases.len(), 1);
assert!(phases[0].duration >= Duration::from_millis(20));
}
#[test]
fn reset_clears_phase_marks() {
let mut r = FrameReport::new();
r.mark_phase("a");
r.record(Duration::from_millis(1));
r.reset();
assert!(r.phase_stats(Instant::now(), 60.0).is_empty());
}
#[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(Duration::from_millis(10));
assert_eq!(r.late_at_hz(60.0), (0, 0.0));
assert_eq!(r.late_at_hz(120.0), (1, 100.0));
}
}
+468 -111
View File
@@ -1,30 +1,141 @@
use std::num::NonZero;
use crate::{
UiData, UiRenderState,
render::{data::PrimitiveInstance, texture::GpuTextures, util::ArrBuf},
util::HashMap,
render::{
data::{PrimitiveInstance, instance_slot_layout},
texture::GpuTextures,
util::ArrBuf,
},
util::{HashMap, Vec2},
};
use data::WindowUniform;
use pollster::FutureExt;
use wgpu::{
util::{BufferInitDescriptor, DeviceExt},
*,
};
use winit::dpi::PhysicalSize;
mod atlas;
mod data;
mod frame_report;
mod primitive;
mod sdf;
mod texture;
mod util;
pub use data::{Mask, MaskIdx};
pub use atlas::*;
pub use data::{Mask, MaskIdx, MoveIdx, MoveOffset};
pub use frame_report::{FrameReport, FrameStats, JANK_THRESHOLD};
pub use primitive::*;
pub use sdf::{distance_from_rect, rounded_rect_coverage};
const SHAPE_SHADER: &str = include_str!("./shader.wgsl");
/// 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,
max_compute_workgroup_storage_size: 0,
max_compute_invocations_per_workgroup: 0,
max_compute_workgroup_size_x: 0,
max_compute_workgroup_size_y: 0,
max_compute_workgroup_size_z: 0,
max_compute_workgroups_per_dimension: 0,
..Default::default()
}
}
/// 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 {
fn default() -> Self {
Self {
errors: std::sync::Arc::new(std::sync::Mutex::new(std::collections::VecDeque::new())),
}
}
}
impl WgpuErrorLog {
pub fn record(&self, error: impl std::fmt::Display) {
let mut errors = self.errors.lock().unwrap();
if errors.len() >= WGPU_ERROR_LOG_CAP {
errors.pop_front();
}
errors.push_back(error.to_string());
}
/// A snapshot for the Diagnostics page -- cloned rather than held,
/// since the lock must not outlive one call.
pub fn snapshot(&self) -> Vec<String> {
self.errors.lock().unwrap().iter().cloned().collect()
}
}
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,
rsc_group: BindGroup,
@@ -34,28 +145,76 @@ 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 {
instance: ArrBuf<PrimitiveInstance>,
primitives: PrimitiveBuffers,
primitive_group: BindGroup,
order: ArrBuf<u32>,
/// A standalone image's slots, kept apart from `order` because each
/// one draws with its own bind group -- see `UiRenderNode::draw`.
images: ArrBuf<u32>,
/// The texture slot each entry of `images` draws with, in the same
/// order, refreshed alongside it. Not in the vertex buffer itself
/// because it names a bind group, not shader data.
image_tex_indices: Vec<u32>,
}
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, &[]);
pass.set_bind_group(2, &self.rsc_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];
if layer.instance.len() == 0 {
if layer.order.len() == 0 && layer.images.len() == 0 {
continue;
}
pass.set_bind_group(1, &layer.primitive_group, &[]);
pass.set_vertex_buffer(0, layer.instance.buffer.slice(..));
pass.draw(0..4, 0..layer.instance.len() as u32);
if layer.order.len() > 0 {
pass.set_bind_group(2, &self.rsc_group, &[]);
pass.set_vertex_buffer(0, layer.order.buffer.slice(..));
pass.draw(0..4, 0..layer.order.len() as u32);
}
// Images draw after this layer's rects and glyphs, one draw call
// each with its own bind group. That draws every image "on top"
// within the layer, which loses nothing that currently exists:
// `Primitives::apply_free` frees with `swap_remove`, so a layer's
// draw order was already undefined before images had their own
// list -- nothing before this relied on interleaving a rect
// between two images at a particular position.
if layer.images.len() > 0 {
pass.set_vertex_buffer(0, layer.images.buffer.slice(..));
for (k, &tex_idx) in layer.image_tex_indices.iter().enumerate() {
pass.set_bind_group(2, self.textures.image_bind_group(tex_idx), &[]);
pass.draw(0..4, k as u32..k as u32 + 1);
}
}
}
}
@@ -65,79 +224,156 @@ impl UiRenderNode {
queue: &Queue,
ui: &mut UiData,
ui_render: &mut UiRenderState,
) {
) -> FrameUpdateStats {
self.active.clear();
for (i, primitives) in ui_render.layers.iter_mut() {
for (i, order) in ui_render.layers.iter_mut() {
self.active.push(i);
for change in primitives.apply_free() {
if let Some(inst) = ui_render.active.get_mut(&change.id) {
for h in &mut inst.primitives {
if h.layer == i && h.inst_idx == change.old {
h.inst_idx = change.new;
break;
}
}
}
}
let rlayer = self.layers.entry(i).or_insert_with(|| {
let primitives = PrimitiveBuffers::new(device);
let primitive_group =
Self::primitive_group(device, &self.primitive_layout, primitives.buffers());
RenderLayer {
instance: ArrBuf::new(
device,
BufferUsages::VERTEX | BufferUsages::COPY_DST,
"instance",
),
primitives,
primitive_group,
}
});
if primitives.updated {
rlayer
.instance
.update(device, queue, primitives.instances());
rlayer.primitives.update(device, queue, primitives.data());
rlayer.primitive_group = Self::primitive_group(
let rlayer = self.layers.entry(i).or_insert_with(|| RenderLayer {
order: ArrBuf::new(
device,
&self.primitive_layout,
rlayer.primitives.buffers(),
);
primitives.updated = false;
BufferUsages::VERTEX | BufferUsages::COPY_DST,
"layer order",
),
images: ArrBuf::new(
device,
BufferUsages::VERTEX | BufferUsages::COPY_DST,
"layer image order",
),
image_tex_indices: Vec::new(),
});
if order.updated {
rlayer.order.update(device, queue, order.order());
rlayer.images.update(device, queue, order.images());
rlayer.image_tex_indices = order
.images()
.iter()
.map(|&slot| ui_render.primitives.instance(slot).idx)
.collect();
order.updated = false;
}
}
let mut changed = false;
changed |= self.textures.update(&mut ui.textures);
if ui.masks.changed {
let instances_resized = if ui_render.primitives.updated {
ui_render.primitives.updated = false;
let resized = self
.instances
.update(device, queue, ui_render.primitives.instances());
self.primitives
.update(device, queue, ui_render.primitives.data());
self.primitive_group =
Self::primitive_group(device, &self.primitive_layout, self.primitives.buffers());
resized
} else {
false
};
let masks_resized = if ui.masks.changed {
ui.masks.changed = false;
self.masks.update(device, queue, &ui.masks[..]);
changed = true;
self.masks.update(device, queue, &ui.masks[..])
} else {
false
};
let moves_resized = if ui.move_offsets.changed {
ui.move_offsets.changed = false;
self.move_offsets
.update(device, queue, &ui.move_offsets[..])
} else {
false
};
if masks_resized || moves_resized || instances_resized {
self.masks_group = Self::masks_group(
device,
&self.masks_layout,
&self.masks,
&self.move_offsets,
&self.instances,
);
}
if changed {
self.rsc_group = Self::rsc_group(device, &self.rsc_layout, &self.textures, &self.masks);
let rebuild_main = self.textures.update(&mut ui.textures, &self.rsc_layout);
if rebuild_main {
self.rsc_group = Self::rsc_group(device, &self.rsc_layout, &self.textures);
}
FrameUpdateStats {
masks_resized,
moves_resized,
}
}
pub fn resize(&mut self, size: &PhysicalSize<u32>, queue: &Queue) {
/// Takes a size rather than a window type: this is the only thing the
/// core wanted from winit, and depending on a windowing backend for two
/// numbers is what put `android-activity` in the core's graph for an
/// Android build that is meant to go through android-view instead.
pub fn resize(&mut self, size: impl Into<Vec2>, queue: &Queue) {
let size = size.into();
let slice = &[WindowUniform {
width: size.width as f32,
height: size.height as f32,
width: size.x,
height: size.y,
}];
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,
limits: UiLimits,
) -> Self {
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);
let shader = device.create_shader_module(ShaderModuleDescriptor {
label: Some("UI Shape Shader"),
source: ShaderSource::Wgsl(SHAPE_SHADER.into()),
});
let window_uniform = WindowUniform::default();
// 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 {
width: size.x,
height: size.y,
}
};
let window_buffer = device.create_buffer_init(&BufferInitDescriptor {
label: Some("window"),
contents: bytemuck::cast_slice(&[window_uniform]),
@@ -161,34 +397,53 @@ impl UiRenderNode {
let uniform_group = Self::bind_group_0(device, &uniform_layout, &window_buffer);
let primitive_layout = device.create_bind_group_layout(&BindGroupLayoutDescriptor {
entries: &core::array::from_fn::<_, { PrimitiveBuffers::LEN }, _>(|i| {
BindGroupLayoutEntry {
binding: i as u32,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}
entries: &PrimitiveBuffers::BINDINGS.map(|binding| BindGroupLayoutEntry {
binding,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}),
label: Some("primitive"),
});
let tex_manager = GpuTextures::new(device, queue);
let primitives = PrimitiveBuffers::new(device);
let primitive_group =
Self::primitive_group(device, &primitive_layout, primitives.buffers());
let instances = ArrBuf::new(
device,
BufferUsages::STORAGE | BufferUsages::COPY_DST,
"ui instances",
);
let masks = ArrBuf::new(
device,
BufferUsages::STORAGE | BufferUsages::COPY_DST,
"ui masks",
);
let move_offsets = ArrBuf::new(
device,
BufferUsages::STORAGE | BufferUsages::COPY_DST,
"ui move offsets",
);
let rsc_layout = Self::rsc_layout(device, &limits);
let rsc_group = Self::rsc_group(device, &rsc_layout, &tex_manager, &masks);
let rsc_layout = Self::rsc_layout(device);
let rsc_group = Self::rsc_group(device, &rsc_layout, &tex_manager);
let masks_layout = Self::masks_layout(device);
let masks_group =
Self::masks_group(device, &masks_layout, &masks, &move_offsets, &instances);
let pipeline_layout = device.create_pipeline_layout(&PipelineLayoutDescriptor {
label: Some("UI Shape Pipeline Layout"),
bind_group_layouts: &[&uniform_layout, &primitive_layout, &rsc_layout],
bind_group_layouts: &[
Some(&uniform_layout),
Some(&primitive_layout),
Some(&rsc_layout),
Some(&masks_layout),
],
immediate_size: 0,
});
let pipeline = device.create_render_pipeline(&RenderPipelineDescriptor {
@@ -197,7 +452,7 @@ impl UiRenderNode {
vertex: VertexState {
module: &shader,
entry_point: Some("vs_main"),
buffers: &[PrimitiveInstance::desc()],
buffers: &[Some(instance_slot_layout())],
compilation_options: Default::default(),
},
fragment: Some(FragmentState {
@@ -229,9 +484,22 @@ impl UiRenderNode {
cache: None,
});
Self {
// Reverse of the push order above. Only one of these should ever be
// `Some` in practice -- three separate scopes exist to name *which*
// kind of error it was, not because more than one is expected at
// once.
let internal_err = internal_scope.pop().block_on();
let validation_err = validation_scope.pop().block_on();
let oom_err = oom_scope.pop().block_on();
if let Some(err) = validation_err.or(oom_err).or(internal_err) {
return Err(err.to_string());
}
Ok(Self {
uniform_group,
primitive_layout,
primitives,
primitive_group,
rsc_layout,
rsc_group,
pipeline,
@@ -239,8 +507,12 @@ impl UiRenderNode {
layers: HashMap::default(),
active: Vec::new(),
textures: tex_manager,
instances,
masks,
}
move_offsets,
masks_layout,
masks_group,
})
}
fn bind_group_0(
@@ -273,7 +545,14 @@ impl UiRenderNode {
})
}
fn rsc_layout(device: &Device, limits: &UiLimits) -> BindGroupLayout {
/// Group 2: the shared atlas array and one standalone-image slot (a null
/// view for the main draw, a real one for each image's own bind group --
/// see `GpuTextures`), plus one sampler. No `count` on any entry: this
/// needs nothing beyond plain Vulkan 1.0 / GLES sampling, unlike the
/// `binding_array` layout it replaced (see TEXTURES.md's "Recommended
/// shape"). Masks and move_offsets are deliberately *not* here -- see
/// `masks_layout` below for why they get their own group.
fn rsc_layout(device: &Device) -> BindGroupLayout {
device.create_bind_group_layout(&BindGroupLayoutDescriptor {
entries: &[
BindGroupLayoutEntry {
@@ -281,20 +560,91 @@ impl UiRenderNode {
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Texture {
sample_type: TextureSampleType::Float { filterable: false },
view_dimension: TextureViewDimension::D2,
view_dimension: TextureViewDimension::D2Array,
multisampled: false,
},
count: Some(NonZero::new(limits.max_textures).unwrap()),
count: None,
},
BindGroupLayoutEntry {
binding: 1,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Sampler(SamplerBindingType::NonFiltering),
count: Some(NonZero::new(limits.max_samplers).unwrap()),
ty: BindingType::Texture {
sample_type: TextureSampleType::Float { filterable: false },
view_dimension: TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
BindGroupLayoutEntry {
binding: 2,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Sampler(SamplerBindingType::NonFiltering),
count: None,
},
],
label: Some("ui rsc"),
})
}
/// The main group: rects and glyphs never sample the image slot, so it
/// gets a 1x1 null view rather than any live standalone image's.
fn rsc_group(
device: &Device,
layout: &BindGroupLayout,
tex_manager: &GpuTextures,
) -> BindGroup {
device.create_bind_group(&BindGroupDescriptor {
layout,
entries: &[
BindGroupEntry {
binding: 0,
resource: BindingResource::TextureView(tex_manager.array_view()),
},
BindGroupEntry {
binding: 1,
resource: BindingResource::TextureView(tex_manager.null_view()),
},
BindGroupEntry {
binding: 2,
resource: BindingResource::Sampler(tex_manager.sampler()),
},
],
label: Some("ui rsc"),
})
}
/// 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: &[
BindGroupLayoutEntry {
binding: 0,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
BindGroupLayoutEntry {
binding: 1,
visibility: ShaderStages::VERTEX | ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
BindGroupLayoutEntry {
binding: 2,
visibility: ShaderStages::VERTEX | ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
@@ -303,60 +653,67 @@ impl UiRenderNode {
count: None,
},
],
label: Some("ui rsc"),
label: Some("ui masks"),
})
}
fn rsc_group(
fn masks_group(
device: &Device,
layout: &BindGroupLayout,
tex_manager: &GpuTextures,
masks: &ArrBuf<Mask>,
move_offsets: &ArrBuf<MoveOffset>,
instances: &ArrBuf<PrimitiveInstance>,
) -> BindGroup {
device.create_bind_group(&BindGroupDescriptor {
layout,
entries: &[
BindGroupEntry {
binding: 0,
resource: BindingResource::TextureViewArray(&tex_manager.views()),
resource: masks.buffer.as_entire_binding(),
},
BindGroupEntry {
binding: 1,
resource: BindingResource::SamplerArray(&tex_manager.samplers()),
resource: move_offsets.buffer.as_entire_binding(),
},
BindGroupEntry {
binding: 2,
resource: masks.buffer.as_entire_binding(),
resource: instances.buffer.as_entire_binding(),
},
],
label: Some("ui rsc"),
label: Some("ui masks"),
})
}
pub fn view_count(&self) -> usize {
self.textures.view_count()
}
}
pub struct UiLimits {
max_textures: u32,
max_samplers: u32,
}
/// 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()
}
impl Default for UiLimits {
fn default() -> Self {
Self {
max_textures: 100000,
max_samplers: 1000,
}
/// 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()
}
}
impl UiLimits {
pub fn max_binding_array_elements_per_shader_stage(&self) -> u32 {
self.max_textures + self.max_samplers
}
pub fn max_binding_array_sampler_elements_per_shader_stage(&self) -> u32 {
self.max_samplers
}
/// What `UiRenderNode::update` changed this frame that a caller building a
/// per-frame diagnostic report cares about -- see `take_image_bind_group_creates`/
/// `take_atlas_pages_grown` for the two counters this doesn't carry (they
/// use the existing "call before update()" convention instead, so as not
/// to disturb `bench_images`' documented counts).
#[derive(Clone, Copy, Debug, Default)]
pub struct FrameUpdateStats {
pub masks_resized: bool,
pub moves_resized: bool,
}
+402 -76
View File
@@ -4,35 +4,27 @@ use crate::{
Color, UiRegion, WidgetId,
render::{
ArrBuf,
data::{MaskIdx, PrimitiveInstance},
data::{MaskIdx, MoveIdx, PrimitiveInstance},
},
util::HashSet,
};
use bytemuck::Pod;
use wgpu::*;
pub struct Primitives {
instances: Vec<PrimitiveInstance>,
assoc: Vec<WidgetId>,
data: PrimitiveData,
free: Vec<usize>,
pub updated: bool,
}
impl Default for Primitives {
fn default() -> Self {
Self {
instances: Default::default(),
assoc: Default::default(),
data: Default::default(),
free: Vec::new(),
updated: true,
}
}
}
/// The `binding` tag `Painter` writes on an image instance. Distinct from any
/// `Primitive::BINDING` because images have no `PrimitiveData` entry to key
/// one from -- a bind group already selects the texture -- so this only ever
/// has to match the shader's `TEXTURE` constant and flag "this instance is
/// drawn with its own bind group" to the code below.
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>;
}
macro_rules! primitives {
@@ -54,6 +46,14 @@ 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] {
[
$((<$ty>::BINDING, &self.$name.buffer),)*
@@ -90,73 +90,236 @@ macro_rules! primitives {
fn vec(data: &mut PrimitiveData) -> &mut PrimitiveVec<Self> {
&mut data.$name
}
fn vec_ref(data: &PrimitiveData) -> &PrimitiveVec<Self> {
&data.$name
}
}
)*
};
(@count $t1:tt $($t:tt)+) => { 1 + primitives!(@count $($t),+) };
// The recursion has to hand back the same shape it matches -- space
// separated, not comma separated. Written with `$($t),+` it re-entered
// with a comma as the first token and never terminated, which happened to
// work only because there were exactly two primitives: the first step left
// a single token, and a single token matches the base case whichever
// separator it was written with.
(@count $t1:tt $($t:tt)+) => { 1 + primitives!(@count $($t)+) };
(@count $t:tt) => { 1 };
}
pub struct PrimitiveInst<P> {
pub id: WidgetId,
pub primitive: P,
pub region: UiRegion,
pub mask_idx: MaskIdx,
/// 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>,
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,
/// Whether the instance arena or the per-primitive data changed since
/// the last upload -- one flag for both, since they are uploaded
/// together.
pub updated: bool,
}
impl Default for Primitives {
fn default() -> Self {
Self {
instances: Default::default(),
assoc: Default::default(),
handle_idx: Default::default(),
freed: Vec::new(),
reusable: Vec::new(),
data: Default::default(),
updated: true,
}
}
}
impl Primitives {
pub fn write<P: Primitive>(
/// 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
/// entry in the per-primitive data. The caller (`UiRenderState`) puts
/// the slot into a layer's draw order -- an instance that no layer
/// names is never rasterized, which is what a mask shape drawn only to
/// be *referenced* uses.
pub fn alloc<P: Primitive>(
&mut self,
layer: usize,
PrimitiveInst {
id,
primitive,
region,
mask_idx,
move_idx,
}: PrimitiveInst<P>,
) -> PrimitiveHandle {
) -> (u32, usize) {
let data_idx = P::vec(&mut self.data).add(primitive);
let slot = self.push(
PrimitiveInstance {
region,
idx: data_idx as u32,
mask_idx,
move_idx,
binding: P::BINDING,
},
id,
);
(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,
texture_idx: u32,
region: UiRegion,
mask_idx: MaskIdx,
move_idx: MoveIdx,
) -> u32 {
self.push(
PrimitiveInstance {
region,
idx: texture_idx,
mask_idx,
move_idx,
binding: IMAGE_BINDING,
},
id,
)
}
fn push(&mut self, inst: PrimitiveInstance, id: WidgetId) -> u32 {
self.updated = true;
let vec = P::vec(&mut self.data);
let i = vec.add(primitive);
let inst = PrimitiveInstance {
region,
idx: i as u32,
mask_idx,
binding: P::BINDING,
};
let inst_i = if let Some(i) = self.free.pop() {
let slot = if let Some(i) = self.reusable.pop() {
self.instances[i] = inst;
self.assoc[i] = id;
self.handle_idx[i] = Self::NO_HANDLE;
i
} else {
let i = self.instances.len();
self.instances.push(inst);
self.assoc.push(id);
i
self.handle_idx.push(Self::NO_HANDLE);
self.instances.len() - 1
};
PrimitiveHandle::new::<P>(layer, inst_i, i)
}
/// returns (old index, new index)
pub fn apply_free(&mut self) -> impl Iterator<Item = PrimitiveChange> {
self.free.sort_by(|a, b| b.cmp(a));
self.free.drain(..).filter_map(|i| {
self.instances.swap_remove(i);
self.assoc.swap_remove(i);
if i == self.instances.len() {
return None;
}
let id = self.assoc[i];
let old = self.instances.len();
Some(PrimitiveChange { id, old, new: i })
})
slot as u32
}
/// Retires a slot, answering the mask it was drawn under so the caller
/// can drop that mask's ref. The slot itself only becomes reusable at
/// the next [`Self::apply_free`] -- see `freed`.
pub fn free(&mut self, h: &PrimitiveHandle) -> MaskIdx {
self.updated = true;
self.data.free(h.binding, h.data_idx);
self.free.push(h.inst_idx);
self.instances[h.inst_idx].mask_idx
let slot = h.slot as usize;
if h.binding != IMAGE_BINDING {
self.data.free(h.binding, h.data_idx);
}
self.freed.push(slot);
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;
}
/// Where `slot`'s handle sits in its owner's `ActiveData::primitives`
/// -- see [`Self::handle_idx`]. `None` only for a slot whose owner
/// never took the handle, which nothing in this crate does.
pub fn handle_index(&self, slot: u32) -> Option<usize> {
match self.handle_idx[slot as usize] {
Self::NO_HANDLE => None,
idx => Some(idx as usize),
}
}
pub fn clear(&mut self) {
self.updated = true;
self.instances.clear();
self.assoc.clear();
self.handle_idx.clear();
self.freed.clear();
self.reusable.clear();
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())
.filter(move |i| !dead.contains(i))
.map(|i| {
(
i as u32,
self.assoc[i],
self.instances[i].binding == IMAGE_BINDING,
)
})
}
pub fn data(&self) -> &PrimitiveData {
@@ -167,40 +330,166 @@ impl Primitives {
&self.instances
}
pub fn instance(&self, slot: u32) -> &PrimitiveInstance {
&self.instances[slot as usize]
}
/// The per-primitive data behind `slot`, or `None` if that slot holds
/// a different kind of primitive -- the `binding` check is the same
/// one the shader's dispatch switch makes, and it is what stops a
/// caller reading a glyph's index into the rect table.
pub fn primitive_data<P: Primitive>(&self, slot: u32) -> Option<&P> {
let inst = self.instance(slot);
(inst.binding == P::BINDING).then(|| &P::vec_ref(&self.data)[inst.idx as usize])
}
pub fn region_mut(&mut self, h: &PrimitiveHandle) -> &mut UiRegion {
self.updated = true;
&mut self.instances[h.inst_idx].region
&mut self.instances[h.slot as usize].region
}
}
pub struct PrimitiveChange {
pub id: WidgetId,
pub old: usize,
pub new: usize,
/// 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.
#[derive(Default)]
pub struct LayerOrder {
order: Vec<u32>,
/// Standalone images, kept apart because each draws with its own bind
/// group rather than sharing the layer's one instanced draw -- see
/// `UiRenderNode::draw`.
images: Vec<u32>,
free: Vec<usize>,
image_free: Vec<usize>,
pub updated: bool,
}
impl LayerOrder {
pub fn push(&mut self, slot: u32, is_image: bool) -> usize {
self.updated = true;
let list = if is_image {
&mut self.images
} else {
&mut self.order
};
list.push(slot);
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 {
self.image_free.push(pos);
} else {
self.free.push(pos);
}
}
/// 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, &mut self.order, false);
changes.extend(Self::apply_free_list(
&mut self.image_free,
&mut self.images,
true,
));
changes
}
fn apply_free_list(
free: &mut Vec<usize>,
list: &mut Vec<u32>,
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| {
list.swap_remove(pos);
if pos == list.len() {
return None;
}
Some(OrderChange {
slot: list[pos],
is_image,
pos,
})
})
.collect()
}
pub fn order(&self) -> &Vec<u32> {
&self.order
}
pub fn images(&self) -> &Vec<u32> {
&self.images
}
}
/// 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,
}
/// Whether a primitive goes into its layer's draw order. [`Drawn::No`] is
/// a primitive written only to be *referenced* -- a mask's shape
/// (LAYOUT.md's "Masks with a shape"). It is owned, moved, resized and
/// freed exactly like any other; it is simply never rasterized.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Drawn {
Yes,
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,
pub inst_idx: usize,
pub pos: usize,
pub slot: u32,
pub data_idx: usize,
pub binding: u32,
}
impl PrimitiveHandle {
fn new<P: Primitive>(layer: usize, inst_idx: usize, data_idx: usize) -> Self {
Self {
layer,
inst_idx,
data_idx,
binding: P::BINDING,
}
pub fn is_image(&self) -> bool {
self.binding == IMAGE_BINDING
}
}
pub struct PrimitiveInst<P> {
pub id: WidgetId,
pub primitive: P,
pub region: UiRegion,
pub mask_idx: MaskIdx,
pub move_idx: MoveIdx,
}
primitives!(
rects: RectPrimitive => 0,
textures: TexturePrimitive => 1,
glyphs: GlyphPrimitive => 2,
);
#[repr(C)]
@@ -223,11 +512,48 @@ 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 TexturePrimitive {
pub view_idx: u32,
pub sampler_idx: u32,
pub struct GlyphPrimitive {
pub uv_min: [f32; 2],
pub uv_max: [f32; 2],
/// Layer of the shared atlas array texture this glyph's page occupies --
/// not a bind-group or view index, since a page never gets one of its
/// own. See TEXTURES.md's "Recommended shape".
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,
}
impl GlyphPrimitive {
pub const IS_COLOR: u32 = 1;
pub fn new(
uv_min: [f32; 2],
uv_max: [f32; 2],
layer: u32,
color: Color<u8>,
flags: u32,
) -> Self {
Self {
uv_min,
uv_max,
layer,
color,
flags,
_pad: 0,
}
}
}
pub struct PrimitiveVec<T> {
+54
View File
@@ -0,0 +1,54 @@
//! 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),
);
let clamped = Vec2::new(q.x.max(0.0), q.y.max(0.0));
(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;
let center = top_left + corner;
let dist = distance_from_rect(pos, center, corner, radius);
1.0 - smoothstep(-edge.min(radius), edge, dist)
}
/// WGSL's `smoothstep`, which Rust has no equivalent of. Undefined in WGSL
/// when `low == high`, which is why the caller above never passes a zero
/// radius into the low edge without `edge` bounding it.
fn smoothstep(low: f32, high: f32, x: f32) -> f32 {
let t = ((x - low) / (high - low)).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
+193 -53
View File
@@ -1,12 +1,16 @@
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.
const TEXTURE: u32 = 1u;
const GLYPH: u32 = 2u;
@group(0) @binding(0)
var<uniform> window: WindowUniform;
@group(1) @binding(RECT)
var<storage> rects: array<Rect>;
@group(1) @binding(TEXTURE)
var<storage> textures: array<TextureInfo>;
@group(1) @binding(GLYPH)
var<storage> glyphs: array<GlyphInfo>;
struct Rect {
color: u32,
@@ -15,14 +19,30 @@ struct Rect {
inner_radius: f32,
}
struct TextureInfo {
view_idx: u32,
sampler_idx: u32,
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".
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).
struct Mask {
x: UiSpan,
y: UiSpan,
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.
struct MoveOffset {
delta: vec2<f32>,
parent: u32,
}
struct UiSpan {
@@ -35,39 +55,93 @@ struct UiScalar {
abs: f32,
}
struct UiVec2 {
rel: vec2<f32>,
abs: vec2<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).
@group(2) @binding(0)
var views: binding_array<texture_2d<f32>>;
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.
@group(2) @binding(1)
var samplers: binding_array<sampler>;
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.
@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.
@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.
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;
for (var step = 0u; step < PARENT_CHAIN_LIMIT; step++) {
let entry = move_offsets[i];
total += entry.delta;
if entry.parent == 4294967295u {
break;
}
i = entry.parent;
}
return total;
}
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.
struct PrimitiveInstance {
x: UiSpan,
y: UiSpan,
binding: u32,
idx: u32,
mask_idx: u32,
move_idx: u32,
}
/// A layer's draw order: one slot into `instances` per instance drawn.
struct InstanceInput {
@location(0) x_start: vec2<f32>,
@location(1) x_end: vec2<f32>,
@location(2) y_start: vec2<f32>,
@location(3) y_end: vec2<f32>,
@location(4) binding: u32,
@location(5) idx: u32,
@location(6) mask_idx: u32,
@location(0) slot: u32,
}
struct VertexOutput {
@location(0) top_left: vec2<f32>,
@location(1) bot_right: vec2<f32>,
@location(2) uv: vec2<f32>,
@location(3) binding: u32,
@location(4) idx: u32,
@location(5) mask_idx: u32,
// `flat` is the only interpolation an integer can have, and naga
// (wgpu 30) now requires saying so rather than inferring it.
@location(3) @interpolate(flat) binding: u32,
@location(4) @interpolate(flat) idx: u32,
@location(5) @interpolate(flat) mask_idx: u32,
@builtin(position) clip_position: vec4<f32>,
};
@@ -78,20 +152,38 @@ 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.
struct Corners {
top_left: vec2<f32>,
bot_right: vec2<f32>,
}
fn corners_of(inst: PrimitiveInstance) -> Corners {
let top_left_rel = vec2(inst.x.start.rel, inst.y.start.rel);
let top_left_abs = vec2(inst.x.start.abs, inst.y.start.abs);
let bot_right_rel = vec2(inst.x.end.rel, inst.y.end.rel);
let bot_right_abs = vec2(inst.x.end.abs, inst.y.end.abs);
let move_delta = resolve_move(inst.move_idx);
return Corners(
floor(top_left_rel * window.dim) + floor(top_left_abs) + move_delta,
floor(bot_right_rel * window.dim) + floor(bot_right_abs) + move_delta,
);
}
@vertex
fn vs_main(
@builtin(vertex_index) vi: u32,
in: InstanceInput,
) -> VertexOutput {
var out: VertexOutput;
let inst = instances[in.slot];
let top_left_rel = vec2(in.x_start.x, in.y_start.x);
let top_left_abs = vec2(in.x_start.y, in.y_start.y);
let bot_right_rel = vec2(in.x_end.x, in.y_end.x);
let bot_right_abs = vec2(in.x_end.y, in.y_end.y);
let top_left = floor(top_left_rel * window.dim) + floor(top_left_abs);
let bot_right = floor(bot_right_rel * window.dim) + floor(bot_right_abs);
let c = corners_of(inst);
let top_left = c.top_left;
let bot_right = c.bot_right;
let size = bot_right - top_left;
let uv = vec2<f32>(
@@ -101,11 +193,11 @@ fn vs_main(
let pos = (top_left + uv * size) / window.dim * 2.0 - 1.0;
out.clip_position = vec4<f32>(pos.x, -pos.y, 0.0, 1.0);
out.uv = uv;
out.binding = in.binding;
out.idx = in.idx;
out.binding = inst.binding;
out.idx = inst.idx;
out.top_left = top_left;
out.bot_right = bot_right;
out.mask_idx = in.mask_idx;
out.mask_idx = inst.mask_idx;
return out;
}
@@ -123,29 +215,79 @@ fn fs_main(
color = draw_rounded_rect(region, rects[i]);
}
case TEXTURE: {
color = draw_texture(region, textures[i]);
color = draw_texture(region);
}
case GLYPH: {
color = draw_glyph(region, glyphs[i]);
}
default: {
color = vec4(1.0, 0.0, 1.0, 1.0);
}
}
if in.mask_idx != 4294967295u {
let mask = masks[in.mask_idx];
let tl = UiVec2(vec2(mask.x.start.rel, mask.y.start.rel), vec2(mask.x.start.abs, mask.y.start.abs));
let br = UiVec2(vec2(mask.x.end.rel, mask.y.end.rel), vec2(mask.x.end.abs, mask.y.end.abs));
let top_left = floor(tl.rel * window.dim) + floor(tl.abs);
let bot_right = floor(br.rel * window.dim) + floor(br.abs);
if pos.x < top_left.x || pos.x > bot_right.x || pos.y < top_left.y || pos.y > bot_right.y {
color *= 0.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).
var mask_idx = in.mask_idx;
for (var step = 0u; step < PARENT_CHAIN_LIMIT; step++) {
if mask_idx == 4294967295u {
break;
}
let mask = masks[mask_idx];
color.a *= mask_coverage(pos, mask);
mask_idx = mask.parent;
}
return color;
}
// TODO: this seems really inefficient (per frag indexing)?
fn draw_texture(region: Region, info: TextureInfo) -> vec4<f32> {
return textureSample(views[info.view_idx], samplers[info.sampler_idx], region.uv);
/// 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.
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.
return 1.0;
}
let c = corners_of(inst);
return rounded_rect_coverage(pos, c.top_left, c.bot_right, rects[inst.idx].radius);
}
fn draw_texture(region: Region) -> vec4<f32> {
return textureSample(image_texture, samp, region.uv);
}
fn draw_glyph(region: Region, g: GlyphInfo) -> vec4<f32> {
let uv = mix(g.uv_min, g.uv_max, region.uv);
let texel = textureSample(atlas, samp, uv, i32(g.layer));
if (g.flags & 1u) != 0u {
return texel;
}
var color = unpack4x8unorm(g.color);
color.a *= texel.a;
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.
fn rounded_rect_coverage(
pos: vec2<f32>,
top_left: vec2<f32>,
bot_right: vec2<f32>,
radius: f32,
) -> f32 {
let edge = 0.5;
let corner = (bot_right - top_left) / 2.0;
let center = top_left + corner;
let dist = distance_from_rect(pos, center, corner, radius);
return 1.0 - smoothstep(-min(edge, radius), edge, dist);
}
fn draw_rounded_rect(region: Region, rect: Rect) -> vec4<f32> {
@@ -153,14 +295,12 @@ fn draw_rounded_rect(region: Region, rect: Rect) -> vec4<f32> {
let edge = 0.5;
let size = region.bot_right - region.top_left;
let corner = size / 2.0;
let center = region.top_left + corner;
let dist = distance_from_rect(region.pos, center, corner, rect.radius);
color.a *= 1.0 - smoothstep(-min(edge, rect.radius), edge, dist);
color.a *= rounded_rect_coverage(region.pos, region.top_left, region.bot_right, rect.radius);
if rect.thickness > 0.0 {
let size = region.bot_right - region.top_left;
let corner = size / 2.0;
let center = region.top_left + corner;
let dist2 = distance_from_rect(region.pos, center, corner - rect.thickness, rect.inner_radius);
color.a *= smoothstep(-min(edge, rect.inner_radius), edge, dist2);
}
+430 -55
View File
@@ -1,59 +1,306 @@
use image::{DynamicImage, EncodableLayout};
use image::{DynamicImage, EncodableLayout, GenericImageView};
use wgpu::{util::DeviceExt, *};
use crate::{TextureUpdate, Textures};
use crate::{PatchRect, TextureKind, TextureUpdate, Textures};
use super::atlas::PAGE;
/// The fewest layers the glyph atlas array is ever created with. Two, not
/// 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
/// `Textures::free`), so this is the only variant that outlives a `Free`.
Page(u32),
}
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
/// Vulkan 1.0/GLES sampling is needed for this -- a layer index is an
/// ordinary sampling operand.
/// - **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,
views: Vec<TextureView>,
view_count: usize,
samplers: Vec<Sampler>,
slots: Vec<Slot>,
array_texture: Texture,
array_view: TextureView,
array_capacity: u32,
/// Layers actually written. Only grows -- see `Slot::Page`.
page_count: u32,
sampler: Sampler,
/// Bound in the image slot of the main draw's bind group, which has
/// nothing of its own to put there: rects and glyphs never sample it,
/// but the layout requires something bound regardless.
null_view: TextureView,
no_views: Vec<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,
}
impl GpuTextures {
pub fn update(&mut self, textures: &mut Textures) -> bool {
let mut changed = false;
/// Applies queued `Textures` updates, then reports whether the *main*
/// bind group (the one rects and glyphs draw with) needs rebuilding --
/// true exactly when the atlas array was recreated (its view identity
/// changed). Pushing or freeing a standalone image never touches that
/// group: it built or drops its own. Masks/move_offsets resizing is
/// `UiRenderNode`'s own concern now (its `masks_group`, group 3) --
/// see that struct's field comment for why standalone images no longer
/// hear about either buffer at all.
pub fn update(&mut self, textures: &mut Textures, rsc_layout: &BindGroupLayout) -> bool {
let mut rebuild_main = false;
for update in textures.updates() {
changed = true;
match update {
TextureUpdate::Push(image) => self.push(image),
TextureUpdate::Set(i, image) => self.set(i, image),
TextureUpdate::SetFree => self.view_count += 1,
TextureUpdate::Push(kind, image) => {
rebuild_main |= self.push(kind, image, rsc_layout);
}
TextureUpdate::Set(kind, i, image) => {
rebuild_main |= self.set(kind, i, image, rsc_layout);
}
// A patch changes texture contents, not which layer or bind
// group exists, so it never asks for a rebuild -- rebuilding
// per glyph is exactly the cost this exists to avoid.
TextureUpdate::Patch(i, rect, image) => self.patch(i, rect, image),
TextureUpdate::SetFree => {}
TextureUpdate::Free(i) => self.free(i),
TextureUpdate::PushFree => self.push_free(),
TextureUpdate::PushFree(_kind) => self.slots.push(Slot::Empty),
}
}
changed
}
fn set(&mut self, i: u32, image: &DynamicImage) {
self.view_count += 1;
let view = self.create_view(image);
self.views[i as usize] = view;
}
fn free(&mut self, i: u32) {
self.view_count -= 1;
self.views[i as usize] = self.null_view.clone();
}
fn push(&mut self, image: &DynamicImage) {
self.view_count += 1;
let view = self.create_view(image);
self.views.push(view);
}
fn push_free(&mut self) {
self.view_count += 1;
self.views.push(self.null_view.clone());
rebuild_main
}
fn create_view(&self, image: &DynamicImage) -> TextureView {
let image = image.to_rgba8();
let (width, height) = image.dimensions();
fn push(
&mut self,
kind: TextureKind,
image: &DynamicImage,
rsc_layout: &BindGroupLayout,
) -> bool {
let (slot, rebuilt) = self.make_slot(kind, image, rsc_layout);
self.slots.push(slot);
rebuilt
}
fn set(
&mut self,
kind: TextureKind,
i: u32,
image: &DynamicImage,
rsc_layout: &BindGroupLayout,
) -> bool {
let (slot, rebuilt) = self.make_slot(kind, image, rsc_layout);
self.slots[i as usize] = slot;
rebuilt
}
fn make_slot(
&mut self,
kind: TextureKind,
image: &DynamicImage,
rsc_layout: &BindGroupLayout,
) -> (Slot, bool) {
match kind {
TextureKind::Image => {
let gpu = self.create_image(image, rsc_layout);
(Slot::Image(gpu), false)
}
TextureKind::Page { layer } => {
let mut rebuilt = false;
if layer >= self.array_capacity {
self.grow_array(rsc_layout);
rebuilt = true;
}
self.write_full_layer(layer, image);
self.page_count = self.page_count.max(layer + 1);
(Slot::Page(layer), rebuilt)
}
}
}
fn free(&mut self, i: u32) {
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) {
let Some(&Slot::Page(layer)) = self.slots.get(i as usize) else {
return;
};
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();
self.queue.write_texture(
TexelCopyTextureInfo {
texture: &self.array_texture,
mip_level: 0,
origin: Origin3d {
x: rect.x,
y: rect.y,
z: layer,
},
aspect: TextureAspect::All,
},
sub.as_bytes(),
TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(rect.width * 4),
rows_per_image: Some(rect.height),
},
Extent3d {
width: rect.width,
height: rect.height,
depth_or_array_layers: 1,
},
);
}
fn write_full_layer(&self, layer: u32, image: &DynamicImage) {
// Every page is created as exactly PAGE x PAGE (`GlyphAtlas::allocate`),
// so this is always a whole-layer write, never a crop.
let rgba = image.to_rgba8();
self.queue.write_texture(
TexelCopyTextureInfo {
texture: &self.array_texture,
mip_level: 0,
origin: Origin3d {
x: 0,
y: 0,
z: layer,
},
aspect: TextureAspect::All,
},
rgba.as_bytes(),
TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(PAGE * 4),
rows_per_image: Some(PAGE),
},
Extent3d {
width: PAGE,
height: PAGE,
depth_or_array_layers: 1,
},
);
}
/// 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;
let new_texture = Self::create_array_texture(&self.device, new_capacity);
if self.page_count > 0 {
let mut encoder = self
.device
.create_command_encoder(&CommandEncoderDescriptor {
label: Some("atlas array grow"),
});
encoder.copy_texture_to_texture(
TexelCopyTextureInfo {
texture: &self.array_texture,
mip_level: 0,
origin: Origin3d::ZERO,
aspect: TextureAspect::All,
},
TexelCopyTextureInfo {
texture: &new_texture,
mip_level: 0,
origin: Origin3d::ZERO,
aspect: TextureAspect::All,
},
Extent3d {
width: PAGE,
height: PAGE,
depth_or_array_layers: self.page_count,
},
);
self.queue.submit(std::iter::once(encoder.finish()));
}
self.array_texture = new_texture;
self.array_view = self.array_texture.create_view(&TextureViewDescriptor {
dimension: Some(TextureViewDimension::D2Array),
..Default::default()
});
self.array_capacity = new_capacity;
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 {
gpu.bind_group = Self::make_image_bind_group(
&self.device,
rsc_layout,
&self.array_view,
&gpu.view,
&self.sampler,
);
self.bind_group_creates += 1;
}
}
}
fn create_image(&mut self, image: &DynamicImage, rsc_layout: &BindGroupLayout) -> ImageGpu {
let rgba = image.to_rgba8();
let (width, height) = rgba.dimensions();
let texture = self.device.create_texture_with_data(
&self.queue,
&TextureDescriptor {
label: None,
label: Some("image"),
size: Extent3d {
width,
height,
@@ -63,45 +310,173 @@ impl GpuTextures {
sample_count: 1,
dimension: TextureDimension::D2,
format: TextureFormat::Rgba8Unorm,
usage: TextureUsages::TEXTURE_BINDING,
usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
view_formats: &[],
},
wgt::TextureDataOrder::MipMajor,
image.as_bytes(),
rgba.as_bytes(),
);
texture.create_view(&TextureViewDescriptor::default())
let view = texture.create_view(&TextureViewDescriptor::default());
let bind_group = Self::make_image_bind_group(
&self.device,
rsc_layout,
&self.array_view,
&view,
&self.sampler,
);
self.bind_group_creates += 1;
ImageGpu {
texture,
view,
bind_group,
}
}
/// 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,
array_view: &TextureView,
image_view: &TextureView,
sampler: &Sampler,
) -> BindGroup {
device.create_bind_group(&BindGroupDescriptor {
layout: rsc_layout,
entries: &[
BindGroupEntry {
binding: 0,
resource: BindingResource::TextureView(array_view),
},
BindGroupEntry {
binding: 1,
resource: BindingResource::TextureView(image_view),
},
BindGroupEntry {
binding: 2,
resource: BindingResource::Sampler(sampler),
},
],
label: Some("ui rsc image"),
})
}
/// 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,
"glyph atlas array asked for {capacity} layers; fewer than {MIN_ARRAY_LAYERS} is a \
GL_TEXTURE_2D on the GLES backend and draws every glyph as a box"
);
device.create_texture(&TextureDescriptor {
label: Some("glyph atlas array"),
size: Extent3d {
width: PAGE,
height: PAGE,
depth_or_array_layers: capacity,
},
mip_level_count: 1,
sample_count: 1,
dimension: TextureDimension::D2,
format: TextureFormat::Rgba8Unorm,
usage: TextureUsages::TEXTURE_BINDING
| TextureUsages::COPY_DST
| TextureUsages::COPY_SRC,
view_formats: &[],
})
}
pub fn new(device: &Device, queue: &Queue) -> Self {
let sampler = default_sampler(device);
let null_view = null_texture_view(device);
let array_capacity = MIN_ARRAY_LAYERS;
let array_texture = Self::create_array_texture(device, array_capacity);
let array_view = array_texture.create_view(&TextureViewDescriptor {
dimension: Some(TextureViewDimension::D2Array),
..Default::default()
});
Self {
device: device.clone(),
queue: queue.clone(),
views: Vec::new(),
samplers: vec![default_sampler(device)],
no_views: vec![null_view.clone()],
slots: Vec::new(),
array_texture,
array_view,
array_capacity,
page_count: 0,
sampler,
null_view,
view_count: 0,
bind_group_creates: 0,
pages_grown: 0,
}
}
pub fn views(&self) -> Vec<&TextureView> {
if self.views.is_empty() {
&self.no_views
} else {
&self.views
}
.iter()
.by_ref()
.collect()
/// 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)
}
pub fn samplers(&self) -> Vec<&Sampler> {
self.samplers.iter().by_ref().collect()
/// 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)
}
pub fn array_view(&self) -> &TextureView {
&self.array_view
}
pub fn null_view(&self) -> &TextureView {
&self.null_view
}
pub fn sampler(&self) -> &Sampler {
&self.sampler
}
/// The bind group a standalone image draws with. Panics if `idx` names an
/// atlas page or a freed slot instead -- either is a caller bug (the
/// wrong kind of instance reached this draw path), not a condition to
/// recover from.
pub fn image_bind_group(&self, idx: u32) -> &BindGroup {
match self.slots.get(idx as usize) {
Some(Slot::Image(gpu)) => &gpu.bind_group,
other => panic!("texture slot {idx} is not a live standalone image: {other:?}"),
}
}
pub fn view_count(&self) -> usize {
self.view_count
self.slots
.iter()
.filter(|s| !matches!(s, Slot::Empty))
.count()
}
}
impl std::fmt::Debug for Slot {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Slot::Empty => write!(f, "Empty"),
Slot::Image(_) => write!(f, "Image"),
Slot::Page(layer) => write!(f, "Page(layer={layer})"),
}
}
}
+7 -2
View File
@@ -21,13 +21,18 @@ impl<T: Pod> ArrBuf<T> {
_pd: PhantomData,
}
}
pub fn update(&mut self, device: &Device, queue: &Queue, data: &[T]) {
if self.len != data.len() {
/// Returns whether the underlying `Buffer` was recreated -- a caller that
/// cached a `BindGroup` referencing it (as `GpuTextures` does for the
/// masks buffer) needs to know to rebuild that too.
pub fn update(&mut self, device: &Device, queue: &Queue, data: &[T]) -> bool {
let resized = self.len != data.len();
if resized {
self.len = data.len();
self.buffer =
Self::init_buf(device, std::mem::size_of_val(data), self.usage, self.label);
}
queue.write_buffer(&self.buffer, 0, bytemuck::cast_slice(data));
resized
}
fn init_buf(device: &Device, size: usize, usage: BufferUsages, label: &'static str) -> Buffer {
let mut size = size as u64;
+152
View File
@@ -0,0 +1,152 @@
//! 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};
/// Reserved for the synthetic root; every real widget's `SlotId::as_u64`
/// starts at 1, so this can never collide with one (see that method's
/// doc comment).
const WINDOW_NODE: NodeId = NodeId(0);
fn node_id(id: WidgetId) -> NodeId {
NodeId(id.as_u64())
}
#[derive(Clone, PartialEq)]
struct Entry {
name: String,
role: Role,
bounds: PixelRegion,
}
fn entry_node(entry: &Entry) -> Node {
let mut node = Node::new(entry.role);
node.set_label(entry.name.clone());
node.set_bounds(Rect {
x0: entry.bounds.top_left.x as f64,
y0: entry.bounds.top_left.y as f64,
x1: entry.bounds.bot_right.x as f64,
y1: entry.bounds.bot_right.y as f64,
});
node
}
/// Owns the last tree pushed out, so `update` can tell "nothing
/// accessibility-relevant changed" from "something did" without asking
/// the platform adapter to diff two `Node`s itself. One of these per
/// window/view -- `default::DefaultUiState` and `android::AndroidUiState`
/// each keep one.
#[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,
}
impl AccessTree {
pub fn new() -> Self {
Self::default()
}
fn collect(
widgets: &Widgets,
render: &UiRenderState,
rsc: &dyn UiRsc,
) -> HashMap<WidgetId, Entry> {
let mut current = HashMap::default();
for id in widgets.named() {
let Some(bounds) = render.window_region(&id, rsc) else {
continue;
};
let Some(widget) = widgets.get_dyn(id) else {
continue;
};
current.insert(
id,
Entry {
name: widgets.label(id).clone(),
role: widget.access_role(),
bounds,
},
);
}
current
}
/// Walks `widgets.named()`, looks up each one's current screen bounds
/// via `render.window_region` (which resolves the same move-chain
/// `resolved_region` does, so a moved subtree reports where it
/// actually is), and returns a full `TreeUpdate` if and only if that
/// set differs from the last call -- added, removed, renamed, or
/// moved/resized. A widget that is named but not currently active
/// (not drawn this frame) is left out, the same as one never named at
/// all.
pub fn update(
&mut self,
widgets: &Widgets,
render: &UiRenderState,
rsc: &dyn UiRsc,
) -> Option<TreeUpdate> {
let current = Self::collect(widgets, render, rsc);
if current == self.known {
return None;
}
self.known = current.clone();
self.rebuilds += 1;
Some(build_update(&current))
}
/// The unconditional twin of `update`, for a platform adapter's
/// activation handler (`android/access.rs`'s `AndroidAccessSource`) --
/// AccessKit asks for a full tree the first time a client attaches,
/// which is exactly the case `update`'s diff-against-`known` is not
/// meant to answer (it may have already sent this same snapshot to a
/// client that has since detached and reattached).
pub fn build_full(widgets: &Widgets, render: &UiRenderState, rsc: &dyn UiRsc) -> TreeUpdate {
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)
}
}
fn build_update(current: &HashMap<WidgetId, Entry>) -> TreeUpdate {
let mut window = Node::new(Role::Window);
let mut nodes = Vec::with_capacity(current.len() + 1);
for (&id, entry) in current {
window.push_child(node_id(id));
nodes.push((node_id(id), entry_node(entry)));
}
nodes.push((WINDOW_NODE, window));
TreeUpdate {
nodes,
tree: Some(TreeInfo::new(WINDOW_NODE)),
tree_id: TreeId::ROOT,
focus: WINDOW_NODE,
}
}
+56 -1
View File
@@ -1,4 +1,6 @@
use crate::{LayerId, MaskIdx, PrimitiveHandle, TextureHandle, UiRegion, WidgetId};
use crate::{
LayerId, MaskIdx, MoveIdx, PrimitiveHandle, Size, TextureHandle, UiRegion, WidgetId, util::Vec2,
};
/// important non rendering data for retained drawing
#[derive(Debug)]
@@ -9,6 +11,59 @@ pub struct ActiveData {
pub textures: Vec<TextureHandle>,
pub primitives: Vec<PrimitiveHandle>,
pub children: Vec<WidgetId>,
/// The mask this widget was drawn **under** (its parent's), not the
/// one it set for itself -- see `own_mask` for that.
pub mask: MaskIdx,
/// The mask slot this widget allocated for *itself* with
/// `Painter::set_mask`, or `MaskIdx::NONE`. Kept across redraws and
/// rewritten in place, the way `move_slot` is: a `Masked` that pushed
/// a fresh slot each draw left every already-drawn descendant --
/// which `draw_inner`'s unchanged-region fast path does not revisit --
/// clipping to the *old* slot's region, so a composer whose bar had
/// since been placed at the bottom of the screen was still being
/// clipped to a box at the top of it and drew nothing (measured
/// 2026-09-06: four mask entries live, none of them the widget's
/// current region). Its path out is the `undraw` branch of
/// `UiRenderState::remove`, which drops the self-ownership ref taken
/// when the slot was allocated.
pub own_mask: MaskIdx,
pub layer: LayerId,
/// What `Widget::draw` returned the last time this widget was actually
/// drawn -- read by a parent placing this widget again without
/// redrawing it, replacing `Cache.size`'s old role. See LAYOUT.md
/// section 5.
pub size: Size,
/// This widget's slot in `UiData::move_offsets`, assigned on its first
/// draw and kept for the rest of its life (redraws reuse it in place
/// so a retained child's `parent` link never goes stale). See
/// LAYOUT.md section 2.
pub move_slot: MoveIdx,
/// How much of this widget's own `move_slot` delta is already folded
/// into `region` above, in window pixels. The two mechanisms that
/// write that slot disagree about this and cannot be told apart from
/// the slot alone: `UiRenderState::mov` shifts `region` and the delta
/// together (the *offered* region genuinely moved), while
/// `Painter::reposition` writes only the delta (`region` stays the
/// offered box and the delta says where inside it the content was
/// placed). So anything that wants the widget's real position --
/// `resolved_region`, and through it every hit test -- must subtract
/// this from the chain sum. Without it a panned widget's own hit box
/// sits at twice the pan while its descendants' are correct, which is
/// how it went unnoticed: the composer's field became untappable
/// after a finger pan (2026-09-06). Reset to zero whenever the widget
/// is really redrawn, since `draw_inner` zeroes the slot then too.
pub move_applied: Vec2,
/// The offset the last `Painter::reposition` placed this widget's
/// content at *within* `region`, in window pixels. The move slot has
/// exactly one owner and one meaning:
/// `move_offsets[move_slot] == move_applied + repositioned`. `mov`
/// adds to the first, `reposition` overwrites the second (it
/// recomputes `from` afresh every call, so repeating it must land on
/// the same answer rather than drifting), and both then rewrite the
/// slot from the sum -- which is what lets a parent both move a child
/// with its own layout and place it inside that moved region in one
/// frame. `LazySpan::place`'s Bottom-known branch does exactly that once a
/// row's blocks wrap. Reset to zero on a real redraw, with
/// `move_applied` and the slot itself.
pub repositioned: Vec2,
}
-18
View File
@@ -1,18 +0,0 @@
use crate::{BothAxis, Len, UiVec2, WidgetId, util::HashMap};
#[derive(Default)]
pub struct Cache {
pub size: BothAxis<HashMap<WidgetId, (UiVec2, Len)>>,
}
impl Cache {
pub fn remove(&mut self, id: WidgetId) {
self.size.x.remove(&id);
self.size.y.remove(&id);
}
pub fn clear(&mut self) {
self.size.x.clear();
self.size.y.clear();
}
}
+51 -4
View File
@@ -1,15 +1,16 @@
use crate::{Mask, TextData, Textures, WeakWidget, WidgetId, Widgets, util::TrackedArena};
use crate::{
Mask, MoveOffset, TextData, Textures, WeakWidget, WidgetId, Widgets, util::TrackedArena,
};
mod access;
mod active;
mod cache;
mod painter;
mod render_state;
mod size;
pub use access::*;
pub use active::*;
pub use painter::Painter;
pub use render_state::*;
pub use size::*;
#[derive(Default)]
pub struct UiData {
@@ -17,6 +18,52 @@ pub struct UiData {
pub textures: Textures,
pub text: TextData,
pub masks: TrackedArena<Mask, u32>,
/// One entry per widget ever drawn, forming the parent-linked chain
/// `resolve_move` walks in both shader stages. Allocated once on a
/// widget's first draw and reused for every later redraw of the same
/// id (never reallocated), 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>,
}
impl UiData {
/// Ask for `id`'s [`crate::Widget::tick`] to run every frame until it
/// says it is done. Idempotent -- registering an already-animating
/// widget is the ordinary case (a second fling before the first
/// settled) and must not tick it twice per frame.
pub fn animate(&mut self, id: WidgetId) {
if !self.animating.contains(&id) {
self.animating.push(id);
}
}
/// 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),
None => false,
});
for id in registered {
self.animate(id);
}
!self.animating.is_empty()
}
}
pub trait UiRsc {
+283 -33
View File
@@ -1,7 +1,10 @@
use crate::{
Axis, Len, RenderedText, Size, SizeCtx, StrongWidget, TextAttrs, TextBuffer, TextData,
TextureHandle, UiRegion, UiRenderState, UiRsc, Widget, WidgetId,
render::{Mask, MaskIdx, Primitive, PrimitiveHandle, PrimitiveInst},
Color, RenderedText, Size, StrongWidget, TextAttrs, TextBuffer, TextData, TextureHandle,
UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, WidgetId,
render::{
Drawn, GlyphPrimitive, Mask, MaskIdx, MoveIdx, Primitive, PrimitiveHandle, PrimitiveInst,
RectPrimitive,
},
util::Vec2,
};
@@ -12,6 +15,11 @@ pub struct Painter<'a> {
pub(super) region: UiRegion,
pub(super) mask: MaskIdx,
pub(super) move_slot: MoveIdx,
/// This widget's own mask slot, reused across redraws -- see
/// `ActiveData::own_mask`. `MaskIdx::NONE` until `set_mask` is called
/// for the first time in this widget's life.
pub(super) own_mask: MaskIdx,
pub(super) textures: Vec<TextureHandle>,
pub(super) primitives: Vec<PrimitiveHandle>,
pub(super) children: Vec<WidgetId>,
@@ -21,19 +29,49 @@ pub struct Painter<'a> {
impl<'a> Painter<'a> {
fn primitive_at<P: Primitive>(&mut self, primitive: P, region: UiRegion) {
let h = self.state.layers.write(
self.write_primitive(primitive, region, Drawn::Yes);
}
/// 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,
region: UiRegion,
drawn: Drawn,
) -> u32 {
let h = self.state.write_primitive(
self.layer,
drawn,
PrimitiveInst {
id: self.id,
primitive,
region,
mask_idx: self.mask,
move_idx: self.move_slot,
},
);
if self.mask != MaskIdx::NONE {
// TODO: I have no clue if this works at all :joy:
self.rsc.ui_mut().masks.push_ref(self.mask);
}
let slot = h.slot;
self.own(h);
slot
}
/// 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
.set_handle_index(h.slot, self.primitives.len() as u32);
self.primitives.push(h);
}
@@ -46,75 +84,291 @@ 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
/// so keeps pointing at whichever slot it was drawn under. See
/// `ActiveData::own_mask` for what pushing a fresh one cost.
pub fn set_mask(&mut self, region: UiRegion) {
assert!(self.mask == MaskIdx::NONE);
self.mask = self.rsc.ui_mut().masks.push(Mask { region });
let shape = self.write_primitive(RectPrimitive::color(Color::NONE), region, Drawn::No);
self.set_mask_to(shape);
}
/// Draws a widget within this widget's region.
pub fn widget<W: ?Sized>(&mut self, id: &StrongWidget<W>) {
self.widget_at(id, self.region);
/// Clip everything this widget draws after this call to `shape`'s
/// own shape -- the first primitive `shape`'s subtree drew, which
/// must already have been drawn this frame
/// (`UiRenderState::first_primitive`). What `.masked_by()` uses to
/// clip a container's content to the rounded background it draws,
/// with no radius argument anywhere that could fall out of step with
/// the one being drawn.
pub fn set_mask_to_widget<W: ?Sized>(&mut self, shape: &StrongWidget<W>) {
let slot = self.state.first_primitive(shape.id()).unwrap_or_else(|| {
panic!(
"'{}' was given as a mask's shape but drew no primitive, so there is nothing to \
clip to",
self.rsc.widgets().label(shape.id()),
)
});
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
// (docs/REVIEW-2026-09-07.md's R1).
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,
RectPrimitive::BINDING,
"a mask's shape must be a rect primitive; primitive {shape} is binding {binding}",
);
let parent = self.mask;
let mask = Mask {
primitive: shape,
parent,
};
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
} else {
let old = self.rsc.ui().masks[self.own_mask.idx()].parent;
*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);
}
if old_parent != MaskIdx::NONE {
self.rsc.ui_mut().masks.remove(old_parent);
}
}
self.mask = self.own_mask;
}
/// Draws a widget within this widget's region, returning the size it
/// reported using.
pub fn widget<W: ?Sized>(&mut self, id: &StrongWidget<W>) -> Size {
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<W: ?Sized>(&mut self, id: &StrongWidget<W>, region: UiRegion) {
self.widget_at(id, region.within(&self.region));
pub fn widget_within<W: ?Sized>(&mut self, id: &StrongWidget<W>, region: UiRegion) -> Size {
self.widget_at(id, region.within(&self.region))
}
fn widget_at<W: ?Sized>(&mut self, id: &StrongWidget<W>, region: UiRegion) {
fn widget_at<W: ?Sized>(&mut self, id: &StrongWidget<W>, region: UiRegion) -> Size {
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`.
self.state.draw_inner(
self.layer,
id.id(),
region,
Some(self.id),
self.move_slot.idx() as u32,
self.mask,
None,
None,
crate::render::MaskIdx::NONE,
self.rsc,
);
self.state
.active
.get(&id.id())
.map(|a| a.size)
.unwrap_or_default()
}
/// Move an already-drawn child from wherever it currently sits to
/// `region` (resolved against this widget's own region, matching
/// `widget_within`) without a second draw -- an O(1) offset write via
/// `UiRenderState::mov`. For a container that draws a child
/// provisionally to learn its size (e.g. `Aligned`) and then places it
/// for real. Only valid when the target keeps the child's drawn size;
/// if the shape actually changes, the normal `widget_within` dispatch
/// (which detects that from the stored region) does the right thing
/// instead.
pub fn reposition<W: ?Sized>(&mut self, id: &StrongWidget<W>, region: UiRegion) {
let region = region.within(&self.region);
self.state.reposition(id.id(), region, self.rsc);
}
/// Draw `child` at a provisional region to learn its size under one
/// axis's worth of assumption, discard everything it wrote, then draw
/// it again at the region that assumption produced. For the rare
/// parent that cannot pick an offered size without already knowing the
/// answer. Twice the cost of one `draw`; every other case in this file
/// avoids it.
pub fn draw_twice<W: ?Sized>(
&mut self,
id: &StrongWidget<W>,
first: UiRegion,
second: impl FnOnce(Size) -> UiRegion,
) -> Size {
let used = self.widget_within(id, first);
let region = second(used);
self.widget_within(id, region)
}
pub fn texture_within(&mut self, handle: &TextureHandle, region: UiRegion) {
self.textures.push(handle.clone());
self.primitive_at(handle.primitive(), region.within(&self.region));
self.write_image(handle.image_index(), region.within(&self.region));
}
pub fn texture(&mut self, handle: &TextureHandle) {
self.textures.push(handle.clone());
self.primitive(handle.primitive());
self.write_image(handle.image_index(), self.region);
}
pub fn texture_at(&mut self, handle: &TextureHandle, region: UiRegion) {
self.textures.push(handle.clone());
self.primitive_at(handle.primitive(), region);
self.write_image(handle.image_index(), region);
}
/// returns (handle, offset from top left)
pub fn render_text(&mut self, buffer: &mut TextBuffer, attrs: &TextAttrs) -> RenderedText {
/// 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 = self.state.write_image(
self.layer,
self.id,
texture_idx,
region,
self.mask,
self.move_slot,
);
if self.mask != MaskIdx::NONE {
self.rsc.ui_mut().masks.push_ref(self.mask);
}
self.own(h);
}
pub fn render_text(
&mut self,
buffer: &mut TextBuffer,
attrs: &TextAttrs,
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.draw(buffer, attrs, &mut ui.textures)
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
// something else. Caught at the submission rather than on screen,
// where it reads as fragments of unrelated letters. `assert_eq!`
// for R1's reason: two integers per laid-out string, not per
// glyph, and the failure is unreadable text on a release build.
assert_eq!(
text.generation,
self.atlas_generation(),
"glyphs placed against atlas generation {} submitted against {}: the holder did not \
re-render after the atlas was cleared",
text.generation,
self.atlas_generation(),
);
let flags_for = |is_color| {
if is_color {
GlyphPrimitive::IS_COLOR
} else {
0
}
};
for glyph in text.glyphs.iter() {
let mut region = origin;
region.x.end = region.x.start;
region.y.end = region.y.start;
let mut region = region.offset(UiVec2::abs(glyph.offset));
region.x.end = region.x.start + UiScalar::abs(glyph.entry.width as f32);
region.y.end = region.y.start + UiScalar::abs(glyph.entry.height as f32);
self.primitive_at(
GlyphPrimitive::new(
glyph.entry.uv_min,
glyph.entry.uv_max,
glyph.entry.layer,
glyph.color,
flags_for(glyph.entry.is_color),
),
region,
);
}
}
pub fn region(&self) -> UiRegion {
self.region
}
pub fn size<W: ?Sized + Widget>(&mut self, id: &StrongWidget<W>) -> Size {
self.size_ctx().size(id)
}
pub fn len_axis<W: ?Sized + Widget>(&mut self, id: &StrongWidget<W>, axis: Axis) -> Len {
match axis {
Axis::X => self.size_ctx().width(id),
Axis::Y => self.size_ctx().height(id),
}
}
pub fn output_size(&self) -> Vec2 {
self.state.output_size
}
/// Physical pixels per `dp` -- see `UiRenderState::density`'s field
/// doc. What `Len::dp`'s `apply_rest` call resolves against.
pub fn density(&self) -> f32 {
self.state.density
}
pub fn px_size(&mut self) -> Vec2 {
self.region.size().to_abs(self.state.output_size)
}
@@ -138,8 +392,4 @@ impl<'a> Painter<'a> {
pub fn id(&self) -> &WidgetId {
&self.id
}
pub fn size_ctx(&mut self) -> SizeCtx<'_> {
self.state.size_ctx(self.id, self.region.size(), self.rsc)
}
}
File diff suppressed because it is too large. Load diff
-86
View File
@@ -1,86 +0,0 @@
use crate::{
Axis, AxisT, IdLike, Len, RenderedText, Size, TextAttrs, TextBuffer, TextData, Textures,
UiVec2, WidgetAxisFns, WidgetId, Widgets, XAxis, YAxis, ui::cache::Cache, util::Vec2,
};
pub struct SizeCtx<'a> {
pub text: &'a mut TextData,
pub textures: &'a mut Textures,
pub(super) source: WidgetId,
pub(super) widgets: &'a Widgets,
pub(super) cache: &'a mut Cache,
/// TODO: should this be pub? rn used for sized
pub outer: UiVec2,
pub(super) output_size: Vec2,
pub(super) id: WidgetId,
}
impl SizeCtx<'_> {
pub fn id(&self) -> &WidgetId {
&self.id
}
pub fn source(&self) -> &WidgetId {
&self.source
}
pub(super) fn len_inner<A: const AxisT>(&mut self, id: WidgetId) -> Len {
if let Some((_, len)) = self.cache.size.axis::<A>().get(&id) {
return *len;
}
let len = self
.widgets
.get_dyn_dynamic(id)
.desired_len::<A>(&mut SizeCtx {
text: self.text,
textures: self.textures,
source: self.source,
widgets: self.widgets,
cache: self.cache,
outer: self.outer,
output_size: self.output_size,
id,
});
self.cache.size.axis::<A>().insert(id, (self.outer, len));
len
}
pub fn width(&mut self, id: impl IdLike) -> Len {
self.len_inner::<XAxis>(id.id())
}
pub fn height(&mut self, id: impl IdLike) -> Len {
self.len_inner::<YAxis>(id.id())
}
pub fn len_axis(&mut self, id: impl IdLike, axis: Axis) -> Len {
match axis {
Axis::X => self.width(id),
Axis::Y => self.height(id),
}
}
pub fn size(&mut self, id: impl IdLike) -> Size {
let id = id.id();
Size {
x: self.width(id),
y: self.height(id),
}
}
pub fn px_size(&mut self) -> Vec2 {
self.outer.to_abs(self.output_size)
}
pub fn output_size(&mut self) -> Vec2 {
self.output_size
}
pub fn draw_text(&mut self, buffer: &mut TextBuffer, attrs: &TextAttrs) -> RenderedText {
self.text.draw(buffer, attrs, self.textures)
}
pub fn label(&self, id: WidgetId) -> &String {
self.widgets.label(id)
}
}
+9
View File
@@ -71,6 +71,15 @@ 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.changed = true;
&mut self.inner.data[id.idx()]
}
pub fn remove(&mut self, id: Id<I>) -> T
where
T: Copy,
+9 -8
View File
@@ -9,15 +9,16 @@ pub const trait DivOr {
fn div_or(self, rhs: Self, other: Self) -> Self;
}
impl const DivOr for f32 {
const impl DivOr for f32 {
fn div_or(self, rhs: Self, other: Self) -> Self {
let res = self / rhs;
if res.is_nan() { other } else { res }
}
}
impl<T: const Add<Output = T> + const Sub<Output = T> + const Mul<Output = T> + const DivOr + Copy> const
LerpUtil for T
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
@@ -37,7 +38,7 @@ macro_rules! impl_op {
use super::*;
#[allow(unused_imports)]
use std::ops::*;
impl const $op for $T {
const impl $op for $T {
type Output = Self;
fn $fn(self, rhs: Self) -> Self::Output {
@@ -46,12 +47,12 @@ macro_rules! impl_op {
}
}
}
impl const $opa for $T {
const impl $opa for $T {
fn $fna(&mut self, rhs: Self) {
*self = self.$fn(rhs);
}
}
impl const $op<f32> for $T {
const impl $op<f32> for $T {
type Output = Self;
fn $fn(self, rhs: f32) -> Self::Output {
@@ -60,7 +61,7 @@ macro_rules! impl_op {
}
}
}
impl const $op<$T> for f32 {
const impl $op<$T> for f32 {
type Output = $T;
fn $fn(self, rhs: $T) -> Self::Output {
@@ -69,7 +70,7 @@ macro_rules! impl_op {
}
}
}
impl const $opa<f32> for $T {
const impl $opa<f32> for $T {
fn $fna(&mut self, rhs: f32) {
*self = self.$fn(rhs);
}
+11
View File
@@ -4,6 +4,17 @@ pub struct SlotId {
genr: u32,
}
impl SlotId {
/// A stable, collision-free `u64` encoding of this id -- for a caller
/// (accesskit's `NodeId`, today) that wants a flat integer key rather
/// than the two `u32`s. `idx` is offset by one so no real id ever
/// encodes to 0, which callers can then reserve for their own
/// out-of-band root/window node.
pub fn as_u64(&self) -> u64 {
((self.idx as u64) + 1) << 32 | self.genr as u64
}
}
pub struct SlotVec<T> {
data: Vec<(u32, Option<T>)>,
free: Vec<u32>,
+1 -1
View File
@@ -67,7 +67,7 @@ impl_op!(Vec2 Sub sub; x y);
impl_op!(Vec2 Mul mul; x y);
impl_op!(Vec2 Div div; x y);
impl const DivOr for Vec2 {
const impl DivOr for Vec2 {
fn div_or(self, rhs: Self, other: Self) -> Self {
Self {
x: self.x.div_or(rhs.x, other.x),
+47 -19
View File
@@ -1,4 +1,4 @@
use crate::{Axis, AxisT, Len, Painter, SizeCtx};
use crate::{Painter, Size};
use std::any::Any;
mod data;
@@ -16,31 +16,59 @@ pub use view::*;
pub use widgets::*;
pub trait Widget: Any {
fn draw(&mut self, painter: &mut Painter);
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len;
fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len;
}
/// Draw within `painter.region()` (the space the parent offered) and
/// report how much of it was actually used, per axis.
fn draw(&mut self, painter: &mut Painter) -> Size;
pub trait WidgetAxisFns {
fn desired_len<A: AxisT>(&mut self, ctx: &mut SizeCtx) -> Len;
}
/// True if `draw`'s output (both the primitives it writes and the
/// `Size` it returns) is the same for any `painter.region()` of the
/// same *content* -- an icon, a fixed-size rect, an already-decoded
/// image at its natural size. Default `false` (redraw on any change to
/// the offered region) because assuming independence wrongly produces
/// a stale draw; a widget must opt in. See LAYOUT.md.
fn is_size_independent(&self) -> bool {
false
}
impl<W: Widget + ?Sized> WidgetAxisFns for W {
fn desired_len<A: AxisT>(&mut self, ctx: &mut SizeCtx) -> Len {
match A::get() {
Axis::X => self.desired_width(ctx),
Axis::Y => self.desired_height(ctx),
}
/// What kind of control this is, for the AccessKit tree `ui::access`
/// builds (RUST.md's I4). Only consulted for a widget that also has an
/// explicit `.label()` -- an unnamed widget is never visited by that
/// tree at all, named or not, so the default here costs nothing except
/// at the handful of call sites that opt in. Default `Unknown` (a
/// generic control with no more specific semantics); a widget with a
/// real platform equivalent -- `TextEdit`'s `MultilineTextInput` --
/// overrides it.
fn access_role(&self) -> accesskit::Role {
accesskit::Role::Unknown
}
/// Advance whatever this widget is animating to `now`, and say whether
/// it is still animating afterwards. Default: nothing is, so a widget
/// opts in by overriding this *and* by something calling
/// [`crate::UiData::animate`] with its id when the animation starts --
/// which is that animation's path out, since the driver
/// ([`crate::UiData::tick_animations`]) drops every id whose `tick`
/// answers `false`.
///
/// Called once per frame, before the frame's draw, by whichever
/// backend owns the surface; a `true` answer is what makes that
/// backend ask for another frame. So this is the only thing in iris
/// that moves without an input event, and a widget that animates
/// without registering simply never moves -- which is exactly how a
/// finger fling looked on Iris's phone before this existed.
#[allow(unused_variables)]
fn tick(&mut self, now: std::time::Instant) -> bool {
false
}
}
impl Widget for () {
fn draw(&mut self, _: &mut Painter) {}
fn desired_width(&mut self, _: &mut SizeCtx) -> Len {
Len::ZERO
fn draw(&mut self, _: &mut Painter) -> Size {
Size::ZERO
}
fn desired_height(&mut self, _: &mut SizeCtx) -> Len {
Len::ZERO
fn is_size_independent(&self) -> bool {
true
}
}
+20 -2
View File
@@ -11,6 +11,11 @@ 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>,
}
impl Widgets {
@@ -20,6 +25,7 @@ impl Widgets {
needs_redraw: Default::default(),
vec: Default::default(),
waiting: Default::default(),
named: Default::default(),
send,
recv,
}
@@ -95,9 +101,20 @@ impl Widgets {
&self.data(id.id()).unwrap().label
}
/// useful for debugging
/// 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) {
self.data_mut(id.id()).unwrap().label = label;
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()
}
pub fn data_mut(&mut self, id: impl IdLike) -> Option<&mut WidgetData> {
@@ -107,6 +124,7 @@ impl Widgets {
pub fn free_next(&mut self) -> Option<WidgetId> {
let next = self.recv.try_recv().ok()?;
self.vec.free(next);
self.named.remove(&next);
Some(next)
}