From 90dffce51456ba2b22581ba25aaef0400667995a Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sat, 12 Sep 2026 18:10:55 -0400 Subject: [PATCH] iris: add replaceable glyph atlas buckets --- app/src/ui/mod.rs | 37 ++++++ docs/LAYOUT.md | 7 ++ docs/PLAN.md | 13 +- docs/TEXTURES.md | 6 +- iris/core/src/primitive/text.rs | 196 ++++++++++++++++++++++++++--- iris/core/src/primitive/texture.rs | 54 ++++---- iris/core/src/render/atlas.rs | 86 ++++++++----- iris/core/src/render/shader.wgsl | 4 +- iris/core/src/render/texture.rs | 16 +-- iris/core/src/ui/mod.rs | 44 ++++++- iris/core/src/ui/render_state.rs | 2 +- iris/src/layout_tests.rs | 27 ++++ 12 files changed, 401 insertions(+), 91 deletions(-) diff --git a/app/src/ui/mod.rs b/app/src/ui/mod.rs index 4629574..caceac4 100644 --- a/app/src/ui/mod.rs +++ b/app/src/ui/mod.rs @@ -550,6 +550,43 @@ mod apply_tests { assert!(!rsc.widgets().has_updates()); } + #[test] + fn replacing_a_font_only_clears_its_glyph_bucket() { + let mut rsc = TestRsc { + ui: Ui::default(), + events: EventManager::default(), + }; + rsc.ui.resize((800.0, 600.0)); + rsc.ui + .register_font_in("first-icons", "first", ICON_FONT) + .unwrap(); + rsc.ui + .register_font_in("second-icons", "second", ICON_FONT) + .unwrap(); + let first = wtext(icon::OPEN) + .family("first-icons") + .add_strong(&mut rsc) + .any(); + let second = wtext(icon::OPEN) + .family("second-icons") + .add_strong(&mut rsc) + .any(); + + rsc.draw(&first); + rsc.draw(&second); + let glyphs_before = rsc.ui.text.borrow().atlas.glyph_count(); + let pages_before = rsc.ui.text.borrow().atlas.page_count(); + assert_eq!((glyphs_before, pages_before), (2, 2)); + + rsc.ui.replace_font("first-icons", ICON_FONT).unwrap(); + + assert_eq!(rsc.ui.text.borrow().atlas.glyph_count(), 1); + assert_eq!(rsc.ui.text.borrow().atlas.page_count(), 1); + rsc.draw(&first); + assert_eq!(rsc.ui.text.borrow().atlas.glyph_count(), 2); + assert_eq!(rsc.ui.text.borrow().atlas.page_count(), 2); + } + fn cost_of_one_delta(paragraphs: usize) -> (u64, u64) { let mut rsc = TestRsc { ui: Ui::default(), diff --git a/docs/LAYOUT.md b/docs/LAYOUT.md index 02edc88..f50ae2f 100644 --- a/docs/LAYOUT.md +++ b/docs/LAYOUT.md @@ -286,6 +286,13 @@ accepted duplication rather than threading a `Painter` into every input handler for one field, the same tradeoff `AndroidRenderer::content_scale` already makes for the Diagnostics page. +Glyph masks are cached at four horizontal quarter-pixel phases. Their final +quad edges snap to physical pixels after retained move offsets are applied; +the CPU mask geometry uses the same calculation as the shader. In particular, +a fractional scroll offset therefore moves text and other primitives in whole +physical-pixel steps instead of resampling the atlas vertically with the +nearest sampler. + **What did not change**: `rel`/`rest` are unaffected (already resolution-independent, a fraction of the parent). `Span::gap` and `Padding`'s four sides moved from bare `f32` to `Len` so `dp(...)` works diff --git a/docs/PLAN.md b/docs/PLAN.md index f08aab4..eac1880 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -823,9 +823,16 @@ a text widget's `TextHandle` wraps the UI-local `RscHandle` into it. The same generic arena owns texture and managed-paint lifetimes. Its `StrongRscId` and `WeakRscId` are sendable IDs rather than data pointers; clone and drop events cross one arena-owned standard channel, and reference counts remain in the -arena instead of allocating one atomic counter per resource. Registering -another family invalidates the live text entries and dirties their active owner -widgets, so registration is also valid after the first shape. ai-app owns the +arena instead of allocating one atomic counter per resource. The ordinary +`register_font(family, data)` call needs no policy argument and uses the shared +default glyph-atlas bucket; `register_font_in` opts a reloadable or sparse font +into a named bucket. `replace_font` unregisters the old faces, releases only +that bucket's pages, and invalidates the live text entries so their active owner +widgets reshape automatically. Freed array layers are reused without moving +live pages. Glyph rasterisation retains four horizontal quarter-pixel phases, +while final primitive edges (including retained scroll moves) snap to physical +pixels; this favours low-DPI text stability and keeps vertical scrolling from +resampling atlas masks. ai-app owns the `ai-app-icons` family, its Nerd Fonts subset, its codepoints, its license and the script that rebuilds it; the CSS generic names `sans-serif` and `monospace` continue to resolve through the platform. diff --git a/docs/TEXTURES.md b/docs/TEXTURES.md index bc706c5..41ea999 100644 --- a/docs/TEXTURES.md +++ b/docs/TEXTURES.md @@ -18,7 +18,11 @@ Glyph atlas pages are layers of one `texture_2d_array`. `GpuTextures` doubles the array when it runs out of layers, copies the old layers on the GPU, and rebuilds every bind group that referenced the old view. Page numbers are assigned synchronously by `Textures::add_page` because glyph insertion needs -the layer before the renderer processes queued texture updates. +the layer before the renderer processes queued texture updates. Atlas maps and +pages are grouped into named buckets: ordinary font registration uses one +shared default bucket, while callers can isolate fonts they expect to replace. +Replacing a font drops its bucket. The resource slots and array layers from +those pages are reused without compacting or changing any live page number. Standalone images each own a bind group and are not placed in the glyph array. Each render layer keeps ordinary rect/glyph instances separately from diff --git a/iris/core/src/primitive/text.rs b/iris/core/src/primitive/text.rs index ceeab5c..11e4353 100644 --- a/iris/core/src/primitive/text.rs +++ b/iris/core/src/primitive/text.rs @@ -5,7 +5,7 @@ use crate::{ use parley::{ Alignment, AlignmentOptions, FontContext, FontFamily, FontStyle, FontWeight, GenericFamily, Layout, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty, - fontique::{Blob, FontInfoOverride}, + fontique::{Blob, Collection, CollectionOptions, FamilyId, FontInfoOverride, FontWidth}, }; use std::{ cell::{Ref, RefCell, RefMut}, @@ -35,6 +35,7 @@ pub struct FontDiagnostics { #[derive(Clone, Debug, PartialEq, Eq)] pub enum FontRegistrationError { AlreadyRegistered(String), + NotRegistered(String), InvalidFont(String), } @@ -44,6 +45,9 @@ impl fmt::Display for FontRegistrationError { Self::AlreadyRegistered(family) => { write!(f, "font data is already registered for {family:?}") } + Self::NotRegistered(family) => { + write!(f, "no font data is registered for {family:?}") + } Self::InvalidFont(family) => { write!( f, @@ -56,6 +60,23 @@ impl fmt::Display for FontRegistrationError { impl std::error::Error for FontRegistrationError {} +/// The bucket used by [`crate::Ui::register_font`] when none is specified. +pub const DEFAULT_GLYPH_BUCKET: &str = "default"; + +struct RegisteredFace { + family: FamilyId, + width: FontWidth, + style: FontStyle, + weight: FontWeight, +} + +struct RegisteredFont { + private_name: String, + bucket: u64, + font_id: u64, + faces: Vec, +} + pub struct TextData { pub font_cx: FontContext, pub layout_cx: LayoutContext, @@ -71,8 +92,11 @@ pub struct TextData { /// truth would mean carrying a `Painter` (or output size) into every /// input handler for the sake of one field. pub density: f32, - registered_families: HashMap, + registered_families: HashMap, + glyph_buckets: HashMap, + font_buckets: HashMap, next_registered_family: u64, + next_glyph_bucket: u64, } impl Default for TextData { @@ -86,7 +110,10 @@ impl Default for TextData { atlas: GlyphAtlas::default(), density: 1.0, registered_families: HashMap::new(), + glyph_buckets: HashMap::new(), + font_buckets: HashMap::new(), next_registered_family: 0, + next_glyph_bucket: 1, } } } @@ -168,29 +195,145 @@ impl TextData { &mut self, family: impl AsRef, data: impl AsRef<[u8]> + Send + Sync + 'static, + ) -> Result<(), FontRegistrationError> { + self.register_font_in(family, DEFAULT_GLYPH_BUCKET, data) + } + + pub(crate) fn register_font_in( + &mut self, + family: impl AsRef, + bucket: impl AsRef, + data: impl AsRef<[u8]> + Send + Sync + 'static, ) -> Result<(), FontRegistrationError> { let family = family.as_ref().to_owned(); if self.registered_families.contains_key(&family) { return Err(FontRegistrationError::AlreadyRegistered(family)); } - // Parley selects fonts by family rather than by a face handle. Give - // application data a private family name so selecting it cannot find - // a system font that happens to carry the same embedded metadata. let private_name = format!("__iris_registered_font_{}__", self.next_registered_family); + let blob = Blob::new(Arc::new(data)); + Self::validate_font(&family, blob.clone())?; + let bucket = self.glyph_bucket(bucket.as_ref()); + let registered = self.add_font(private_name, bucket, blob); + self.next_registered_family += 1; + self.font_buckets.insert(registered.font_id, bucket); + self.registered_families.insert(family, registered); + Ok(()) + } + + pub(crate) fn replace_font( + &mut self, + family: impl AsRef, + data: impl AsRef<[u8]> + Send + Sync + 'static, + ) -> Result<(), FontRegistrationError> { + let family = family.as_ref(); + let Some(bucket) = self.registered_families.get(family).map(|font| font.bucket) else { + return Err(FontRegistrationError::NotRegistered(family.to_owned())); + }; + self.replace_font_with(family, bucket, data) + } + + pub(crate) fn replace_font_in( + &mut self, + family: impl AsRef, + bucket: impl AsRef, + data: impl AsRef<[u8]> + Send + Sync + 'static, + ) -> Result<(), FontRegistrationError> { + let family = family.as_ref(); + if !self.registered_families.contains_key(family) { + return Err(FontRegistrationError::NotRegistered(family.to_owned())); + } + let bucket = self.glyph_bucket(bucket.as_ref()); + self.replace_font_with(family, bucket, data) + } + + fn replace_font_with( + &mut self, + family: &str, + bucket: u64, + data: impl AsRef<[u8]> + Send + Sync + 'static, + ) -> Result<(), FontRegistrationError> { + let blob = Blob::new(Arc::new(data)); + Self::validate_font(family, blob.clone())?; + let old = self.registered_families.remove(family).unwrap(); + for face in old.faces { + self.font_cx.collection.unregister_font( + face.family, + face.width, + face.style, + face.weight, + ); + } + self.font_buckets.remove(&old.font_id); + self.atlas.clear_bucket(old.bucket); + + let registered = self.add_font(old.private_name, bucket, blob); + self.font_buckets.insert(registered.font_id, bucket); + self.registered_families + .insert(family.to_owned(), registered); + Ok(()) + } + + fn validate_font(family: &str, blob: Blob) -> Result<(), FontRegistrationError> { + let mut collection = Collection::new(CollectionOptions { + shared: false, + system_fonts: false, + }); + let fonts = collection.register_fonts(blob, None); + if fonts.is_empty() { + Err(FontRegistrationError::InvalidFont(family.to_owned())) + } else { + Ok(()) + } + } + + fn add_font(&mut self, private_name: String, bucket: u64, blob: Blob) -> RegisteredFont { + let font_id = blob.id(); let fonts = self.font_cx.collection.register_fonts( - Blob::new(Arc::new(data)), + blob, Some(FontInfoOverride { family_name: Some(&private_name), ..Default::default() }), ); - if fonts.is_empty() { - return Err(FontRegistrationError::InvalidFont(family)); + debug_assert!(!fonts.is_empty(), "validated font failed its second scan"); + let faces = fonts + .into_iter() + .flat_map(|(family, fonts)| { + fonts.into_iter().map(move |font| RegisteredFace { + family, + width: font.width(), + style: font.style(), + weight: font.weight(), + }) + }) + .collect(); + RegisteredFont { + private_name, + bucket, + font_id, + faces, } - self.next_registered_family += 1; - self.registered_families.insert(family, private_name); - Ok(()) + } + + fn glyph_bucket(&mut self, name: &str) -> u64 { + if name == DEFAULT_GLYPH_BUCKET { + return crate::render::DEFAULT_GLYPH_BUCKET_ID; + } + if let Some(bucket) = self.glyph_buckets.get(name) { + return *bucket; + } + let bucket = self.next_glyph_bucket; + self.next_glyph_bucket += 1; + self.glyph_buckets.insert(name.to_owned(), bucket); + bucket + } + + fn bucket_for_font(&self, font: u64) -> u64 { + self.font_buckets + .get(&font) + .copied() + .unwrap_or(crate::render::DEFAULT_GLYPH_BUCKET_ID) } pub(crate) fn is_font_registered(&self, family: impl AsRef) -> bool { @@ -201,8 +344,8 @@ impl TextData { /// layout builder holds `&mut self` -- a `String` per shaped registered /// run, paid only when the layout is rebuilt. pub fn resolve_family(&self, family: &str) -> String { - if let Some(name) = self.registered_families.get(family) { - return name.clone(); + if let Some(font) = self.registered_families.get(family) { + return font.private_name.clone(); } family.to_owned() } @@ -304,9 +447,10 @@ impl TextData { }; let coords_hash = hash_coords(coords); let font_id = font.data.id(); + let bucket = self.bucket_for_font(font_id); for glyph in run.positioned_glyphs() { - let subpixel = ((glyph.x.fract() * 4.0).round() as i32).rem_euclid(4) as u8; + let (x, subpixel) = horizontal_phase(glyph.x); let key = GlyphKey { font: font_id, glyph: glyph.id, @@ -314,7 +458,7 @@ impl TextData { subpixel, coords: coords_hash, }; - let entry = match self.atlas.get(&key) { + let entry = match self.atlas.get(bucket, &key) { Some(entry) => entry, None => { let mut scaler = self @@ -333,9 +477,9 @@ impl TextData { .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), + Some(image) => self.atlas.insert(bucket, key, &image, textures), None => { - self.atlas.insert_empty(key); + self.atlas.insert_empty(bucket, key); None } } @@ -345,7 +489,7 @@ impl TextData { placed.push(PlacedGlyph { entry, offset: Vec2::new( - glyph.x.floor() + entry.left as f32, + x + entry.left as f32, glyph.y.floor() - entry.top as f32, ), paint: run_color.slot(), @@ -376,6 +520,11 @@ impl TextData { } } +fn horizontal_phase(x: f32) -> (f32, u8) { + let quarters = (x * 4.0).round() as i32; + (quarters.div_euclid(4) as f32, quarters.rem_euclid(4) as u8) +} + pub const SANS_SERIF: &str = "sans-serif"; pub const SERIF: &str = "serif"; pub const MONOSPACE: &str = "monospace"; @@ -574,6 +723,10 @@ impl TextBuffer { } fn invalidate(&mut self) { + // A layout owns the font blobs it was shaped with. Drop it now so + // replacing a registered font does not retain the old bytes until an + // off-screen text resource happens to be shaped again. + self.layout = Layout::new(); self.shaped = None; } } @@ -849,6 +1002,13 @@ impl TextHandle { mod tests { use super::*; + #[test] + fn a_horizontal_phase_carries_across_pixel_boundaries() { + assert_eq!(horizontal_phase(10.20), (10.0, 1)); + assert_eq!(horizontal_phase(10.90), (11.0, 0)); + assert_eq!(horizontal_phase(-0.20), (-1.0, 3)); + } + #[test] fn invalid_font_data_is_reported() { let mut data = TextData::default(); diff --git a/iris/core/src/primitive/texture.rs b/iris/core/src/primitive/texture.rs index d94e0e1..c4943db 100644 --- a/iris/core/src/primitive/texture.rs +++ b/iris/core/src/primitive/texture.rs @@ -46,8 +46,10 @@ pub struct Textures { /// drawing it and its slot is never recycled underneath one. shared: HashMap, /// Next layer to hand out to an atlas page. Page layers and resource slots - /// are never reused, even if an explicit atlas clear drops their handles. + /// are separate identities: released page layers are reused without moving + /// any still-live page. next_page_layer: u32, + free_page_layers: Vec, updates: Vec, } @@ -87,6 +89,7 @@ impl Textures { kinds: Vec::new(), shared: HashMap::new(), next_page_layer: 0, + free_page_layers: Vec::new(), updates: Vec::new(), } } @@ -95,16 +98,20 @@ impl Textures { let image = image.into(); let size = image.dimensions().into(); let kind = TextureKind::Image; - self.push(kind, size, image, true) + self.push(kind, size, image) } pub fn add_page(&mut self, image: impl Into) -> TextureHandle { + self.free(); let image = image.into(); let size = image.dimensions().into(); - let layer = self.next_page_layer; - self.next_page_layer += 1; + let layer = self.free_page_layers.pop().unwrap_or_else(|| { + let layer = self.next_page_layer; + self.next_page_layer += 1; + layer + }); let kind = TextureKind::Page { layer }; - self.push(kind, size, image, false) + self.push(kind, size, image) } pub fn handle(&self, id: StrongRscId) -> TextureHandle { @@ -119,22 +126,10 @@ impl Textures { Some(self.handle(id)) } - fn push( - &mut self, - kind: TextureKind, - size: Vec2, - image: DynamicImage, - recycle: bool, - ) -> TextureHandle { + fn push(&mut self, kind: TextureKind, size: Vec2, image: DynamicImage) -> TextureHandle { self.free(); let old_capacity = self.resources.borrow().capacity(); - let id = if recycle { - self.resources.borrow_mut().add(TextureRsc { kind, size }) - } else { - self.resources - .borrow_mut() - .add_unrecycled(TextureRsc { kind, size }) - }; + let id = self.resources.borrow_mut().add(TextureRsc { kind, size }); let i = id.slot(); if (i as usize) < old_capacity { self.images[i as usize] = Some(image); @@ -201,10 +196,14 @@ impl Textures { pub fn free(&mut self) { let updates = &mut self.updates; let images = &mut self.images; - self.resources.borrow_mut().apply(|id, _| { + let free_page_layers = &mut self.free_page_layers; + self.resources.borrow_mut().apply(|id, resource| { let idx = id.slot(); images[idx as usize] = None; updates.push(Update::Free(idx)); + if let TextureKind::Page { layer } = resource.kind { + free_page_layers.push(layer); + } }); } @@ -321,16 +320,19 @@ mod tests { } #[test] - fn a_released_atlas_page_slot_is_not_reused_by_an_image() { + fn a_released_atlas_page_layer_is_reused_without_moving_live_pages() { let mut textures = Textures::new(); - let page = textures.add_page(image(4)); - let page_slot = page.rsc.id().slot(); - drop(page); + let first = textures.add_page(image(4)); + let second = textures.add_page(image(4)); + let first_layer = first.layer(); + let second_layer = second.layer(); + drop(first); textures.free(); - let plain = textures.add(image(4)); + let replacement = textures.add_page(image(4)); - assert_ne!(plain.image_index(), page_slot); + assert_eq!(replacement.layer(), first_layer); + assert_eq!(second.layer(), second_layer); } #[test] diff --git a/iris/core/src/render/atlas.rs b/iris/core/src/render/atlas.rs index 470947b..1368425 100644 --- a/iris/core/src/render/atlas.rs +++ b/iris/core/src/render/atlas.rs @@ -6,6 +6,7 @@ use image::RgbaImage; use swash::scale::image::{Content, Image}; pub(crate) const PAGE: u32 = 1024; +pub(crate) const DEFAULT_GLYPH_BUCKET_ID: u64 = 0; const PAD: u32 = 1; @@ -42,40 +43,47 @@ struct Page { } #[derive(Default)] -pub struct GlyphAtlas { +struct Bucket { pages: Vec, - generation: u64, entries: HashMap>, } +#[derive(Default)] +pub struct GlyphAtlas { + buckets: HashMap, + generation: u64, +} + impl GlyphAtlas { - pub fn get(&self, key: &GlyphKey) -> Option> { - self.entries.get(key).copied() + pub(crate) fn get(&self, bucket: u64, key: &GlyphKey) -> Option> { + self.buckets.get(&bucket)?.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, + bucket: u64, key: GlyphKey, image: &Image, textures: &mut Textures, ) -> Option { + let bucket = self.buckets.entry(bucket).or_default(); let w = image.placement.width; let h = image.placement.height; if w == 0 || h == 0 { - self.entries.insert(key, None); + bucket.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); + bucket.entries.insert(key, None); return None; } - let (page_idx, x, y) = self.allocate(w, h, textures); - let page = &self.pages[page_idx]; + let (page_idx, x, y) = bucket.allocate(w, h, textures); + let page = &bucket.pages[page_idx]; let img = textures.image_mut(&page.handle); let rgba = img.as_mut_rgba8().expect("atlas page is rgba8"); @@ -89,7 +97,7 @@ impl GlyphAtlas { }; textures.patch(&page.handle, rect); - let page = &self.pages[page_idx]; + let page = &bucket.pages[page_idx]; let scale = 1.0 / PAGE as f32; let entry = GlyphEntry { uv_min: [x as f32 * scale, y as f32 * scale], @@ -101,10 +109,46 @@ impl GlyphAtlas { is_color: matches!(image.content, Content::Color), layer: page.handle.layer(), }; - self.entries.insert(key, Some(entry)); + bucket.entries.insert(key, Some(entry)); Some(entry) } + pub(crate) fn insert_empty(&mut self, bucket: u64, key: GlyphKey) { + self.buckets + .entry(bucket) + .or_default() + .entries + .insert(key, None); + } + + pub(crate) fn clear_bucket(&mut self, bucket: u64) { + if self.buckets.remove(&bucket).is_some() { + self.generation += 1; + } + } + + pub fn generation(&self) -> u64 { + self.generation + } + + pub fn page_count(&self) -> usize { + self.buckets.values().map(|bucket| bucket.pages.len()).sum() + } + + pub fn glyph_count(&self) -> usize { + self.buckets + .values() + .map(|bucket| bucket.entries.len()) + .sum() + } + + pub fn clear(&mut self) { + self.buckets.clear(); + self.generation += 1; + } +} + +impl Bucket { fn allocate(&mut self, w: u32, h: u32, textures: &mut Textures) -> (usize, u32, u32) { let need_w = w + PAD; let need_h = h + PAD; @@ -130,28 +174,6 @@ impl GlyphAtlas { }); (self.pages.len() - 1, PAD, PAD) } - - pub fn insert_empty(&mut self, key: GlyphKey) { - self.entries.insert(key, None); - } - - 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() - } - - 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 { diff --git a/iris/core/src/render/shader.wgsl b/iris/core/src/render/shader.wgsl index 6d434f8..5c5de35 100644 --- a/iris/core/src/render/shader.wgsl +++ b/iris/core/src/render/shader.wgsl @@ -137,8 +137,8 @@ fn corners_of(inst: PrimitiveInstance) -> Corners { 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, + floor(top_left_rel * window.dim) + floor(top_left_abs + move_delta), + floor(bot_right_rel * window.dim) + floor(bot_right_abs + move_delta), ); } diff --git a/iris/core/src/render/texture.rs b/iris/core/src/render/texture.rs index e151cac..361c97c 100644 --- a/iris/core/src/render/texture.rs +++ b/iris/core/src/render/texture.rs @@ -12,8 +12,8 @@ const MIN_ARRAY_LAYERS: u32 = 2; enum Slot { 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`. + /// The array layer a live page occupies. Dropping the page empties this + /// resource slot; its array layer may then be assigned to a new page. Page(u32), } @@ -41,7 +41,9 @@ pub struct GpuTextures { array_texture: Texture, array_view: TextureView, array_capacity: u32, - page_count: u32, + /// One past the highest layer ever populated. Holes below this remain in + /// place when the array grows, while `Textures` can reuse their numbers. + layer_high_water: u32, sampler: Sampler, /// Bound in the image slot of the main draw's bind group, which has @@ -125,7 +127,7 @@ impl GpuTextures { rebuilt = true; } self.write_full_layer(layer, image); - self.page_count = self.page_count.max(layer + 1); + self.layer_high_water = self.layer_high_water.max(layer + 1); (Slot::Page(layer), rebuilt) } } @@ -205,7 +207,7 @@ impl GpuTextures { 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 { + if self.layer_high_water > 0 { let mut encoder = self .device .create_command_encoder(&CommandEncoderDescriptor { @@ -227,7 +229,7 @@ impl GpuTextures { Extent3d { width: PAGE, height: PAGE, - depth_or_array_layers: self.page_count, + depth_or_array_layers: self.layer_high_water, }, ); self.queue.submit(std::iter::once(encoder.finish())); @@ -368,7 +370,7 @@ impl GpuTextures { array_texture, array_view, array_capacity, - page_count: 0, + layer_high_water: 0, sampler, null_view, bind_group_creates: 0, diff --git a/iris/core/src/ui/mod.rs b/iris/core/src/ui/mod.rs index f008893..2c5395a 100644 --- a/iris/core/src/ui/mod.rs +++ b/iris/core/src/ui/mod.rs @@ -75,10 +75,52 @@ impl Ui { &mut self, family: impl AsRef, data: impl AsRef<[u8]> + Send + Sync + 'static, + ) -> Result<(), crate::FontRegistrationError> { + self.update_fonts(|text| text.register_font(family, data)) + } + + /// Register application-owned font data in a named glyph-atlas bucket. + /// Fonts registered without this method share the default bucket. + #[track_caller] + pub fn register_font_in( + &mut self, + family: impl AsRef, + bucket: impl AsRef, + data: impl AsRef<[u8]> + Send + Sync + 'static, + ) -> Result<(), crate::FontRegistrationError> { + self.update_fonts(|text| text.register_font_in(family, bucket, data)) + } + + /// Replace an application's registered font while retaining its atlas + /// bucket. Text is reshaped and the bucket's old glyph pages are released. + #[track_caller] + pub fn replace_font( + &mut self, + family: impl AsRef, + data: impl AsRef<[u8]> + Send + Sync + 'static, + ) -> Result<(), crate::FontRegistrationError> { + self.update_fonts(|text| text.replace_font(family, data)) + } + + /// Replace an application's registered font and assign the replacement to + /// a named glyph-atlas bucket. + #[track_caller] + pub fn replace_font_in( + &mut self, + family: impl AsRef, + bucket: impl AsRef, + data: impl AsRef<[u8]> + Send + Sync + 'static, + ) -> Result<(), crate::FontRegistrationError> { + self.update_fonts(|text| text.replace_font_in(family, bucket, data)) + } + + fn update_fonts( + &mut self, + update: impl FnOnce(&mut TextResources) -> Result<(), crate::FontRegistrationError>, ) -> Result<(), crate::FontRegistrationError> { let owners = { let mut text = self.data.text.borrow_mut(); - text.register_font(family, data)?; + update(&mut text)?; text.invalidate_all() }; let active: Vec = { diff --git a/iris/core/src/ui/render_state.rs b/iris/core/src/ui/render_state.rs index 928b21e..4f047b2 100644 --- a/iris/core/src/ui/render_state.rs +++ b/iris/core/src/ui/render_state.rs @@ -1006,7 +1006,7 @@ impl UiRenderState { let inst = self.primitives.instance(slot); let delta = self.resolve_move_chain(inst.move_idx, rsc); let size = self.output_size; - let corner = |c: UiVec2| (c.get_rel() * size).floor() + c.get_abs().floor() + delta; + let corner = |c: UiVec2| (c.get_rel() * size).floor() + (c.get_abs() + delta).floor(); PixelRegion { top_left: corner(inst.region.top_left()), bot_right: corner(inst.region.bot_right()), diff --git a/iris/src/layout_tests.rs b/iris/src/layout_tests.rs index b775fa7..3cdfae4 100644 --- a/iris/src/layout_tests.rs +++ b/iris/src/layout_tests.rs @@ -362,6 +362,33 @@ fn scrolling_moves_in_o1_without_a_redraw() { assert_eq!(moves, 1, "the scrolled subtree should move in one write"); } +#[test] +fn fractional_scrolling_keeps_primitive_edges_on_physical_pixels() { + let mut rsc = TestRsc { ui: Ui::default() }; + let (scroll, root, rects) = scrolled_rects(&mut rsc, 20); + let mut render = UiRenderState::new(); + render.resize((800.0, 600.0)); + + render.update(&root, &mut rsc); + rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(0.0); + render.update(&root, &mut rsc); + rsc.ui.widgets.get_mut(&scroll).unwrap().scroll(-37.25); + render.update(&root, &mut rsc); + + let slot = render + .first_primitive(rects[2].id()) + .expect("the scrolled rect has a primitive"); + let corners = render.primitive_corners(slot, &rsc); + for edge in [ + corners.top_left.x, + corners.top_left.y, + corners.bot_right.x, + corners.bot_right.y, + ] { + assert_eq!(edge.fract(), 0.0, "fractional rendered edge: {corners:?}"); + } +} + #[test] fn hit_testing_follows_a_scrolled_widget() { let mut rsc = TestRsc { ui: Ui::default() };