Files
ARCADIA-Studio/harness/experiments/scenario-label-measurement-20260913/attempt2/scenario_label.rs
T

272 lines
13 KiB
Rust

//! 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<Self, Error> {
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<usize>,
/// 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<Range<usize>, Error> {
self.check(session, zoom)?;
Ok(self.retained_source.clone())
}
pub fn retains_any(&self, session: &RenderSession, zoom: f32, field: Range<usize>) -> Result<bool, Error> {
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())))
}
pub fn bounds_at(&self, session: &RenderSession, zoom: f32, position: Point) -> Result<Rectangle, Error> {
self.check(session, zoom)?;
Ok(Rectangle::new(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<Text, Error> {
self.check(session, zoom)?;
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, StaleZoom, StaleRenderSession, FontSetChanged }
struct Entry { source: String, style: Style, zoom: f32, width: f32, label: Arc<Label> }
#[derive(Default)]
/// Two recent resolutions for ONE live labelled occurrence. A global small LRU
/// would thrash when a larger Scene is rebuilt in order. The integration owner
/// must retain these by occurrence identity and prune on removal/document change.
pub(super) struct Cache {
version: Option<graphics_text::Version>,
session: Option<Arc<()>>,
entries: VecDeque<Entry>,
#[cfg(test)]
allocations: usize,
}
impl Cache {
/// `width` excludes any caller-chosen backplate padding. Zero is valid.
/// Cache ownership must survive redraws; constructing one per Scene defeats
/// reuse. Changed fonts invalidate the SESSION: the caller must establish
/// a fresh renderer and a new settled session, not just remeasure this cache.
/// Error handling/fallback for these explicit experiment budgets is an
/// integration decision; never silently paint the old truncated contract.
pub fn resolve(&mut self, session: &RenderSession, source: &str, width: f32, zoom: f32, style: Style) -> Result<Arc<Label>, Error> {
let (size, line_height) = style.scaled(zoom);
if !width.is_finite() || width < 0. || !zoom.is_finite() || zoom <= 0.
|| !size.is_finite() || size <= 0. || !line_height.is_finite() || line_height <= 0.
|| !(width * zoom).is_finite() || !(size / zoom).is_finite()
|| !(line_height / zoom).is_finite() {
return Err(Error::InvalidMetrics);
}
if source.len() > MAX_SOURCE_BYTES { return Err(Error::SourceBudget); }
let mut fonts = graphics_text::font_system().write().map_err(|_| Error::FontSystemUnavailable)?;
if session.version != fonts.version() { return Err(Error::FontSetChanged); }
if self.version != Some(fonts.version())
|| self.session.as_ref().is_none_or(|identity| !Arc::ptr_eq(identity, &session.identity)) {
self.entries.clear();
self.version = Some(fonts.version());
self.session = Some(session.identity.clone());
}
if let Some(index) = self.entries.iter().position(|entry|
entry.source == source && entry.style == style && entry.zoom == zoom && entry.width == width) {
let entry = self.entries.remove(index).unwrap();
let label = entry.label.clone();
self.entries.push_back(entry);
return Ok(label);
}
let normalized = Normalized::new(source);
let cuts: Vec<usize> = normalized.text.grapheme_indices(true).map(|(i, _)| i)
.chain(std::iter::once(normalized.text.len())).collect();
if cuts.len().saturating_sub(1) > MAX_GRAPHEMES { return Err(Error::GraphemeBudget); }
// Candidate buffers are temporary. Only the resolved strings/metrics
// survive, bounded by MAX_ENTRIES; renderer caching remains independent.
let mut shapes = graphics_text::Cache::new();
let mut measure = |content: &str| -> Result<Size, Error> {
if content.is_empty() { return Ok(Size::ZERO); }
#[cfg(test)] { self.allocations += 1; }
let (_, entry) = shapes.allocate(fonts.raw(), graphics_text::cache::Key {
content, size, line_height, font: style.font, bounds: Size::INFINITE,
shaping: style.shaping, align_x: Alignment::Default,
});
if entry.buffer.layout_runs().count() != 1 || !entry.min_bounds.width.is_finite()
|| !entry.min_bounds.height.is_finite() { return Err(Error::InvalidMeasurement); }
world_size(entry.min_bounds, zoom)
};
let full = measure(&normalized.text)?;
let (displayed, retained, measured) = if full.width <= width {
(normalized.text.clone(), normalized.text.len(), full)
} else {
let ellipsis = measure("…")?;
if ellipsis.width > width {
(String::new(), 0, Size::ZERO)
} else {
// Every accepted candidate is actually shaped and fits. Search
// is bounded/logarithmic; contextual shaping is not universally
// monotone, so this does NOT promise the globally longest prefix.
// Full content is always checked first and never needlessly cut.
let mut best = ("…".to_owned(), 0, ellipsis);
let (mut low, mut high) = (1, cuts.len().saturating_sub(1));
while low < high {
let middle = low + (high - low) / 2;
let candidate = format!("{}…", &normalized.text[..cuts[middle]]);
let measured = measure(&candidate)?;
if measured.width <= width {
best = (candidate, cuts[middle], measured);
low = middle + 1;
} else { high = middle; }
}
best
}
};
let label = Arc::new(Label { source: source.into(), displayed,
retained_source: 0..normalized.source_end(retained),
bounds: Rectangle::with_size(measured), style,
measured_session: session.clone(), measured_zoom: zoom });
if self.entries.len() == MAX_ENTRIES { self.entries.pop_front(); }
self.entries.push_back(Entry { source: source.into(), style, zoom, width, label: label.clone() });
Ok(label)
}
}
fn world_size(scaled: Size, zoom: f32) -> Result<Size, Error> {
let world = Size::new(scaled.width / zoom, scaled.height / zoom);
if !world.width.is_finite() || !world.height.is_finite() || world.width < 0. || world.height < 0. {
return Err(Error::InvalidMeasurement);
}
Ok(world)
}
struct Normalized { text: String, ends: Vec<(usize, usize)> }
impl Normalized {
fn new(source: &str) -> Self {
let mut result = Self { text: String::new(), ends: vec![(0, 0)] };
let mut separator = false;
for (start, c) in source.char_indices() {
if c.is_whitespace() { separator = !result.text.is_empty(); continue; }
if separator {
result.text.push(' ');
result.ends.push((result.text.len(), start));
separator = false;
}
result.text.push(c);
result.ends.push((result.text.len(), start + c.len_utf8()));
}
result
}
fn source_end(&self, normalized_end: usize) -> usize {
self.ends.binary_search_by_key(&normalized_end, |(end, _)| *end)
.map(|index| self.ends[index].1).expect("grapheme cuts are character boundaries")
}
}
#[cfg(test)]
mod tests;