diff --git a/docs/LAYOUT.md b/docs/LAYOUT.md index d3ebbe4..6e559a1 100644 --- a/docs/LAYOUT.md +++ b/docs/LAYOUT.md @@ -215,6 +215,12 @@ widget observed during that same draw; the next draw replaces the list, so a dependency disappears as soon as the widget stops reading it. Both fields have `ActiveData`'s existing lifecycle through `remove`/`remove_rec`. +Retained draw output uses two buffers per collection. A redraw clears and +fills the spare child, primitive, texture, and paint buffers while consuming +the current buffers for reuse, then swaps their roles. Stable redraws therefore +reuse vector capacity and move matching resource handles instead of allocating +new collections or changing resource reference counts each frame. + ### 6. Rejected alternatives - **A flat (non-chained) per-subtree offset table**, Iris's literal diff --git a/docs/PLAN.md b/docs/PLAN.md index 3696864..9931ca8 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -819,7 +819,9 @@ reads it during `draw` and calls `request_next_frame` while it remains active. Iris stages the requesting widget's invalidation until the current retained redraw has finished, then the host schedules the next platform frame before renderer update or swapchain acquisition. `Widget::draw` still returns unit, -and `Widget` has no tick callback. +and `Widget` has no tick callback. Retained draw records alternate between two +buffers for children, primitives, textures, and paints, so steady frame-driven +redraws reuse their storage and matching resource handles. **Iris ships no fonts, and font families are application-named strings** (2026-09-12). Applications register their own font bytes on `Ui` and select diff --git a/iris/core/src/primitive/text.rs b/iris/core/src/primitive/text.rs index 1b44c5e..e8a41fe 100644 --- a/iris/core/src/primitive/text.rs +++ b/iris/core/src/primitive/text.rs @@ -617,6 +617,15 @@ impl From<&TextAttrs> for TextShapeAttrs { } } +impl TextShapeAttrs { + fn matches(&self, attrs: &TextAttrs) -> bool { + self.color == attrs.color + && self.font_size == attrs.font_size + && self.line_height == attrs.line_height + && self.family == attrs.family + } +} + pub const LINE_HEIGHT_MULT: f32 = 1.1; impl Default for TextAttrs { @@ -701,10 +710,16 @@ impl TextBuffer { width: Option, density: f32, ) { - let shape_attrs = TextShapeAttrs::from(attrs); - if self.shaped.as_ref() == Some(&(shape_attrs.clone(), width, density)) { + if self + .shaped + .as_ref() + .is_some_and(|(old, old_width, old_density)| { + old.matches(attrs) && *old_width == width && *old_density == density + }) + { return; } + let shape_attrs = TextShapeAttrs::from(attrs); let base_family = data.resolve_family(&attrs.family); let span_families: Vec> = self .spans diff --git a/iris/core/src/primitive/texture.rs b/iris/core/src/primitive/texture.rs index c4943db..461c33f 100644 --- a/iris/core/src/primitive/texture.rs +++ b/iris/core/src/primitive/texture.rs @@ -34,6 +34,14 @@ pub struct TextureHandle { rsc: RscHandle, } +impl PartialEq for TextureHandle { + fn eq(&self, other: &Self) -> bool { + self.rsc.id() == other.rsc.id() + } +} + +impl Eq for TextureHandle {} + /// a texture manager for a ui /// note that this is heavily oriented towards wgpu's renderer so the primitives don't need mapped pub struct Textures { diff --git a/iris/core/src/render/mod.rs b/iris/core/src/render/mod.rs index 35d8c4c..b5ebe84 100644 --- a/iris/core/src/render/mod.rs +++ b/iris/core/src/render/mod.rs @@ -112,6 +112,14 @@ impl WgpuErrorLog { pub fn snapshot(&self) -> Vec { self.errors.lock().unwrap().iter().cloned().collect() } + + pub fn len(&self) -> usize { + self.errors.lock().unwrap().len() + } + + pub fn is_empty(&self) -> bool { + self.errors.lock().unwrap().is_empty() + } } pub struct UiRenderNode { @@ -206,11 +214,13 @@ impl UiRenderNode { rlayer.order.update(device, queue, entries, dirty); let (entries, dirty) = order.images_for_upload(); rlayer.images.update(device, queue, entries, dirty); - rlayer.image_tex_indices = order - .images() - .iter() - .map(|&slot| ui_render.primitives.instance(slot).idx) - .collect(); + rlayer.image_tex_indices.clear(); + rlayer.image_tex_indices.extend( + order + .images() + .iter() + .map(|&slot| ui_render.primitives.instance(slot).idx), + ); order.updated = false; } } diff --git a/iris/core/src/render/primitive.rs b/iris/core/src/render/primitive.rs index 1d8ffd3..d7950d2 100644 --- a/iris/core/src/render/primitive.rs +++ b/iris/core/src/render/primitive.rs @@ -450,19 +450,26 @@ impl LayerOrder { } pub fn apply_free(&mut self) -> Vec { - let mut changes = Self::apply_free_list( + let mut changes = Vec::new(); + self.apply_free_into(&mut changes); + changes + } + + pub(crate) fn apply_free_into(&mut self, changes: &mut Vec) { + Self::apply_free_list( &mut self.free, &mut self.order, &mut self.order_dirty, false, + changes, ); - changes.extend(Self::apply_free_list( + Self::apply_free_list( &mut self.image_free, &mut self.images, &mut self.images_dirty, true, - )); - changes + changes, + ); } pub fn order_for_upload(&mut self) -> (&[u32], &mut Dirty) { @@ -478,22 +485,20 @@ impl LayerOrder { list: &mut Vec, dirty: &mut Dirty, is_image: bool, - ) -> Vec { + changes: &mut Vec, + ) { free.sort_by(|a, b| b.cmp(a)); - free.drain(..) - .filter_map(|pos| { - list.swap_remove(pos); - if pos == list.len() { - return None; - } + for pos in free.drain(..) { + list.swap_remove(pos); + if pos != list.len() { dirty.mark(pos); - Some(OrderChange { + changes.push(OrderChange { slot: list[pos], is_image, pos, - }) - }) - .collect() + }); + } + } } pub fn order(&self) -> &Vec { @@ -523,7 +528,7 @@ pub enum Drawn { pub const NOT_DRAWN: usize = usize::MAX; -#[derive(Debug)] +#[derive(Clone, Copy, Debug)] pub struct PrimitiveHandle { pub layer: usize, pub pos: usize, diff --git a/iris/core/src/render/util/mod.rs b/iris/core/src/render/util/mod.rs index c87ed7f..305fec1 100644 --- a/iris/core/src/render/util/mod.rs +++ b/iris/core/src/render/util/mod.rs @@ -90,13 +90,13 @@ impl ArrBuf { } self.len = data.len(); let stride = std::mem::size_of::() as BufferAddress; - for range in dirty.ranges(data.len(), Self::MERGE_GAP) { + dirty.for_each_range(data.len(), Self::MERGE_GAP, |range| { queue.write_buffer( &self.buffer, range.start as BufferAddress * stride, bytemuck::cast_slice(&data[range]), ); - } + }); dirty.clear(); reallocated } diff --git a/iris/core/src/ui/access.rs b/iris/core/src/ui/access.rs index d8c8306..a7bfe12 100644 --- a/iris/core/src/ui/access.rs +++ b/iris/core/src/ui/access.rs @@ -10,11 +10,11 @@ fn node_id(id: WidgetId) -> NodeId { NodeId(id.as_u64()) } -#[derive(Clone, PartialEq)] struct Entry { name: String, role: Role, bounds: PixelRegion, + seen: u64, } fn entry_node(entry: &Entry) -> Node { @@ -37,6 +37,7 @@ fn entry_node(entry: &Entry) -> Node { #[derive(Default)] pub struct AccessTree { known: HashMap, + generation: u64, rebuilds: u64, } @@ -45,12 +46,17 @@ impl AccessTree { Self::default() } - fn collect( - widgets: &Widgets, - render: &UiRenderState, - rsc: &dyn UiRsc, - ) -> HashMap { - let mut current = HashMap::default(); + /// Refresh the retained accessibility state and report whether it changed. + /// Existing entries are updated in place so an ordinary frame allocates + /// nothing, including one where only bounds changed. + pub fn refresh(&mut self, widgets: &Widgets, render: &UiRenderState, rsc: &dyn UiRsc) -> bool { + self.generation = self.generation.wrapping_add(1); + if self.generation == 0 { + self.known.clear(); + self.generation = 1; + } + let generation = self.generation; + let mut changed = false; for id in widgets.named() { let Some(bounds) = render.window_region(&id, rsc) else { continue; @@ -58,16 +64,42 @@ impl AccessTree { let Some(widget) = widgets.get_dyn(id) else { continue; }; - current.insert( - id, - Entry { - name: widgets.label(id).clone(), - role: widget.access_role(), - bounds, - }, - ); + let name = widgets.label(id); + let role = widget.access_role(); + match self.known.get_mut(&id) { + Some(entry) => { + if entry.name != *name { + entry.name.clone_from(name); + changed = true; + } + if entry.role != role || entry.bounds != bounds { + entry.role = role; + entry.bounds = bounds; + changed = true; + } + entry.seen = generation; + } + None => { + self.known.insert( + id, + Entry { + name: name.clone(), + role, + bounds, + seen: generation, + }, + ); + changed = true; + } + } } - current + let old_len = self.known.len(); + self.known.retain(|_, entry| entry.seen == generation); + changed |= self.known.len() != old_len; + if changed { + self.rebuilds += 1; + } + changed } /// Walks `widgets.named()`, looks up each one's current screen bounds @@ -84,13 +116,14 @@ impl AccessTree { render: &UiRenderState, rsc: &dyn UiRsc, ) -> Option { - let current = Self::collect(widgets, render, rsc); - if current == self.known { + if !self.refresh(widgets, render, rsc) { return None; } - self.known = current.clone(); - self.rebuilds += 1; - Some(build_update(¤t)) + Some(self.tree_update()) + } + + pub fn tree_update(&self) -> TreeUpdate { + build_update(&self.known) } /// The unconditional twin of `update`, for a platform adapter's @@ -100,7 +133,9 @@ impl AccessTree { /// 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)) + let mut tree = Self::new(); + tree.refresh(widgets, render, rsc); + tree.tree_update() } pub fn take_rebuilds(&mut self) -> u64 { diff --git a/iris/core/src/ui/active.rs b/iris/core/src/ui/active.rs index 0d47649..05901e6 100644 --- a/iris/core/src/ui/active.rs +++ b/iris/core/src/ui/active.rs @@ -9,12 +9,16 @@ pub struct ActiveData { pub region: UiRegion, pub parent: Option, pub textures: Vec, + pub(crate) spare_textures: Vec, /// Paint slots retained by this draw. The GPU primitive stores only the /// slot index, so these handles are what prevent a live primitive from /// observing a recycled paint. pub paints: Vec, + pub(crate) spare_paints: Vec, pub primitives: Vec, + pub(crate) spare_primitives: Vec, pub children: Vec, + pub(crate) spare_children: Vec, pub size_dependencies: Vec, pub mask: MaskIdx, /// The widget's retained mask slot, or `MaskIdx::NONE`. diff --git a/iris/core/src/ui/mod.rs b/iris/core/src/ui/mod.rs index 433ca33..b66f7d7 100644 --- a/iris/core/src/ui/mod.rs +++ b/iris/core/src/ui/mod.rs @@ -122,13 +122,11 @@ impl Ui { update(&mut text)?; text.invalidate_all() }; - let active: Vec = { + let mut active = owners; + { let render = self.render_state.get(); - owners - .into_iter() - .filter(|owner| render.active.contains_key(owner)) - .collect() - }; + active.retain(|owner| render.active.contains_key(owner)); + } self.data.widgets.needs_redraw.extend(active); Ok(()) } diff --git a/iris/core/src/ui/painter.rs b/iris/core/src/ui/painter.rs index 012ad20..49f532c 100644 --- a/iris/core/src/ui/painter.rs +++ b/iris/core/src/ui/painter.rs @@ -21,9 +21,12 @@ pub struct Painter<'a> { pub(super) child_move_slot: Option, pub(super) own_mask: MaskIdx, pub(super) textures: Vec, + pub(super) recycle_textures: Vec, pub(super) paints: Vec, + pub(super) recycle_paints: Vec, pub(super) primitives: Vec, - pub(super) recycle: std::iter::Peekable>, + pub(super) recycle: Vec, + pub(super) recycle_at: usize, pub(super) children: Vec, pub(super) size_dependencies: Vec, pub(super) size: Option, @@ -87,12 +90,13 @@ impl<'a> Painter<'a> { /// anyway. Stopping is also what keeps the invariant simple: every /// handle from `recycled` on is untouched and gets freed together. fn take_recycled(&mut self, binding: u32, drawn: Drawn) -> Option { - let h = self.recycle.peek()?; + let h = *self.recycle.get(self.recycle_at)?; let drawn_matches = (h.pos == NOT_DRAWN) == (drawn == Drawn::No); if h.binding != binding || h.layer != self.layer || !drawn_matches { return None; } - self.recycle.next() + self.recycle_at += 1; + Some(h) } fn write_primitive( @@ -139,14 +143,18 @@ impl<'a> Painter<'a> { /// primitive and retains the handle for exactly as long as that draw. pub fn paint(&mut self, paint: &PaintId) -> u32 { if !self.paints.contains(paint) { - self.paints.push(paint.clone()); + if let Some(i) = self.recycle_paints.iter().position(|old| old == paint) { + self.paints.push(self.recycle_paints.swap_remove(i)); + } else { + self.paints.push(paint.clone()); + } } paint.slot() } pub fn paint_value(&mut self, paint: &mut crate::PaintValue) -> u32 { - let paint = paint.resolve(&mut self.rsc.ui_mut().paints).clone(); - self.paint(&paint) + let paint = paint.resolve(&mut self.rsc.ui_mut().paints); + self.paint(paint) } pub fn primitive_within(&mut self, primitive: P, region: UiRegion) { @@ -387,20 +395,31 @@ impl<'a> Painter<'a> { } pub fn texture_within(&mut self, handle: &TextureHandle, region: UiRegion) { - self.textures.push(handle.clone()); + self.retain_texture(handle); self.write_image(handle.image_index(), region.within(&self.region)); } pub fn texture(&mut self, handle: &TextureHandle) { - self.textures.push(handle.clone()); + self.retain_texture(handle); self.write_image(handle.image_index(), self.region); } pub fn texture_at(&mut self, handle: &TextureHandle, region: UiRegion) { - self.textures.push(handle.clone()); + self.retain_texture(handle); self.write_image(handle.image_index(), region); } + fn retain_texture(&mut self, handle: &TextureHandle) { + if self.textures.contains(handle) { + return; + } + if let Some(i) = self.recycle_textures.iter().position(|old| old == handle) { + self.textures.push(self.recycle_textures.swap_remove(i)); + } else { + self.textures.push(handle.clone()); + } + } + fn write_image(&mut self, texture_idx: u32, region: UiRegion) { let h = match self.take_recycled(IMAGE_BINDING, Drawn::Yes) { Some(h) => { diff --git a/iris/core/src/ui/render_state.rs b/iris/core/src/ui/render_state.rs index 662a626..d47f7dc 100644 --- a/iris/core/src/ui/render_state.rs +++ b/iris/core/src/ui/render_state.rs @@ -5,8 +5,8 @@ use crate::{ ActiveData, Axis, ChildOrder, IdLike, MaskIdx, MoveIdx, Painter, PixelRegion, PrimitiveLayers, RegionAlign, Size, StrongWidget, UiRegion, UiRsc, UiVec2, Widget, WidgetId, Widgets, render::{ - Drawn, MoveOffset, NOT_DRAWN, Primitive, PrimitiveHandle, PrimitiveInst, Primitives, - RectPrimitive, rounded_rect_coverage, + Drawn, MoveOffset, NOT_DRAWN, OrderChange, Primitive, PrimitiveHandle, PrimitiveInst, + Primitives, RectPrimitive, rounded_rect_coverage, }, util::{HashMap, HashSet, Id, Vec2}, }; @@ -30,6 +30,7 @@ pub struct UiRenderState { pub active: HashMap, pub primitives: Primitives, pub layers: PrimitiveLayers, + order_changes: Vec, pub(super) output_size: Vec2, /// Physical pixels per `dp` -- see `LayoutLen::dp`'s field doc. `1.0` (an /// unscaled display) until a backend that knows its own density calls @@ -80,11 +81,17 @@ pub struct UiRenderState { pub(crate) struct Retained { pub region: Option, pub children: Vec, + pub spare_children: Vec, pub move_slot: Option, pub child_move_slot: Option, pub own_mask: MaskIdx, + pub textures: Vec, + pub spare_textures: Vec, pub primitives: Vec, + pub spare_primitives: Vec, pub paints: Vec, + pub spare_paints: Vec, + pub size_dependencies: Vec, } impl Default for Retained { @@ -92,11 +99,17 @@ impl Default for Retained { Self { region: None, children: Vec::new(), + spare_children: Vec::new(), move_slot: None, child_move_slot: None, own_mask: MaskIdx::NONE, + textures: Vec::new(), + spare_textures: Vec::new(), primitives: Vec::new(), + spare_primitives: Vec::new(), paints: Vec::new(), + spare_paints: Vec::new(), + size_dependencies: Vec::new(), } } } @@ -109,6 +122,7 @@ impl UiRenderState { active: Default::default(), primitives: Default::default(), layers: Default::default(), + order_changes: Vec::new(), output_size: Vec2::ZERO, density: 1.0, old_root: None, @@ -184,8 +198,11 @@ impl UiRenderState { } fn apply_free(&mut self) { + let mut changes = std::mem::take(&mut self.order_changes); for (layer, order) in self.layers.iter_mut() { - for change in order.apply_free() { + changes.clear(); + order.apply_free_into(&mut changes); + for change in changes.drain(..) { let owner = self.primitives.owner(change.slot); let Some(idx) = self.primitives.handle_index(change.slot) else { continue; @@ -204,6 +221,7 @@ impl UiRenderState { } } } + self.order_changes = changes; self.primitives.release_freed(); } @@ -380,11 +398,17 @@ impl UiRenderState { let Retained { region: mut old_region, children: mut old_children, + spare_children: mut children, move_slot: mut old_move_slot, mut child_move_slot, mut own_mask, + textures: mut recycle_textures, + spare_textures: mut textures, primitives: mut recycle, - paints: _old_paints, + spare_primitives: mut primitives, + paints: mut recycle_paints, + spare_paints: mut paints, + mut size_dependencies, } = retained; let dirty = rsc.widgets_mut().needs_redraw.remove(&id); let requires_exact_region = rsc @@ -434,10 +458,17 @@ impl UiRenderState { let active = self.remove(id, false, true, rsc).unwrap(); old_region = Some(active.region); old_children = active.children; + children = active.spare_children; old_move_slot = Some(active.move_slot); child_move_slot = active.child_move_slot; own_mask = active.own_mask; + recycle_textures = active.textures; + textures = active.spare_textures; recycle = active.primitives; + primitives = active.spare_primitives; + recycle_paints = active.paints; + paints = active.spare_paints; + size_dependencies = active.size_dependencies; } else if self.active.contains_key(&id) { let layer_changed = self .active @@ -449,12 +480,25 @@ impl UiRenderState { } old_region = Some(active.region); old_children = active.children; + children = active.spare_children; old_move_slot = Some(active.move_slot); child_move_slot = active.child_move_slot; own_mask = active.own_mask; + recycle_textures = active.textures; + textures = active.spare_textures; recycle = active.primitives; + primitives = active.spare_primitives; + recycle_paints = active.paints; + paints = active.spare_paints; + size_dependencies = active.size_dependencies; } + textures.clear(); + paints.clear(); + primitives.clear(); + children.clear(); + size_dependencies.clear(); + let reentrant = !self.draw_started.insert(id); debug_assert!( !reentrant, @@ -481,12 +525,15 @@ impl UiRenderState { own_mask, layer, id, - textures: Vec::new(), - paints: Vec::new(), - primitives: Vec::new(), - recycle: recycle.into_iter().peekable(), - children: Vec::new(), - size_dependencies: Vec::new(), + textures, + recycle_textures, + paints, + recycle_paints, + primitives, + recycle, + recycle_at: 0, + children, + size_dependencies, size: None, reuse_child_sizes, rsc, @@ -534,9 +581,12 @@ impl UiRenderState { child_move_slot, own_mask, textures, + mut recycle_textures, paints, + mut recycle_paints, primitives, - recycle, + mut recycle, + recycle_at, children, size_dependencies, size: _, @@ -545,18 +595,31 @@ impl UiRenderState { id, } = painter; - for h in recycle { - self.free_primitive(&h); + for h in &recycle[recycle_at..] { + self.free_primitive(h); } + for c in &old_children { + if !children.contains(c) { + self.remove_rec(*c, rsc); + } + } + recycle.clear(); + recycle_textures.clear(); + recycle_paints.clear(); + old_children.clear(); let active = ActiveData { id, region, parent, textures, + spare_textures: recycle_textures, paints, + spare_paints: recycle_paints, primitives, + spare_primitives: recycle, children, + spare_children: old_children, size_dependencies, mask: inherited_mask, layer: inherited_layer, @@ -567,12 +630,6 @@ impl UiRenderState { move_applied: Vec2::ZERO, }; - for c in &old_children { - if !active.children.contains(c) { - self.remove_rec(*c, rsc); - } - } - rsc.on_draw(&active); self.active.insert(id, active); size @@ -713,8 +770,6 @@ impl UiRenderState { } } Self::remask_shape_users(&self.active, id, active.own_mask, &active.primitives, rsc); - active.textures.clear(); - rsc.ui_mut().textures.free(); if undraw { // A captured widget that goes away mid-gesture (LazySpan's // virtualisation retiring a row, a rebuild) must not leave @@ -1137,11 +1192,17 @@ impl UiRenderState { Retained { region: Some(active.region), children: active.children, + spare_children: active.spare_children, move_slot: Some(active.move_slot), child_move_slot: active.child_move_slot, own_mask: active.own_mask, + textures: active.textures, + spare_textures: active.spare_textures, primitives: active.primitives, + spare_primitives: active.spare_primitives, paints: active.paints, + spare_paints: active.spare_paints, + size_dependencies: active.size_dependencies, }, rsc, ); diff --git a/iris/core/src/util/dirty.rs b/iris/core/src/util/dirty.rs index 1beda01..beb074a 100644 --- a/iris/core/src/util/dirty.rs +++ b/iris/core/src/util/dirty.rs @@ -62,10 +62,24 @@ impl Dirty { } pub fn ranges(&self, len: usize, gap: usize) -> Vec> { + let mut ranges = Vec::new(); + self.for_each_range(len, gap, |range| ranges.push(range)); + ranges + } + + pub(crate) fn for_each_range( + &self, + len: usize, + gap: usize, + mut visit: impl FnMut(Range), + ) { if self.all { - return Vec::from_iter((len > 0).then_some(0..len)); + if len > 0 { + visit(0..len); + } + return; } - let mut ranges: Vec> = Vec::new(); + let mut pending: Option> = None; for (w, word) in self.words.iter().enumerate() { let mut bits = *word; while bits != 0 { @@ -75,14 +89,20 @@ impl Dirty { if start >= len { break; } - match ranges.last_mut() { + match pending.as_mut() { Some(last) if start - last.end <= gap => last.end = end, - _ => ranges.push(start..end), + _ => { + if let Some(range) = pending.replace(start..end) { + visit(range); + } + } } bits &= !(((1u128 << run) - 1) as u64) << (start - w * 64); } } - ranges + if let Some(range) = pending { + visit(range); + } } pub fn clear(&mut self) { diff --git a/iris/core/src/widget/data.rs b/iris/core/src/widget/data.rs index b0fe4dd..bdc6485 100644 --- a/iris/core/src/widget/data.rs +++ b/iris/core/src/widget/data.rs @@ -8,10 +8,18 @@ pub struct WidgetData { impl WidgetData { pub fn new(widget: W) -> Self { - let mut label = std::any::type_name::().to_string(); - if let (Some(first), Some(last)) = (label.find(":"), label.rfind(":")) { - label = label.split_at(first).0.to_string() + "::" + label.split_at(last + 1).1; - } + let name = std::any::type_name::(); + let label = match (name.find("::"), name.rfind("::")) { + (Some(first), Some(last)) => { + let suffix = &name[last + 2..]; + let mut label = String::with_capacity(first + 2 + suffix.len()); + label.push_str(&name[..first]); + label.push_str("::"); + label.push_str(suffix); + label + } + _ => name.to_owned(), + }; Self { widget: Box::new(widget), label, diff --git a/iris/src/android/view.rs b/iris/src/android/view.rs index 8686b0e..69208ff 100644 --- a/iris/src/android/view.rs +++ b/iris/src/android/view.rs @@ -379,7 +379,7 @@ impl IrisViewPeer { frame_diagnostics.paints_resized, frame_diagnostics.atlas_pages_grown_prev, frame_diagnostics.image_bind_group_creates_prev, - renderer.wgpu_errors.snapshot().len(), + renderer.wgpu_errors.len(), ); } let mut parts = renderer.draw(); @@ -410,13 +410,18 @@ impl IrisViewPeer { } let ui_state = self.state.android_state_mut(); - if let Some(tree_update) = ui_state.access.update( + let access_changed = ui_state.access.refresh( self.rsc.widgets(), &self.rsc.ui().render_state().get(), &self.rsc, - ) { + ); + if access_changed { let ui_state = self.state.android_state_mut(); - if let Some(events) = ui_state.access_adapter.update_if_active(|| tree_update) { + let access = &ui_state.access; + if let Some(events) = ui_state + .access_adapter + .update_if_active(|| access.tree_update()) + { ctx.push_dynamic_deferred_callback(move |env, view| { raise_if_enabled(env, view, events); }); diff --git a/iris/src/desktop/mod.rs b/iris/src/desktop/mod.rs index 953dfd3..d7faa06 100644 --- a/iris/src/desktop/mod.rs +++ b/iris/src/desktop/mod.rs @@ -269,9 +269,11 @@ impl AppState for DesktopApp { let render_state = rsc.ui.render_state(); let render_state = render_state.get(); crate::diagnostics::log_frame(&render_state, frame_start, parts, animating); - if let Some(tree_update) = ui_state.access.update(rsc.widgets(), &render_state, rsc) - { - ui_state.access_adapter.update_if_active(|| tree_update); + if ui_state.access.refresh(rsc.widgets(), &render_state, rsc) { + let access = &ui_state.access; + ui_state + .access_adapter + .update_if_active(|| access.tree_update()); } } WindowEvent::Resized(size) => { diff --git a/iris/src/rsc/sense.rs b/iris/src/rsc/sense.rs index 3a80307..fae2321 100644 --- a/iris/src/rsc/sense.rs +++ b/iris/src/rsc/sense.rs @@ -328,18 +328,17 @@ impl SensorUi for UiRenderState { // Platform cancellation reaches every tracker and produces no other sense. if cursor.cancelled { let captured = pointer.captured.take(); - let mut told: Vec = captured.into_iter().collect(); - for id in pointer.pressed.drain(..) { - if Some(id) != captured { - told.push(id); - } - } requests.release(); pointer.press_origin = None; pointer.drag_axis = None; - for id in told { + if let Some(id) = captured { deliver_cancel(self, rsc, state, id, &cursor, window_size, &requests); } + for id in pointer.pressed.drain(..) { + if Some(id) != captured { + deliver_cancel(self, rsc, state, id, &cursor, window_size, &requests); + } + } rsc.events_mut().get_type::().global = pointer; return; } @@ -442,15 +441,16 @@ impl SensorUi for UiRenderState { pointer.captured = requests.holder(); match pointer.captured { Some(winner) => { - let losers: Vec = pointer - .pressed - .iter() - .copied() - .filter(|&id| id != winner) - .collect(); - pointer.pressed.retain(|&id| id == winner); - for loser in losers { - deliver_cancel(self, rsc, state, loser, &cursor, window_size, &requests); + let mut winner_was_pressed = false; + for id in pointer.pressed.drain(..) { + if id == winner { + winner_was_pressed = true; + } else { + deliver_cancel(self, rsc, state, id, &cursor, window_size, &requests); + } + } + if winner_was_pressed { + pointer.pressed.push(winner); } } None if !button_down => pointer.pressed.clear(), diff --git a/iris/src/widget/layout/span.rs b/iris/src/widget/layout/span.rs index 96a1ffc..d44da86 100644 --- a/iris/src/widget/layout/span.rs +++ b/iris/src/widget/layout/span.rs @@ -17,17 +17,13 @@ impl Widget for Span { let axis = self.dir.axis; let gap = self.gap.apply_rest(painter.density()).abs; - let mut lens: Vec> = self - .children - .iter() - .map(|child| painter.known_len(child, axis)) - .collect(); - let mut drawn = vec![false; self.children.len()]; - + let gap_total = gap * self.children.len().saturating_sub(1) as f32; + let mut measured = Vec::with_capacity(self.children.len()); + let mut total = LayoutLen::abs(gap_total); let mut cursor = UiScalar::rel_min(); - for (i, child) in self.children.iter().enumerate() { - let len = match lens[i] { - Some(len) => len, + for child in &self.children { + let (len, drawn) = match painter.known_len(child, axis) { + Some(len) => (len, false), None => { let mut slot = UiSpan::new(cursor, UiScalar::rel_max()); if self.dir.sign == Sign::Neg { @@ -35,47 +31,25 @@ impl Widget for Span { } let region = UiRegion::from_axis(axis, slot, UiSpan::FULL); let len = painter.widget_within(child, region).size().axis(axis); - lens[i] = Some(len); - drawn[i] = true; - len + (len, true) } }; + measured.push((len, drawn)); + total += len; cursor.abs += len.abs + gap; cursor.rel += len.rel; } - let lens: Vec = lens.into_iter().map(Option::unwrap).collect(); - - let gap_total = gap * self.children.len().saturating_sub(1) as f32; - let total = lens.iter().fold(LayoutLen::abs(gap_total), |s, &l| s + l); - let mut start = UiScalar::rel_min(); let mut ortho_len = LayoutLen::ZERO; let mut ortho_mixed = false; - let mut placed = Vec::with_capacity(self.children.len()); - for (i, (child, &len)) in self.children.iter().zip(&lens).enumerate() { - let mut span = UiSpan::FULL; - span.start = start; - if len.rest > 0.0 { - let offset = UiScalar::new(total.rel, total.abs); - let rel_end = UiScalar::rel(len.rest / total.rest); - let end = (UiScalar::rel_max() + start) - offset; - start = rel_end.within(&start.to(end)); - } - start.abs += len.abs; - start.rel += len.rel; - span.end = start; - let mut child_region = UiRegion::from_axis(axis, span, UiSpan::FULL); - if self.dir.sign == Sign::Neg { - child_region.flip(axis); - } - let used = if drawn[i] { + for (child, &(len, drawn)) in self.children.iter().zip(&measured) { + let child_region = child_region(axis, self.dir.sign, total, gap, &mut start, len); + let used = if drawn { painter.place(child, child_region).size() } else { painter.widget_within(child, child_region).size() }; - placed.push(child_region); - start.abs += gap; let ortho = used.axis(!axis); if ortho.rel > 0.0 || ortho.rest > 0.0 { @@ -90,7 +64,9 @@ impl Widget for Span { let ortho = ortho_len .apply_rest(painter.density()) .align(AxisAlign::Neg); - for (child, mut region) in self.children.iter().zip(placed) { + let mut start = UiScalar::rel_min(); + for (child, &(len, _)) in self.children.iter().zip(&measured) { + let mut region = child_region(axis, self.dir.sign, total, gap, &mut start, len); *region.axis_mut(!axis) = ortho; painter.place(child, region); } @@ -106,6 +82,33 @@ impl Widget for Span { } } +fn child_region( + axis: Axis, + sign: Sign, + total: LayoutLen, + gap: f32, + start: &mut UiScalar, + len: LayoutLen, +) -> UiRegion { + let mut span = UiSpan::FULL; + span.start = *start; + if len.rest > 0.0 { + let offset = UiScalar::new(total.rel, total.abs); + let rel_end = UiScalar::rel(len.rest / total.rest); + let end = (UiScalar::rel_max() + *start) - offset; + *start = rel_end.within(&start.to(end)); + } + start.abs += len.abs; + start.rel += len.rel; + span.end = *start; + start.abs += gap; + let mut region = UiRegion::from_axis(axis, span, UiSpan::FULL); + if sign == Sign::Neg { + region.flip(axis); + } + region +} + impl Span { pub fn empty(dir: Dir) -> Self { Self { diff --git a/iris/src/widget/layout/stack.rs b/iris/src/widget/layout/stack.rs index 807fdd2..6d5407f 100644 --- a/iris/src/widget/layout/stack.rs +++ b/iris/src/widget/layout/stack.rs @@ -20,27 +20,32 @@ impl Widget for Stack { }), }; let region = known.map(|size| size.to_uivec2(density).align(RegionAlign::TOP_LEFT)); - let mut used = Vec::with_capacity(self.children.len()); - let mut iter = self.children.iter(); - if let Some(child) = iter.next() { - painter.child_layer(); - used.push(match region { + let mut used = known + .is_none() + .then(|| Vec::with_capacity(self.children.len())); + let mut selected = known.unwrap_or_default(); + for (i, child) in self.children.iter().enumerate() { + if i == 0 { + painter.child_layer(); + } else { + painter.next_layer(); + } + let child_size = match region { Some(region) => painter.widget_within(child, region).size(), None => painter.widget(child).size(), - }); - } - for child in iter { - painter.next_layer(); - used.push(match region { - Some(region) => painter.widget_within(child, region).size(), - None => painter.widget(child).size(), - }); + }; + if matches!(self.size, StackSize::Child(target) if target == i) { + selected = child_size; + } + if let Some(used) = &mut used { + used.push(child_size); + } } let size = match self.size { StackSize::Default => Size::default(), - StackSize::Child(i) => used.get(i).copied().unwrap_or_default(), + StackSize::Child(_) => selected, }; - if known.is_none() { + if let Some(used) = used { let final_region = size.to_uivec2(density).align(RegionAlign::TOP_LEFT); for (child, child_size) in self.children.iter().zip(used) { let child_region = child_size diff --git a/iris/src/widget/text/mod.rs b/iris/src/widget/text/mod.rs index 5cd9ad4..745cc88 100644 --- a/iris/src/widget/text/mod.rs +++ b/iris/src/widget/text/mod.rs @@ -188,16 +188,18 @@ impl TextView { self.overflow = OverflowState::default(); let available = painter.px_size().x.max(0.0); let excess = (tex.size.x - available).max(0.0); - let attrs = self.text.attrs().clone(); - if excess <= 0.0 || matches!(attrs.overflow, TextOverflow::Visible | TextOverflow::Wrap) { + let (overflow, overflow_position) = { + let attrs = self.text.attrs(); + (attrs.overflow, attrs.overflow_position) + }; + if excess <= 0.0 || matches!(overflow, TextOverflow::Visible | TextOverflow::Wrap) { return None; } let marker = - (attrs.overflow == TextOverflow::Ellipsis).then(|| painter.render_ellipsis(&self.text)); + (overflow == TextOverflow::Ellipsis).then(|| painter.render_ellipsis(&self.text)); - let mut pan = attrs - .overflow_position + let mut pan = overflow_position .resolve(painter.density()) .to_abs(excess) .clamp(0.0, excess); @@ -229,10 +231,7 @@ impl TextView { } pan = pan.clamp(0.0, excess); } - let old_pan = attrs - .overflow_position - .resolve(painter.density()) - .to_abs(excess); + let old_pan = overflow_position.resolve(painter.density()).to_abs(excess); if pan != old_pan { self.text.set_overflow_position(Len::abs(pan)); } @@ -242,7 +241,7 @@ impl TextView { self.overflow.viewport = vec2(available, tex.size.y); self.overflow.pan = pan; self.overflow.content_end = available; - if attrs.overflow != TextOverflow::Ellipsis { + if overflow != TextOverflow::Ellipsis { return None; } @@ -287,14 +286,14 @@ impl TextView { return painter.widget(hint).size(); } let marker = self.prepare_overflow(painter, &tex, follow_caret); - let attrs = self.text.attrs().clone(); + let align = self.text.attrs().align; if !self.overflow.active { - let within = tex.size.align(attrs.align).within(&painter.region()); + let within = tex.size.align(align).within(&painter.region()); painter.glyphs(&tex, within); return Size::abs(tex.size); } - let viewport = self.overflow.viewport.align(attrs.align); + let viewport = self.overflow.viewport.align(align); let marker_highlight = painter.paint(&PaintId::SKY); if self.overflow.suppress_content && self diff --git a/iris/src/widget/text/selection.rs b/iris/src/widget/text/selection.rs index c22664e..fc5227b 100644 --- a/iris/src/widget/text/selection.rs +++ b/iris/src/widget/text/selection.rs @@ -312,15 +312,16 @@ impl SelectionController { } fn deselect(&mut self, rsc: &mut impl UiRsc) { - let mut ids = std::mem::take(&mut self.selected); - if let Some((anchor, _)) = self.anchor.take() - && !ids.contains(&anchor) - { - ids.push(anchor); - } - for id in ids { + let anchor = self.anchor.take().map(|(id, _)| id); + let anchor_was_selected = anchor.is_some_and(|anchor| self.selected.contains(&anchor)); + for id in self.selected.drain(..) { Self::with_text(rsc, id, |text| text.deselect()); } + if let Some(anchor) = anchor + && !anchor_was_selected + { + Self::with_text(rsc, anchor, |text| text.deselect()); + } } fn begin(&mut self, rsc: &mut impl UiRsc, id: WidgetId, pos: Vec2, size: Vec2) { @@ -352,8 +353,7 @@ impl SelectionController { (focus_at, anchor_at) }; - let old = std::mem::take(&mut self.selected); - for old_id in old { + for &old_id in &self.selected { if !self.order[lo..=hi].contains(&old_id) { Self::with_text(rsc, old_id, |text| text.deselect()); } @@ -375,7 +375,8 @@ impl SelectionController { text.select_between(start, end); }); } - self.selected = self.order[lo..=hi].to_vec(); + self.selected.clear(); + self.selected.extend_from_slice(&self.order[lo..=hi]); } pub fn drag( @@ -486,12 +487,11 @@ impl Controller for SelectionController { .map(CommandResult::Copy) .unwrap_or(CommandResult::Unused), Command::SelectAll => { - let order = self.order.clone(); self.deselect(rsc); - for &id in &order { + for &id in &self.order { Self::with_text(rsc, id, |text| text.select_all()); } - self.selected = order; + self.selected.extend_from_slice(&self.order); CommandResult::Used } Command::Escape => { diff --git a/iris/tests/allocation.rs b/iris/tests/allocation.rs new file mode 100644 index 0000000..1d4e3c7 --- /dev/null +++ b/iris/tests/allocation.rs @@ -0,0 +1,126 @@ +use iris::prelude::*; +use std::{ + alloc::{GlobalAlloc, Layout, System}, + cell::Cell, + time::{Duration, Instant}, +}; + +struct CountingAllocator; + +thread_local! { + static TRACKING: Cell = const { Cell::new(false) }; + static ALLOCATIONS: Cell = const { Cell::new(0) }; +} + +fn note_allocation() { + TRACKING.with(|tracking| { + if tracking.get() { + ALLOCATIONS.with(|allocations| allocations.set(allocations.get() + 1)); + } + }); +} + +unsafe impl GlobalAlloc for CountingAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + let ptr = unsafe { System.alloc(layout) }; + note_allocation(); + ptr + } + + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + let ptr = unsafe { System.alloc_zeroed(layout) }; + note_allocation(); + ptr + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) } + } + + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + let ptr = unsafe { System.realloc(ptr, layout, new_size) }; + note_allocation(); + ptr + } +} + +#[global_allocator] +static ALLOCATOR: CountingAllocator = CountingAllocator; + +fn allocations_during(run: impl FnOnce()) -> usize { + ALLOCATIONS.with(|allocations| allocations.set(0)); + TRACKING.with(|tracking| tracking.set(true)); + run(); + TRACKING.with(|tracking| tracking.set(false)); + ALLOCATIONS.with(Cell::get) +} + +struct TestRsc { + ui: Ui, +} + +impl UiRsc for TestRsc { + fn ui(&self) -> &Ui { + &self.ui + } + + fn ui_mut(&mut self) -> &mut Ui { + &mut self.ui + } +} + +struct FrameRequester; + +impl Widget for FrameRequester { + fn draw(&mut self, painter: &mut Painter) { + let paint = painter.paint(&PaintId::WHITE); + painter.primitive(RectPrimitive::color(paint)); + painter.request_next_frame(); + painter.set_size(Size::REST); + } +} + +#[test] +fn a_warmed_self_redraw_allocates_nothing() { + let mut rsc = TestRsc { ui: Ui::default() }; + let root = rsc.ui.widgets.add_strong(FrameRequester).any(); + let mut render = UiRenderState::new(); + render.resize((800.0, 600.0)); + let start = Instant::now(); + + for frame in 0..3 { + assert!(render.update_at(&root, &mut rsc, start + Duration::from_millis(frame * 16),)); + } + + let allocations = allocations_during(|| { + assert!(render.update_at(&root, &mut rsc, start + Duration::from_millis(48),)); + }); + assert_eq!(allocations, 0); +} + +#[test] +fn refreshing_retained_accessibility_allocates_nothing() { + let mut rsc = TestRsc { ui: Ui::default() }; + let leaf = rsc.ui.widgets.add_strong(Rect::new(PaintId::WHITE)); + rsc.ui.widgets.set_label(&leaf, "named".to_owned()); + let offset = rsc.ui.widgets.add_strong(Offset { + inner: leaf.any(), + amt: UiVec2::ZERO, + }); + let offset_id = offset.weak(); + let root = offset.any(); + let mut render = UiRenderState::new(); + render.resize((800.0, 600.0)); + render.update(&root, &mut rsc); + let mut access = AccessTree::new(); + assert!(access.refresh(rsc.widgets(), &render, &rsc)); + + rsc.ui.widgets.get_mut(&offset_id).unwrap().amt = UiVec2::abs(Vec2::new(20.0, 0.0)); + render.update(&root, &mut rsc); + + let moved = allocations_during(|| assert!(access.refresh(rsc.widgets(), &render, &rsc))); + assert_eq!(moved, 0); + + let unchanged = allocations_during(|| assert!(!access.refresh(rsc.widgets(), &render, &rsc))); + assert_eq!(unchanged, 0); +}