//! Unwired single-line scenario label experiment. No renderer or schema is changed. //! //! Iced 0.14.0 WGPU `geometry::Frame::fill_text` shapes positive uniform-scale //! canvas text at `size * zoom`; its `text::prepare` uses graphics text::Cache. //! This adapter uses that same allocator/font system/attributes, not a character //! width estimate. Canvas Text::max_width is wrapping, so it stays infinite. //! Experimental parity requires settled fonts and a fresh renderer/cache after //! font loading. Iced's WGPU text cache key omits FontSystem::Version, so merely //! remeasuring here cannot repair its cached glyph buffers after dynamic loading. //! A RenderSession is caller attestation of that lifecycle, not its enforcement. //! No fonts may be loaded between measurement, guarded access and actual draw. //! //! Integration seam: after lifecycle/activation endpoint alignment, a caller //! supplies a usable text width in world units and retains one cache per live //! labelled occurrence across Scene rebuilds. Paint, hit testing, navigation and extents //! consume the returned Label. Position/color do not invalidate measurement. //! This does NOT choose a self-call, reversed-arrow or obstacle corridor policy. //! It measures layout advances, not raster ink; overhang/clip/activation-border //! acceptance still requires the real WGPU evaluator. The old 32-grapheme/225px //! renderer is deliberately untouched for a later baseline comparison. use iced::advanced::{ graphics::text as graphics_text, text::{Alignment, LineHeight, Shaping}, }; use iced::{Color, Font, Pixels, Point, Rectangle, Size, widget::canvas::Text}; use std::{collections::VecDeque, ops::Range, sync::Arc}; use unicode_segmentation::UnicodeSegmentation; const MAX_SOURCE_BYTES: usize = 64 * 1024; const MAX_GRAPHEMES: usize = 4096; const MAX_ENTRIES: usize = 2; #[derive(Clone, Copy, Debug, PartialEq)] pub(super) struct Style { pub font: Font, pub size: Pixels, pub line_height: LineHeight, pub shaping: Shaping, } impl Default for Style { fn default() -> Self { let text = Text::default(); Self { font: text.font, size: Pixels(12.), line_height: text.line_height, shaping: text.shaping, } } } impl Style { fn scaled(self, zoom: f32) -> (f32, f32) { let size = Pixels(self.size.0 * zoom); let line_height = match self.line_height { LineHeight::Absolute(value) => LineHeight::Absolute(Pixels(value.0 * zoom)), LineHeight::Relative(value) => LineHeight::Relative(value), }; (size.0, line_height.to_absolute(size).0) } } /// Explicit experimental contract: the caller finishes loading fonts and starts /// a fresh renderer/cache BEFORE capturing this session, then keeps fonts fixed. /// This module cannot inspect or reset the actual renderer's text cache. #[derive(Clone, Debug)] pub(super) struct RenderSession { version: graphics_text::Version, identity: Arc<()>, } impl RenderSession { pub fn after_fonts_settled_and_renderer_reset() -> Result { let fonts = graphics_text::font_system() .read() .map_err(|_| Error::FontSystemUnavailable)?; Ok(Self { version: fonts.version(), identity: Arc::new(()), }) } } #[derive(Clone, Debug)] pub(super) struct Label { source: String, displayed: String, /// Original-label bytes represented before any added ellipsis. Includes /// collapsed whitespace, but `retains_any` never counts whitespace as text. retained_source: Range, /// World-space layout advances, at origin; not glyph-ink containment. bounds: Rectangle, style: Style, measured_session: RenderSession, measured_zoom: f32, } impl Label { fn check(&self, session: &RenderSession, zoom: f32) -> Result<(), Error> { if !Arc::ptr_eq(&self.measured_session.identity, &session.identity) { return Err(Error::StaleRenderSession); } if !zoom.is_finite() || zoom != self.measured_zoom { return Err(Error::StaleZoom); } let fonts = graphics_text::font_system() .read() .map_err(|_| Error::FontSystemUnavailable)?; if fonts.version() != self.measured_session.version || fonts.version() != session.version { return Err(Error::FontSetChanged); } Ok(()) } pub fn displayed(&self, session: &RenderSession, zoom: f32) -> Result<&str, Error> { self.check(session, zoom)?; Ok(&self.displayed) } pub fn retained_source( &self, session: &RenderSession, zoom: f32, ) -> Result, Error> { self.check(session, zoom)?; Ok(self.retained_source.clone()) } pub fn retains_any( &self, session: &RenderSession, zoom: f32, field: Range, ) -> Result { self.check(session, zoom)?; if field.start > field.end || self.source.get(field.clone()).is_none() { return Ok(false); } let end = field.end.min(self.retained_source.end); Ok(field.start < end && self .source .get(field.start..end) .is_some_and(|text| text.chars().any(|c| !c.is_whitespace()))) } /// Validate finite world position and rectangle endpoints only. Camera /// transforms and raster bounds remain a separate integration contract. pub fn bounds_at( &self, session: &RenderSession, zoom: f32, position: Point, ) -> Result { self.check(session, zoom)?; translated_bounds(position, self.bounds.size()) } /// Paint under the same positive uniform zoom used for `Cache::resolve`. /// No per-label wrapping or implicit text maximum is introduced. /// The returned Text must be consumed immediately in this session/zoom; /// retaining raw Text past another font load/zoom change bypasses the guard. pub fn canvas_text( &self, session: &RenderSession, zoom: f32, position: Point, color: Color, ) -> Result { self.bounds_at(session, zoom, position)?; Ok(Text { content: self.displayed.clone(), position, color, size: self.style.size, line_height: self.style.line_height, font: self.style.font, shaping: self.style.shaping, max_width: f32::INFINITY, ..Text::default() }) } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) enum Error { InvalidMetrics, SourceBudget, GraphemeBudget, FontSystemUnavailable, InvalidMeasurement, InvalidPosition, StaleZoom, StaleRenderSession, FontSetChanged, } struct Entry { source: String, style: Style, zoom: f32, width: f32, label: Arc