Prepare measured scenario labels and clear corridor evaluators
This commit is contained in:
@@ -0,0 +1,429 @@
|
||||
//! 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())))
|
||||
}
|
||||
|
||||
/// 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<Rectangle, Error> {
|
||||
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<Text, Error> {
|
||||
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<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)
|
||||
}
|
||||
|
||||
fn translated_bounds(position: Point, size: Size) -> Result<Rectangle, Error> {
|
||||
if !position.x.is_finite()
|
||||
|| !position.y.is_finite()
|
||||
|| !(position.x + size.width).is_finite()
|
||||
|| !(position.y + size.height).is_finite()
|
||||
{
|
||||
return Err(Error::InvalidPosition);
|
||||
}
|
||||
Ok(Rectangle::new(position, size))
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -0,0 +1,189 @@
|
||||
//! Unwired corridor-policy candidate for the measured scenario-label experiment.
|
||||
//!
|
||||
//! The caller supplies a finite structural label band AFTER endpoint/activation
|
||||
//! alignment, with padding already removed from its edges. Blockers include
|
||||
//! padded activation/card/header bounds over the complete text band, including
|
||||
//! stroke/ink clearance. This function does not infer a self-message corridor,
|
||||
//! alter scene extents, change zoom, or establish raster-ink containment.
|
||||
use iced::Rectangle;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) struct InvalidGeometry;
|
||||
|
||||
fn edges(rect: Rectangle) -> Result<(f32, f32, f32, f32), InvalidGeometry> {
|
||||
let right = rect.x + rect.width;
|
||||
let bottom = rect.y + rect.height;
|
||||
if [rect.x, rect.y, rect.width, rect.height, right, bottom]
|
||||
.into_iter()
|
||||
.any(|value| !value.is_finite())
|
||||
|| rect.width < 0.
|
||||
|| rect.height < 0.
|
||||
{
|
||||
return Err(InvalidGeometry);
|
||||
}
|
||||
Ok((rect.x, right, rect.y, bottom))
|
||||
}
|
||||
|
||||
/// Select one connected clear interval; never assemble text across blockers.
|
||||
/// Prefer greater width, then proximity to the previous label anchor, then the
|
||||
/// leftmost interval. Input order has no influence. A zero-area band has no
|
||||
/// corridor. Touching the padded vertical band is conservatively obstructing;
|
||||
/// a zero-area blocker has no area and must receive stroke padding upstream.
|
||||
pub(super) fn widest_clear(
|
||||
band: Rectangle,
|
||||
blockers: &[Rectangle],
|
||||
preferred_anchor: f32,
|
||||
) -> Result<Option<Rectangle>, InvalidGeometry> {
|
||||
let (left, right, top, bottom) = edges(band)?;
|
||||
if !preferred_anchor.is_finite() {
|
||||
return Err(InvalidGeometry);
|
||||
}
|
||||
let mut blocked = Vec::new();
|
||||
for &blocker in blockers {
|
||||
let (x0, x1, y0, y1) = edges(blocker)?;
|
||||
if blocker.width > 0. && blocker.height > 0. && y1 >= top && y0 <= bottom {
|
||||
let interval = (x0.max(left), x1.min(right));
|
||||
if interval.0 < interval.1 {
|
||||
blocked.push(interval);
|
||||
}
|
||||
}
|
||||
}
|
||||
if band.width == 0. || band.height == 0. {
|
||||
return Ok(None);
|
||||
}
|
||||
blocked.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1)));
|
||||
let mut best: Option<Rectangle> = None;
|
||||
let mut consider = |start: f32, end: f32| {
|
||||
if start >= end {
|
||||
return;
|
||||
}
|
||||
let candidate = Rectangle {
|
||||
x: start,
|
||||
width: end - start,
|
||||
..band
|
||||
};
|
||||
// Use f64 only for comparison distance: subtracting two admitted f32
|
||||
// world coordinates can overflow even though each rectangle is finite.
|
||||
let distance = |x: f32| (f64::from(x) - f64::from(preferred_anchor)).abs();
|
||||
if best.is_none_or(|old| {
|
||||
candidate.width > old.width
|
||||
|| (candidate.width == old.width
|
||||
&& (distance(candidate.x) < distance(old.x)
|
||||
|| (distance(candidate.x) == distance(old.x) && candidate.x < old.x)))
|
||||
}) {
|
||||
best = Some(candidate);
|
||||
}
|
||||
};
|
||||
let mut cursor = left;
|
||||
for (start, end) in blocked {
|
||||
consider(cursor, start);
|
||||
cursor = cursor.max(end);
|
||||
}
|
||||
consider(cursor, right);
|
||||
Ok(best)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rect(x: f32, y: f32, width: f32, height: f32) -> Rectangle {
|
||||
Rectangle {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_message_does_not_cross_an_intermediate_execution() {
|
||||
let band = rect(20., 100., 700., 22.);
|
||||
let blockers = [rect(250., 80., 16., 80.), rect(490., 90., 28., 140.)];
|
||||
let result = widest_clear(band, &blockers, band.x).unwrap().unwrap();
|
||||
assert_eq!(result, rect(20., 100., 230., 22.));
|
||||
for blocker in blockers {
|
||||
assert!(result.x + result.width <= blocker.x || result.x >= blocker.x + blocker.width);
|
||||
}
|
||||
assert_eq!(widest_clear(band, &[], band.x).unwrap(), Some(band));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_unsorted_blockers_merge_and_widest_gap_wins() {
|
||||
let band = rect(-100., 10., 600., 20.);
|
||||
let blockers = [
|
||||
rect(50., 0., 80., 60.),
|
||||
rect(0., 5., 70., 30.),
|
||||
rect(450., 0., 100., 60.),
|
||||
];
|
||||
let expected = Some(rect(130., 10., 320., 20.));
|
||||
assert_eq!(widest_clear(band, &blockers, -100.).unwrap(), expected);
|
||||
let mut reversed = blockers;
|
||||
reversed.reverse();
|
||||
assert_eq!(widest_clear(band, &reversed, -100.).unwrap(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_padded_band_and_touching_boundaries_are_obstructed() {
|
||||
let band = rect(0., 100., 200., 20.);
|
||||
// The first blocker ends at the band top: checking only a baseline
|
||||
// would miss it. The second starts at the band bottom.
|
||||
let blockers = [rect(0., 70., 60., 30.), rect(150., 120., 50., 30.)];
|
||||
assert_eq!(
|
||||
widest_clear(band, &blockers, 0.).unwrap(),
|
||||
Some(rect(60., 100., 90., 20.))
|
||||
);
|
||||
let outside = [rect(0., 70., 200., 29.), rect(0., 121., 200., 30.)];
|
||||
assert_eq!(widest_clear(band, &outside, 0.).unwrap(), Some(band));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ties_keep_the_nearest_anchor_then_choose_leftmost() {
|
||||
let band = rect(0., 0., 300., 20.);
|
||||
let blockers = [rect(100., 0., 100., 20.)];
|
||||
assert_eq!(
|
||||
widest_clear(band, &blockers, 190.).unwrap(),
|
||||
Some(rect(200., 0., 100., 20.))
|
||||
);
|
||||
assert_eq!(
|
||||
widest_clear(band, &blockers, 100.).unwrap(),
|
||||
Some(rect(0., 0., 100., 20.))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caller_defined_self_and_reversed_corridors_keep_their_geometry() {
|
||||
// Direction and self-loop policy are explicit caller inputs. Neither
|
||||
// a zero endpoint gap nor screen width can silently supply a corridor.
|
||||
for band in [rect(420., 40., 180., 22.), rect(-400., 90., 350., 22.)] {
|
||||
assert_eq!(widest_clear(band, &[], band.x).unwrap(), Some(band));
|
||||
}
|
||||
assert_eq!(
|
||||
widest_clear(rect(420., 40., 0., 22.), &[], 420.).unwrap(),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
widest_clear(rect(420., 40., 180., 0.), &[], 420.).unwrap(),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
widest_clear(rect(0., 0., 100., 20.), &[rect(-10., -10., 200., 50.)], 0.).unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_or_overflowing_geometry_never_enters_a_corridor() {
|
||||
let band = rect(0., 0., 100., 20.);
|
||||
for invalid in [
|
||||
rect(f32::NAN, 0., 10., 20.),
|
||||
rect(0., 0., -1., 20.),
|
||||
rect(f32::MAX, 0., f32::MAX, 20.),
|
||||
rect(0., f32::MAX, 10., f32::MAX),
|
||||
] {
|
||||
assert_eq!(widest_clear(invalid, &[], 0.), Err(InvalidGeometry));
|
||||
assert_eq!(widest_clear(band, &[invalid], 0.), Err(InvalidGeometry));
|
||||
}
|
||||
assert_eq!(widest_clear(band, &[], f32::INFINITY), Err(InvalidGeometry));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
//! Unexecuted evaluator candidates; the module is intentionally not wired yet.
|
||||
use super::*;
|
||||
use iced::advanced::{
|
||||
graphics::text::Paragraph,
|
||||
text::{Paragraph as _, Text as ParagraphText, Wrapping},
|
||||
};
|
||||
|
||||
// Independent paragraph construction: no candidate cache, normalization, search,
|
||||
// or character-width estimate is used by this measurement oracle.
|
||||
fn paragraph_size(content: &str, zoom: f32, style: Style) -> Size {
|
||||
let size = Pixels(style.size.0 * zoom);
|
||||
let line_height = match style.line_height {
|
||||
LineHeight::Relative(factor) => LineHeight::Relative(factor),
|
||||
LineHeight::Absolute(pixels) => LineHeight::Absolute(Pixels(pixels.0 * zoom)),
|
||||
};
|
||||
let paragraph = Paragraph::with_text(ParagraphText {
|
||||
content,
|
||||
bounds: Size::INFINITE,
|
||||
size,
|
||||
line_height,
|
||||
font: style.font,
|
||||
align_x: Alignment::Default,
|
||||
align_y: iced::alignment::Vertical::Top,
|
||||
shaping: style.shaping,
|
||||
wrapping: Wrapping::default(),
|
||||
});
|
||||
assert_eq!(paragraph.buffer().layout_runs().count(), 1);
|
||||
Size::new(paragraph.min_width() / zoom, paragraph.min_height() / zoom)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adequate_corridor_reveals_full_realistic_names_and_distinguishing_suffixes() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let style = Style::default();
|
||||
for text in [
|
||||
"1. call · Request valve repair · Technician service conversation",
|
||||
"2. reply · Return pressure after repair · Technician service conversation",
|
||||
"1. call · Request redundant hydraulic pressure calibration channel A",
|
||||
"1. call · Request redundant hydraulic pressure calibration channel B",
|
||||
] {
|
||||
let wide = cache.resolve(&session, text, 1600., 1., style).unwrap();
|
||||
assert_eq!(wide.displayed(&session, 1.).unwrap(), text);
|
||||
assert_eq!(wide.retained_source(&session, 1.).unwrap(), 0..text.len());
|
||||
assert_eq!(wide.bounds.size(), paragraph_size(text, 1., style));
|
||||
let narrow = cache.resolve(&session, text, 80., 1., style).unwrap();
|
||||
assert_ne!(narrow.displayed, text);
|
||||
assert!(narrow.bounds.width <= 80.);
|
||||
assert_eq!(
|
||||
narrow.bounds.size(),
|
||||
paragraph_size(&narrow.displayed, 1., style)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_visible_prefix_maps_to_original_bytes_without_claiming_hidden_operation() {
|
||||
let raw = " \t1. call · Pump\r\n · Operation: Need\u{301}le ";
|
||||
let expected = "1. call · Pump · Operation: Need\u{301}le";
|
||||
let n = Normalized::new(raw);
|
||||
assert_eq!(n.text, expected);
|
||||
let prefix = "1. call · Pump ";
|
||||
assert_eq!(n.source_end(prefix.len()), raw.find("· Operation").unwrap());
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let wide = cache
|
||||
.resolve(&session, raw, 1600., 1., Style::default())
|
||||
.unwrap();
|
||||
assert_eq!(wide.displayed, expected);
|
||||
assert_eq!(wide.retained_source.end, raw.trim_end().len());
|
||||
let field = raw.find("Need").unwrap()..raw.trim_end().len();
|
||||
assert!(wide.retains_any(&session, 1., field.clone()).unwrap());
|
||||
assert!(
|
||||
!wide.retains_any(&session, 1., 0..2).unwrap(),
|
||||
"normalized whitespace is not a visible field"
|
||||
);
|
||||
let small = cache
|
||||
.resolve(&session, raw, 70., 1., Style::default())
|
||||
.unwrap();
|
||||
assert!(!small.retains_any(&session, 1., field).unwrap());
|
||||
assert!(small.retained_source.end < raw.find("Operation").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grapheme_prefixes_and_zoom_match_actual_paragraph_layout() {
|
||||
let text = "call · 戻り値 圧力 e\u{301} 👩🔬 safety valve verification";
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
for zoom in [0.66, 1., 1.75] {
|
||||
let label = cache
|
||||
.resolve(&session, text, 105., zoom, Style::default())
|
||||
.unwrap();
|
||||
assert!(label.bounds.width <= 105.);
|
||||
assert_eq!(
|
||||
label.bounds.size(),
|
||||
paragraph_size(&label.displayed, zoom, Style::default())
|
||||
);
|
||||
assert!(
|
||||
text.grapheme_indices(true)
|
||||
.any(|(i, _)| i == label.retained_source.end)
|
||||
|| label.retained_source.end == text.len()
|
||||
);
|
||||
let painted = label
|
||||
.canvas_text(&session, zoom, Point::new(400., 90.), Color::BLACK)
|
||||
.unwrap();
|
||||
assert_eq!(painted.content, label.displayed);
|
||||
assert!(painted.max_width.is_infinite());
|
||||
assert_eq!(
|
||||
label
|
||||
.bounds_at(&session, zoom, painted.position)
|
||||
.unwrap()
|
||||
.size(),
|
||||
label.bounds.size()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_too_narrow_labels_never_claim_source_visibility() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
for text in ["", " \t\n", "Operation: Pump"] {
|
||||
let label = cache
|
||||
.resolve(&session, text, 0., 1., Style::default())
|
||||
.unwrap();
|
||||
assert_eq!(label.displayed, "");
|
||||
assert_eq!(label.retained_source, 0..0);
|
||||
assert_eq!(label.bounds.size(), Size::ZERO);
|
||||
assert!(!label.retains_any(&session, 1., 0..text.len()).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redraws_reuse_resolution_but_width_zoom_and_style_are_dependencies() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let text = "call · Pressure verification with a descriptive operation label";
|
||||
let style = Style::default();
|
||||
let a = cache.resolve(&session, text, 100., 1., style).unwrap();
|
||||
let measured = cache.allocations;
|
||||
let b = cache.resolve(&session, text, 100., 1., style).unwrap();
|
||||
assert!(Arc::ptr_eq(&a, &b));
|
||||
assert_eq!(cache.allocations, measured);
|
||||
for (width, zoom, style) in [
|
||||
(120., 1., style),
|
||||
(100., 0.66, style),
|
||||
(
|
||||
100.,
|
||||
1.,
|
||||
Style {
|
||||
size: Pixels(14.),
|
||||
..style
|
||||
},
|
||||
),
|
||||
] {
|
||||
let other = cache.resolve(&session, text, width, zoom, style).unwrap();
|
||||
assert!(!Arc::ptr_eq(&a, &other));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resident_entry_is_remeasured_for_a_fresh_settled_render_session() {
|
||||
let mut cache = Cache::default();
|
||||
let old_session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let text = "call · Pressure verification";
|
||||
let a = cache
|
||||
.resolve(&old_session, text, 100., 1., Style::default())
|
||||
.unwrap();
|
||||
assert_eq!(cache.entries.len(), 1);
|
||||
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &a));
|
||||
let measured = cache.allocations;
|
||||
assert!(measured > 0);
|
||||
assert!(Arc::ptr_eq(
|
||||
&a,
|
||||
&cache
|
||||
.resolve(&old_session, text, 100., 1., Style::default())
|
||||
.unwrap()
|
||||
));
|
||||
assert_eq!(cache.allocations, measured);
|
||||
// A fresh renderer session invalidates a still-resident entry. This does
|
||||
// not mutate global fonts or purport to test dynamic WGPU font loading.
|
||||
let new_session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let refreshed = cache
|
||||
.resolve(&new_session, text, 100., 1., Style::default())
|
||||
.unwrap();
|
||||
assert!(!Arc::ptr_eq(&a, &refreshed));
|
||||
assert!(cache.allocations > measured);
|
||||
assert_eq!(cache.entries.len(), 1);
|
||||
assert!(Arc::ptr_eq(
|
||||
&cache.entries.front().unwrap().label,
|
||||
&refreshed
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resident_entry_remeasures_after_local_font_epoch_invalidation() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let text = "call · Resident pressure label";
|
||||
let a = cache
|
||||
.resolve(&session, text, 100., 1., Style::default())
|
||||
.unwrap();
|
||||
assert_eq!(cache.entries.len(), 1);
|
||||
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &a));
|
||||
let measured = cache.allocations;
|
||||
assert!(measured > 0);
|
||||
// Exercise the local memo epoch branch while a is still resident. No
|
||||
// intervening width/zoom/style resolution can cause incidental eviction.
|
||||
// This is not an actual font-load or renderer-cache acceptance test.
|
||||
cache.version = None;
|
||||
let refreshed = cache
|
||||
.resolve(&session, text, 100., 1., Style::default())
|
||||
.unwrap();
|
||||
assert!(!Arc::ptr_eq(&a, &refreshed));
|
||||
assert!(cache.allocations > measured);
|
||||
assert_eq!(cache.entries.len(), 1);
|
||||
assert!(Arc::ptr_eq(
|
||||
&cache.entries.front().unwrap().label,
|
||||
&refreshed
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_artifact_rejects_other_zoom_and_render_session_for_every_consumer() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let label = cache
|
||||
.resolve(&session, "Operation: Pressure", 200., 1., Style::default())
|
||||
.unwrap();
|
||||
let other = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
for (context, zoom, expected) in [
|
||||
(&session, 0.66, Error::StaleZoom),
|
||||
(&other, 1., Error::StaleRenderSession),
|
||||
] {
|
||||
assert_eq!(label.displayed(context, zoom).unwrap_err(), expected);
|
||||
assert_eq!(label.retained_source(context, zoom).unwrap_err(), expected);
|
||||
assert_eq!(
|
||||
label.retains_any(context, zoom, 0..9).unwrap_err(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(
|
||||
label.bounds_at(context, zoom, Point::ORIGIN).unwrap_err(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(
|
||||
label
|
||||
.canvas_text(context, zoom, Point::ORIGIN, Color::BLACK)
|
||||
.unwrap_err(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
label
|
||||
.canvas_text(&session, 1., Point::ORIGIN, Color::BLACK)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_scaled_metrics_cannot_escape_as_infinite_world_dimensions() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let style = Style {
|
||||
size: Pixels(2.),
|
||||
line_height: LineHeight::Relative(f32::MAX),
|
||||
..Style::default()
|
||||
};
|
||||
// At zoom 0.5 the scaled size=1 and line height=MAX are finite. The
|
||||
// required world line height is not; reject before passing it to shaping.
|
||||
let scaled = style.scaled(0.5);
|
||||
assert!(scaled.0.is_finite() && scaled.1.is_finite());
|
||||
assert_eq!(
|
||||
cache
|
||||
.resolve(&session, "call", 100., 0.5, style)
|
||||
.unwrap_err(),
|
||||
Error::InvalidMetrics
|
||||
);
|
||||
assert_eq!(cache.allocations, 0);
|
||||
assert!(cache.entries.is_empty());
|
||||
// Independently exercise validation after division for actual measured
|
||||
// dimensions: finite scaled width/height do not imply finite world values.
|
||||
assert_eq!(
|
||||
world_size(Size::new(f32::MAX, 1.), 0.5),
|
||||
Err(Error::InvalidMeasurement)
|
||||
);
|
||||
assert_eq!(
|
||||
world_size(Size::new(1., f32::MAX), 0.5),
|
||||
Err(Error::InvalidMeasurement)
|
||||
);
|
||||
assert_eq!(
|
||||
world_size(Size::new(20., 8.), 0.5).unwrap(),
|
||||
Size::new(40., 16.)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_metrics_and_explicit_experiment_budgets_fail_without_a_cached_candidate() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
for (width, zoom) in [(f32::NAN, 1.), (-1., 1.), (100., 0.), (100., f32::INFINITY)] {
|
||||
assert_eq!(
|
||||
cache
|
||||
.resolve(&session, "call", width, zoom, Style::default())
|
||||
.unwrap_err(),
|
||||
Error::InvalidMetrics
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
cache
|
||||
.resolve(
|
||||
&session,
|
||||
&"x".repeat(MAX_SOURCE_BYTES + 1),
|
||||
100.,
|
||||
1.,
|
||||
Style::default()
|
||||
)
|
||||
.unwrap_err(),
|
||||
Error::SourceBudget
|
||||
);
|
||||
assert_eq!(
|
||||
cache
|
||||
.resolve(
|
||||
&session,
|
||||
&"x".repeat(MAX_GRAPHEMES + 1),
|
||||
100.,
|
||||
1.,
|
||||
Style::default()
|
||||
)
|
||||
.unwrap_err(),
|
||||
Error::GraphemeBudget
|
||||
);
|
||||
assert!(cache.entries.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_translation_rejects_nonfinite_world_positions_and_endpoints() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let label = cache
|
||||
.resolve(&session, "call", 100., 1., Style::default())
|
||||
.unwrap();
|
||||
for position in [Point::new(f32::NAN, 0.), Point::new(0., f32::INFINITY)] {
|
||||
assert_eq!(
|
||||
label.bounds_at(&session, 1., position).unwrap_err(),
|
||||
Error::InvalidPosition
|
||||
);
|
||||
assert_eq!(
|
||||
label
|
||||
.canvas_text(&session, 1., position, Color::BLACK)
|
||||
.unwrap_err(),
|
||||
Error::InvalidPosition
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
translated_bounds(Point::new(f32::MAX, 0.), Size::new(f32::MAX, 1.)),
|
||||
Err(Error::InvalidPosition)
|
||||
);
|
||||
assert_eq!(
|
||||
translated_bounds(Point::new(0., f32::MAX), Size::new(1., f32::MAX)),
|
||||
Err(Error::InvalidPosition)
|
||||
);
|
||||
assert_eq!(
|
||||
label
|
||||
.bounds_at(&session, 1., Point::new(-20., -10.))
|
||||
.unwrap()
|
||||
.position(),
|
||||
Point::new(-20., -10.)
|
||||
);
|
||||
}
|
||||
|
||||
/// Must run as the ONLY selected test in a dedicated, fresh test process.
|
||||
/// The ignored marker keeps this global-font mutation out of normal parallel
|
||||
/// desktop tests. See the evidence directory's exact executable runner.
|
||||
#[test]
|
||||
#[ignore = "mutates the global font database; run only this exact test in a fresh process"]
|
||||
fn actual_font_load_rejects_retained_label_and_old_session() {
|
||||
use graphics_text::cosmic_text::fontdb;
|
||||
use std::borrow::Cow;
|
||||
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let source = "Operation: Pressure";
|
||||
let label = cache
|
||||
.resolve(&session, source, 200., 1., Style::default())
|
||||
.unwrap();
|
||||
assert_eq!(label.displayed(&session, 1.).unwrap(), source);
|
||||
assert!(
|
||||
label
|
||||
.canvas_text(&session, 1., Point::ORIGIN, Color::BLACK)
|
||||
.is_ok()
|
||||
);
|
||||
let measured = cache.allocations;
|
||||
assert!(measured > 0);
|
||||
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &label));
|
||||
|
||||
let (version_before, version_after, faces_before, faces_after) = {
|
||||
let mut fonts = graphics_text::font_system().write().unwrap();
|
||||
let face = fonts
|
||||
.raw()
|
||||
.db()
|
||||
.faces()
|
||||
.find(|face| {
|
||||
face.families
|
||||
.iter()
|
||||
.any(|(family, _)| family == "Iced-Icons")
|
||||
})
|
||||
.expect("Iced 0.14 embeds and loads its known Iced-Icons.ttf face")
|
||||
.id;
|
||||
let bytes = fonts
|
||||
.raw()
|
||||
.db()
|
||||
.with_face_data(face, |data, _| data.to_vec())
|
||||
.expect("the built-in binary font has accessible bytes");
|
||||
assert!(!bytes.is_empty());
|
||||
// load_font increments Version even for invalid bytes. Independently
|
||||
// establish actual face admission so this cannot pass with invalid data.
|
||||
let mut probe = fontdb::Database::new();
|
||||
probe.load_font_data(bytes.clone());
|
||||
let admitted = probe
|
||||
.faces()
|
||||
.filter(|face| {
|
||||
face.families
|
||||
.iter()
|
||||
.any(|(family, _)| family == "Iced-Icons")
|
||||
})
|
||||
.count();
|
||||
assert!(
|
||||
admitted > 0,
|
||||
"the extracted bytes parse as the expected real font"
|
||||
);
|
||||
let before = fonts.version();
|
||||
assert_eq!(before, session.version);
|
||||
let count = fonts.raw().db().faces().count();
|
||||
// Owned bytes intentionally bypass Borrowed-address deduplication.
|
||||
// This is an actual valid duplicate-face load, not a claim the chosen
|
||||
// face or rendered metrics change; the stale-epoch rule is the oracle.
|
||||
fonts.load_font(Cow::Owned(bytes));
|
||||
(
|
||||
before,
|
||||
fonts.version(),
|
||||
count,
|
||||
fonts.raw().db().faces().count(),
|
||||
)
|
||||
};
|
||||
assert_ne!(version_before, version_after);
|
||||
assert!(
|
||||
faces_after > faces_before,
|
||||
"actual font admission must accompany the version increment"
|
||||
);
|
||||
assert_eq!(
|
||||
label.displayed(&session, 1.).unwrap_err(),
|
||||
Error::FontSetChanged
|
||||
);
|
||||
assert_eq!(
|
||||
label.retained_source(&session, 1.).unwrap_err(),
|
||||
Error::FontSetChanged
|
||||
);
|
||||
assert_eq!(
|
||||
label.retains_any(&session, 1., 0..9).unwrap_err(),
|
||||
Error::FontSetChanged
|
||||
);
|
||||
assert_eq!(
|
||||
label.bounds_at(&session, 1., Point::ORIGIN).unwrap_err(),
|
||||
Error::FontSetChanged
|
||||
);
|
||||
assert_eq!(
|
||||
label
|
||||
.canvas_text(&session, 1., Point::ORIGIN, Color::BLACK)
|
||||
.unwrap_err(),
|
||||
Error::FontSetChanged
|
||||
);
|
||||
assert_eq!(
|
||||
cache
|
||||
.resolve(&session, source, 200., 1., Style::default())
|
||||
.unwrap_err(),
|
||||
Error::FontSetChanged
|
||||
);
|
||||
assert_eq!(
|
||||
cache.allocations, measured,
|
||||
"an invalid session must not remeasure and imply renderer repair"
|
||||
);
|
||||
println!(
|
||||
"Valid Iced-Icons load: faces {faces_before}->{faces_after}, font Version {version_before:?}->{version_after:?}; old label and session rejected"
|
||||
);
|
||||
// Do not create a fresh session or claim dynamic WGPU cache recovery here.
|
||||
// Process exit is the isolation boundary; no other test may run afterward.
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
attempt1/* -text whitespace=cr-at-eol
|
||||
attempt2/* -text whitespace=cr-at-eol
|
||||
attempt3/* -text whitespace=cr-at-eol
|
||||
@@ -0,0 +1,147 @@
|
||||
# Scenario label measurement: preserved candidate and source correction
|
||||
|
||||
This is an **unwired, uncompiled experiment**, not an accepted renderer policy.
|
||||
Source base: `208268c8aa7f6bc3c2bab6a3c0657358f3f7dc23`.
|
||||
Parent's docs-only worktree HEAD: `75cb09dade2cf4b9a176b5e6159f43e2345d196e`.
|
||||
Only the two new `crates/oa-desktop/src/behavior/scenario_label` module/test
|
||||
paths and this evidence directory belong to this correction.
|
||||
|
||||
## Preserved attempt 1
|
||||
|
||||
`attempt1/scenario_label.rs` and `attempt1/tests.rs` are the exact raw files
|
||||
reviewed before correction. Both SHA256 values were checked before copying and
|
||||
again after copying; [manifest](attempt1/manifest.json) records source paths and
|
||||
hashes. They remain historical evidence, not alternate compiler inputs.
|
||||
|
||||
Independent static review identified:
|
||||
|
||||
1. A retained Label Arc lacked its font epoch and zoom. Cached dimensions could
|
||||
be reused by painting/hit/navigation after their measurement context changed.
|
||||
2. Iced's WGPU text cache omits font Version; remeasuring only this module cannot
|
||||
establish dynamic-font parity with an already-populated renderer cache.
|
||||
3. The font-epoch test had already evicted its original entry with three variants
|
||||
in a capacity-two cache. Different Arc identity did not prove invalidation.
|
||||
4. Finite scaled metrics could overflow when converted back to world units.
|
||||
Concrete example: size 2, Relative(MAX) line height, zoom 0.5.
|
||||
|
||||
These were source findings. Neither the old candidate nor the correction was
|
||||
compiled or executed by this worker; no failure/pass count is inferred.
|
||||
|
||||
## Preserved attempt 2 and missing font-version branch
|
||||
|
||||
`attempt2/` preserves both corrected raw source files and this note before the
|
||||
follow-up, with exact SHA256 checks before and after copying. The root reviewer
|
||||
found that local cache epoch invalidation and a fresh session identity did not
|
||||
exercise an actual global font Version change. That specific branch was still
|
||||
untested; the historical ten tests were not evidence for it.
|
||||
|
||||
## Corrected experimental seam
|
||||
|
||||
`RenderSession::after_fonts_settled_and_renderer_reset()` explicitly records a
|
||||
caller-established settled-font rendering lifetime. It does not reset or inspect
|
||||
the renderer. The caller must finish font loading and create a fresh renderer
|
||||
cache, then prohibit font changes between resolution, guarded consumption and
|
||||
draw. A changed global font Version rejects the old session; a new local cache
|
||||
alone is not a repair for WGPU's old glyph buffers.
|
||||
|
||||
Labels record that session identity/font Version and measured zoom. Displayed
|
||||
text, retained-source bytes, bounds, field retention and canvas Text construction
|
||||
use checked accessors. Another session, another zoom or a detected font change
|
||||
rejects a retained artifact. Returned raw Text/bounds must be consumed in that
|
||||
checked context: holding those values across context changes bypasses the guard.
|
||||
This is an explicit limited contract, not atomic protection against a concurrent
|
||||
font load or an incorrect renderer transform supplied by the caller.
|
||||
|
||||
Finite world line metrics are checked before shaping. Actual measured dimensions
|
||||
are checked again after division by zoom. The existing 64KiB source and 4096
|
||||
grapheme budgets remain explicit experiment errors, not adopted product limits.
|
||||
|
||||
Eleven ordinary test candidates now include separate resident-entry font-epoch invalidation,
|
||||
fresh-session invalidation, stale artifact rejection for every consumer, and
|
||||
the overflow counterexample, plus finite world-position/rectangle-endpoint
|
||||
validation for bounds_at and canvas_text. This last check does not establish
|
||||
validity under arbitrary camera transforms or contain raster ink.
|
||||
The invalidation assertions require a still-resident
|
||||
entry, positive prior measurement count and an increased count afterward.
|
||||
These ordinary tests do not mutate the global font database or claim dynamic-font rendering
|
||||
acceptance. The existing actual Paragraph comparisons remain layout-metric
|
||||
oracles, not raster-ink/Scene collision evidence.
|
||||
|
||||
One additional **ignored** test, `actual_font_load_rejects_retained_label_and_old_session`,
|
||||
must run alone in a fresh process. It resolves a label, extracts the actual
|
||||
Iced-Icons font bytes that pinned Iced embeds in its global fontdb, independently
|
||||
parses them into a fresh fontdb and confirms the expected family, then loads owned
|
||||
valid bytes through actual FontSystem::load_font. Both a new face and a changed
|
||||
Version must be observed. The old Label's five consumer accessors and old-session
|
||||
Cache::resolve must return FontSetChanged; no new measurements may occur.
|
||||
|
||||
This is a valid duplicate-face load, not invalid input used merely to increment
|
||||
a version. It does not claim the selected face or visual metrics change, create
|
||||
a WGPU renderer, or test renderer recovery after dynamic loading. No font asset,
|
||||
system-specific font path, download, or new dependency is needed. Version mutation
|
||||
is not undone; dedicated process exit is the isolation boundary.
|
||||
|
||||
## Pinned primary APIs and remaining evaluator
|
||||
|
||||
Cargo.lock pins iced_graphics/iced_wgpu 0.14.0 and iced_widget 0.14.2. Relevant
|
||||
local registry sources: `iced_wgpu-0.14.0/src/geometry.rs` (`fill_text`, scaled
|
||||
font metrics), `iced_wgpu-0.14.0/src/text.rs` (`Text::Cached` allocation),
|
||||
`iced_graphics-0.14.0/src/text/cache.rs` (Key/hash lacks Version), and
|
||||
`iced_graphics-0.14.0/src/text.rs` (FontSystem::load_font/version).
|
||||
Independent reviewer confirmed `geometry.rs:306-360`, WGPU `text.rs:443-500`
|
||||
and its retained BufferCache at line 304 (trim at 435 only evicts unused
|
||||
entries), graphics `text/cache.rs:109-146`, and graphics `compositor.rs:69`.
|
||||
The compositor's font-load path changes the global FontSystem, not the WGPU
|
||||
buffer cache. A check immediately before canvas_text is therefore not atomic
|
||||
with the later WGPU prepare; fonts must remain unchanged for the entire session.
|
||||
|
||||
The module remains undeclared in behavior.rs. Parent may separately add test-only
|
||||
`#[cfg(test)] mod scenario_label;` and run:
|
||||
|
||||
```
|
||||
cargo test -p oa-desktop --locked --offline behavior::scenario_label::tests:: -- --test-threads=1
|
||||
```
|
||||
|
||||
The normal filter selects twelve tests: eleven ordinary tests and one ignored
|
||||
global-font test. This is an expected selection, not a claim they pass. Do not run
|
||||
the whole desktop suite with `--include-ignored` to exercise the font test.
|
||||
|
||||
After the parent compiles and records a matching test executable, use the exact
|
||||
isolation runner (no Cargo invocation inside the runner):
|
||||
|
||||
```powershell
|
||||
& ./harness/experiments/scenario-label-measurement-20260913/run-font-version-test.ps1 `
|
||||
-TestExecutable $desktopTestExe -ExpectedSha256 $expectedTestHash `
|
||||
-RunDirectory $freshEvidenceDir
|
||||
```
|
||||
|
||||
It verifies the supplied binary hash, lists exactly one ignored test, then starts
|
||||
a separate fresh executable process with:
|
||||
|
||||
```
|
||||
--ignored --exact behavior::scenario_label::tests::actual_font_load_rejects_retained_label_and_old_session --nocapture --test-threads=1
|
||||
```
|
||||
|
||||
The runner refuses an existing result directory and requires exactly one passing
|
||||
test, preserving selection/output/receipt. Parent must provide the matching DLL
|
||||
environment and source-to-binary provenance. Runner and tests are unexecuted.
|
||||
It also accepts `-TestName scenario_label::tests::actual_font_load_rejects_retained_label_and_old_session`
|
||||
for a parent-built standalone test wrapper importing the exact module at crate
|
||||
root. That result must be reported as standalone Iced/module evidence, not desktop
|
||||
or native integration. Wrapper, dependency artifacts and source hashes must be
|
||||
bound separately by the compiler owner.
|
||||
|
||||
Corridors remain
|
||||
external; ordinary/reversed and self-message positioning is not selected here.
|
||||
The bounded prefix search guarantees measured fit, not a globally maximal prefix
|
||||
for all contextual shaping. No wrapping, row-height change, public schema or
|
||||
production gesture is introduced. Real WGPU label ink, activation shoulders,
|
||||
clipping, viewport transforms, and actual navigation integration remain required
|
||||
before adopting the seam. Original fixed 32-grapheme/225-unit rendering is still
|
||||
unchanged for baseline comparison.
|
||||
|
||||
## Parent corridor candidate and isolated evaluator
|
||||
|
||||
The parent added `scenario_label/corridor.rs`: a bounded geometric candidate that subtracts padded blockers intersecting the full label band and selects the widest connected interval, with deterministic anchor/leftmost tie-breaking. It takes an explicit finite caller-supplied band; self/reversed corridor construction, actual Scene integration, font/ink clearance and release gesture policy remain unimplemented. Six unexecuted tests cover intermediate activations, unsorted overlapping blockers, complete-band intersections, ties, explicit self/reversed bands and invalid/overflow coordinates. This is an evaluation candidate, not an adopted paint policy.
|
||||
|
||||
`attempt3` preserves both corrected raw label modules and the first raw corridor before scoped rustfmt. `standalone.rs` imports the actual three current source files without copying their implementation or wiring the desktop. Its expected listing is 18 tests: 17 ordinary and one ignored global-font mutation. Compiling/running that wrapper against explicitly identified pinned Iced/unicode dependencies is a standalone evaluator only; it does not establish desktop integration or WGPU/native acceptance. Use the font runner with the standalone exact test name documented above, in a separate process. No execution result is claimed by this prepared note; subsequent gate evidence will record actual results.
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"base": "208268c8aa7f6bc3c2bab6a3c0657358f3f7dc23",
|
||||
"status": "Original uncompiled, unwired candidate; static review found stale reuse, vacuous epoch test and post-division overflow gaps",
|
||||
"files": [
|
||||
{
|
||||
"path": "crates/oa-desktop/src/behavior/scenario_label.rs",
|
||||
"hash": "1CACEDE2BCB0E3E86C0D0B691D6E65FDADEA75F93FC74BBAF94E8E2082D6E877",
|
||||
"name": "scenario_label.rs"
|
||||
},
|
||||
{
|
||||
"path": "crates/oa-desktop/src/behavior/scenario_label/tests.rs",
|
||||
"hash": "9AC3A0D091E3A319D7822C76B34DCEA56B8D400DBB7F55B3711659E84B6F2BDB",
|
||||
"name": "tests.rs"
|
||||
}
|
||||
],
|
||||
"docsOnlyHead": "75cb09dade2cf4b9a176b5e6159f43e2345d196e"
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! 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)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct Label {
|
||||
source: String,
|
||||
pub displayed: String,
|
||||
/// Original-label bytes represented before any added ellipsis. Includes
|
||||
/// collapsed whitespace, but `retains_any` never counts whitespace as text.
|
||||
pub retained_source: Range<usize>,
|
||||
/// World-space layout advances, at origin; not glyph-ink containment.
|
||||
pub bounds: Rectangle,
|
||||
style: Style,
|
||||
}
|
||||
|
||||
impl Label {
|
||||
pub fn retains_any(&self, field: Range<usize>) -> bool {
|
||||
if field.start > field.end || self.source.get(field.clone()).is_none() {
|
||||
return false;
|
||||
}
|
||||
let end = field.end.min(self.retained_source.end);
|
||||
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, position: Point) -> Rectangle {
|
||||
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.
|
||||
pub fn canvas_text(&self, position: Point, color: Color) -> Text {
|
||||
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 }
|
||||
|
||||
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>,
|
||||
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. Fonts loaded through Iced invalidate all entries by Version.
|
||||
/// Error handling/fallback for these explicit experiment budgets is an
|
||||
/// integration decision; never silently paint the old truncated contract.
|
||||
pub fn resolve(&mut self, 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() {
|
||||
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 self.version != Some(fonts.version()) {
|
||||
self.entries.clear();
|
||||
self.version = Some(fonts.version());
|
||||
}
|
||||
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); }
|
||||
Ok(Size::new(entry.min_bounds.width / zoom, entry.min_bounds.height / 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 });
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Unexecuted evaluator candidates; the module is intentionally not wired yet.
|
||||
use super::*;
|
||||
use iced::advanced::{graphics::text::Paragraph, text::{Paragraph as _, Text as ParagraphText, Wrapping}};
|
||||
|
||||
// Independent paragraph construction: no candidate cache, normalization, search,
|
||||
// or character-width estimate is used by this measurement oracle.
|
||||
fn paragraph_size(content: &str, zoom: f32, style: Style) -> Size {
|
||||
let size = Pixels(style.size.0 * zoom);
|
||||
let line_height = match style.line_height {
|
||||
LineHeight::Relative(factor) => LineHeight::Relative(factor),
|
||||
LineHeight::Absolute(pixels) => LineHeight::Absolute(Pixels(pixels.0 * zoom)),
|
||||
};
|
||||
let paragraph = Paragraph::with_text(ParagraphText { content, bounds: Size::INFINITE,
|
||||
size, line_height, font: style.font, align_x: Alignment::Default,
|
||||
align_y: iced::alignment::Vertical::Top, shaping: style.shaping, wrapping: Wrapping::default() });
|
||||
assert_eq!(paragraph.buffer().layout_runs().count(), 1);
|
||||
Size::new(paragraph.min_width() / zoom, paragraph.min_height() / zoom)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adequate_corridor_reveals_full_realistic_names_and_distinguishing_suffixes() {
|
||||
let mut cache = Cache::default();
|
||||
let style = Style::default();
|
||||
for text in [
|
||||
"1. call · Request valve repair · Technician service conversation",
|
||||
"2. reply · Return pressure after repair · Technician service conversation",
|
||||
"1. call · Request redundant hydraulic pressure calibration channel A",
|
||||
"1. call · Request redundant hydraulic pressure calibration channel B",
|
||||
] {
|
||||
let wide = cache.resolve(text, 1600., 1., style).unwrap();
|
||||
assert_eq!(wide.displayed, text);
|
||||
assert_eq!(wide.retained_source, 0..text.len());
|
||||
assert_eq!(wide.bounds.size(), paragraph_size(text, 1., style));
|
||||
let narrow = cache.resolve(text, 80., 1., style).unwrap();
|
||||
assert_ne!(narrow.displayed, text);
|
||||
assert!(narrow.bounds.width <= 80.);
|
||||
assert_eq!(narrow.bounds.size(), paragraph_size(&narrow.displayed, 1., style));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_visible_prefix_maps_to_original_bytes_without_claiming_hidden_operation() {
|
||||
let raw = " \t1. call · Pump\r\n · Operation: Need\u{301}le ";
|
||||
let expected = "1. call · Pump · Operation: Need\u{301}le";
|
||||
let n = Normalized::new(raw);
|
||||
assert_eq!(n.text, expected);
|
||||
let prefix = "1. call · Pump ";
|
||||
assert_eq!(n.source_end(prefix.len()), raw.find("· Operation").unwrap());
|
||||
let mut cache = Cache::default();
|
||||
let wide = cache.resolve(raw, 1600., 1., Style::default()).unwrap();
|
||||
assert_eq!(wide.displayed, expected);
|
||||
assert_eq!(wide.retained_source.end, raw.trim_end().len());
|
||||
let field = raw.find("Need").unwrap()..raw.trim_end().len();
|
||||
assert!(wide.retains_any(field.clone()));
|
||||
assert!(!wide.retains_any(0..2), "normalized whitespace is not a visible field");
|
||||
let small = cache.resolve(raw, 70., 1., Style::default()).unwrap();
|
||||
assert!(!small.retains_any(field));
|
||||
assert!(small.retained_source.end < raw.find("Operation").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grapheme_prefixes_and_zoom_match_actual_paragraph_layout() {
|
||||
let text = "call · 戻り値 圧力 e\u{301} 👩🔬 safety valve verification";
|
||||
let mut cache = Cache::default();
|
||||
for zoom in [0.66, 1., 1.75] {
|
||||
let label = cache.resolve(text, 105., zoom, Style::default()).unwrap();
|
||||
assert!(label.bounds.width <= 105.);
|
||||
assert_eq!(label.bounds.size(), paragraph_size(&label.displayed, zoom, Style::default()));
|
||||
assert!(text.grapheme_indices(true).any(|(i, _)| i == label.retained_source.end)
|
||||
|| label.retained_source.end == text.len());
|
||||
let painted = label.canvas_text(Point::new(400., 90.), Color::BLACK);
|
||||
assert_eq!(painted.content, label.displayed);
|
||||
assert!(painted.max_width.is_infinite());
|
||||
assert_eq!(label.bounds_at(painted.position).size(), label.bounds.size());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_too_narrow_labels_never_claim_source_visibility() {
|
||||
let mut cache = Cache::default();
|
||||
for text in ["", " \t\n", "Operation: Pump"] {
|
||||
let label = cache.resolve(text, 0., 1., Style::default()).unwrap();
|
||||
assert_eq!(label.displayed, "");
|
||||
assert_eq!(label.retained_source, 0..0);
|
||||
assert_eq!(label.bounds.size(), Size::ZERO);
|
||||
assert!(!label.retains_any(0..text.len()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redraws_reuse_resolution_but_width_zoom_style_and_font_epoch_are_dependencies() {
|
||||
let mut cache = Cache::default();
|
||||
let text = "call · Pressure verification with a descriptive operation label";
|
||||
let style = Style::default();
|
||||
let a = cache.resolve(text, 100., 1., style).unwrap();
|
||||
let measured = cache.allocations;
|
||||
let b = cache.resolve(text, 100., 1., style).unwrap();
|
||||
assert!(Arc::ptr_eq(&a, &b));
|
||||
assert_eq!(cache.allocations, measured);
|
||||
for (width, zoom, style) in [(120., 1., style), (100., 0.66, style),
|
||||
(100., 1., Style { size: Pixels(14.), ..style })] {
|
||||
let other = cache.resolve(text, width, zoom, style).unwrap();
|
||||
assert!(!Arc::ptr_eq(&a, &other));
|
||||
}
|
||||
// Simulate an invalidated local epoch without mutating the shared font DB.
|
||||
cache.version = None;
|
||||
let refreshed = cache.resolve(text, 100., 1., style).unwrap();
|
||||
assert!(!Arc::ptr_eq(&a, &refreshed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_metrics_and_explicit_experiment_budgets_fail_without_a_cached_candidate() {
|
||||
let mut cache = Cache::default();
|
||||
for (width, zoom) in [(f32::NAN, 1.), (-1., 1.), (100., 0.), (100., f32::INFINITY)] {
|
||||
assert_eq!(cache.resolve("call", width, zoom, Style::default()).unwrap_err(), Error::InvalidMetrics);
|
||||
}
|
||||
assert_eq!(cache.resolve(&"x".repeat(MAX_SOURCE_BYTES + 1), 100., 1., Style::default()).unwrap_err(), Error::SourceBudget);
|
||||
assert_eq!(cache.resolve(&"x".repeat(MAX_GRAPHEMES + 1), 100., 1., Style::default()).unwrap_err(), Error::GraphemeBudget);
|
||||
assert!(cache.entries.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
# Scenario label measurement: preserved candidate and source correction
|
||||
|
||||
This is an **unwired, uncompiled experiment**, not an accepted renderer policy.
|
||||
Source base: `208268c8aa7f6bc3c2bab6a3c0657358f3f7dc23`.
|
||||
Parent's docs-only worktree HEAD: `75cb09dade2cf4b9a176b5e6159f43e2345d196e`.
|
||||
Only the two new `crates/oa-desktop/src/behavior/scenario_label` module/test
|
||||
paths and this evidence directory belong to this correction.
|
||||
|
||||
## Preserved attempt 1
|
||||
|
||||
`attempt1/scenario_label.rs` and `attempt1/tests.rs` are the exact raw files
|
||||
reviewed before correction. Both SHA256 values were checked before copying and
|
||||
again after copying; [manifest](attempt1/manifest.json) records source paths and
|
||||
hashes. They remain historical evidence, not alternate compiler inputs.
|
||||
|
||||
Independent static review identified:
|
||||
|
||||
1. A retained Label Arc lacked its font epoch and zoom. Cached dimensions could
|
||||
be reused by painting/hit/navigation after their measurement context changed.
|
||||
2. Iced's WGPU text cache omits font Version; remeasuring only this module cannot
|
||||
establish dynamic-font parity with an already-populated renderer cache.
|
||||
3. The font-epoch test had already evicted its original entry with three variants
|
||||
in a capacity-two cache. Different Arc identity did not prove invalidation.
|
||||
4. Finite scaled metrics could overflow when converted back to world units.
|
||||
Concrete example: size 2, Relative(MAX) line height, zoom 0.5.
|
||||
|
||||
These were source findings. Neither the old candidate nor the correction was
|
||||
compiled or executed by this worker; no failure/pass count is inferred.
|
||||
|
||||
## Corrected experimental seam
|
||||
|
||||
`RenderSession::after_fonts_settled_and_renderer_reset()` explicitly records a
|
||||
caller-established settled-font rendering lifetime. It does not reset or inspect
|
||||
the renderer. The caller must finish font loading and create a fresh renderer
|
||||
cache, then prohibit font changes between resolution, guarded consumption and
|
||||
draw. A changed global font Version rejects the old session; a new local cache
|
||||
alone is not a repair for WGPU's old glyph buffers.
|
||||
|
||||
Labels record that session identity/font Version and measured zoom. Displayed
|
||||
text, retained-source bytes, bounds, field retention and canvas Text construction
|
||||
use checked accessors. Another session, another zoom or a detected font change
|
||||
rejects a retained artifact. Returned raw Text/bounds must be consumed in that
|
||||
checked context: holding those values across context changes bypasses the guard.
|
||||
This is an explicit limited contract, not atomic protection against a concurrent
|
||||
font load or an incorrect renderer transform supplied by the caller.
|
||||
|
||||
Finite world line metrics are checked before shaping. Actual measured dimensions
|
||||
are checked again after division by zoom. The existing 64KiB source and 4096
|
||||
grapheme budgets remain explicit experiment errors, not adopted product limits.
|
||||
|
||||
Ten test candidates now include separate resident-entry font-epoch invalidation,
|
||||
fresh-session invalidation, stale artifact rejection for every consumer, and
|
||||
the overflow counterexample. The invalidation assertions require a still-resident
|
||||
entry, positive prior measurement count and an increased count afterward.
|
||||
These tests do not mutate the global font database or claim dynamic-font rendering
|
||||
acceptance. The existing actual Paragraph comparisons remain layout-metric
|
||||
oracles, not raster-ink/Scene collision evidence.
|
||||
|
||||
## Pinned primary APIs and remaining evaluator
|
||||
|
||||
Cargo.lock pins iced_graphics/iced_wgpu 0.14.0 and iced_widget 0.14.2. Relevant
|
||||
local registry sources: `iced_wgpu-0.14.0/src/geometry.rs` (`fill_text`, scaled
|
||||
font metrics), `iced_wgpu-0.14.0/src/text.rs` (`Text::Cached` allocation),
|
||||
`iced_graphics-0.14.0/src/text/cache.rs` (Key/hash lacks Version), and
|
||||
`iced_graphics-0.14.0/src/text.rs` (FontSystem::load_font/version).
|
||||
Independent reviewer confirmed `geometry.rs:306-360`, WGPU `text.rs:443-500`
|
||||
and its retained BufferCache at line 304 (trim at 435 only evicts unused
|
||||
entries), graphics `text/cache.rs:109-146`, and graphics `compositor.rs:69`.
|
||||
The compositor's font-load path changes the global FontSystem, not the WGPU
|
||||
buffer cache. A check immediately before canvas_text is therefore not atomic
|
||||
with the later WGPU prepare; fonts must remain unchanged for the entire session.
|
||||
|
||||
The module remains undeclared in behavior.rs. Parent may separately add test-only
|
||||
`#[cfg(test)] mod scenario_label;` and run:
|
||||
|
||||
```
|
||||
cargo test -p oa-desktop --locked --offline behavior::scenario_label::tests:: -- --test-threads=1
|
||||
```
|
||||
|
||||
Expected selection is exactly ten tests, not a claim they pass. Corridors remain
|
||||
external; ordinary/reversed and self-message positioning is not selected here.
|
||||
The bounded prefix search guarantees measured fit, not a globally maximal prefix
|
||||
for all contextual shaping. No wrapping, row-height change, public schema or
|
||||
production gesture is introduced. Real WGPU label ink, activation shoulders,
|
||||
clipping, viewport transforms, and actual navigation integration remain required
|
||||
before adopting the seam. Original fixed 32-grapheme/225-unit rendering is still
|
||||
unchanged for baseline comparison.
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"sourceBase": "208268c8aa7f6bc3c2bab6a3c0657358f3f7dc23",
|
||||
"docsOnlyHead": "75cb09dade2cf4b9a176b5e6159f43e2345d196e",
|
||||
"files": [
|
||||
{
|
||||
"name": "scenario_label.rs",
|
||||
"hash": "82789CF6C9D0C3F0B027671D13E286F869A359DF1F163D8B47EA1B1E442CAAF4",
|
||||
"path": "crates/oa-desktop/src/behavior/scenario_label.rs"
|
||||
},
|
||||
{
|
||||
"name": "tests.rs",
|
||||
"hash": "0C7A0CE80570B8DA347B5147AD00DB9AF26AE4E3FDE0F445683D1958348972AF",
|
||||
"path": "crates/oa-desktop/src/behavior/scenario_label/tests.rs"
|
||||
},
|
||||
{
|
||||
"name": "README.md",
|
||||
"hash": "D49F5A48B6C943C4FBA3DB86BFC5C916719DB01A9D962FAB381D976B96E5D993",
|
||||
"path": "harness/experiments/scenario-label-measurement-20260913/README.md"
|
||||
}
|
||||
],
|
||||
"status": "Uncompiled corrected candidate before actual font-load coverage"
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
//! 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;
|
||||
@@ -0,0 +1,200 @@
|
||||
//! Unexecuted evaluator candidates; the module is intentionally not wired yet.
|
||||
use super::*;
|
||||
use iced::advanced::{graphics::text::Paragraph, text::{Paragraph as _, Text as ParagraphText, Wrapping}};
|
||||
|
||||
// Independent paragraph construction: no candidate cache, normalization, search,
|
||||
// or character-width estimate is used by this measurement oracle.
|
||||
fn paragraph_size(content: &str, zoom: f32, style: Style) -> Size {
|
||||
let size = Pixels(style.size.0 * zoom);
|
||||
let line_height = match style.line_height {
|
||||
LineHeight::Relative(factor) => LineHeight::Relative(factor),
|
||||
LineHeight::Absolute(pixels) => LineHeight::Absolute(Pixels(pixels.0 * zoom)),
|
||||
};
|
||||
let paragraph = Paragraph::with_text(ParagraphText { content, bounds: Size::INFINITE,
|
||||
size, line_height, font: style.font, align_x: Alignment::Default,
|
||||
align_y: iced::alignment::Vertical::Top, shaping: style.shaping, wrapping: Wrapping::default() });
|
||||
assert_eq!(paragraph.buffer().layout_runs().count(), 1);
|
||||
Size::new(paragraph.min_width() / zoom, paragraph.min_height() / zoom)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adequate_corridor_reveals_full_realistic_names_and_distinguishing_suffixes() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let style = Style::default();
|
||||
for text in [
|
||||
"1. call · Request valve repair · Technician service conversation",
|
||||
"2. reply · Return pressure after repair · Technician service conversation",
|
||||
"1. call · Request redundant hydraulic pressure calibration channel A",
|
||||
"1. call · Request redundant hydraulic pressure calibration channel B",
|
||||
] {
|
||||
let wide = cache.resolve(&session, text, 1600., 1., style).unwrap();
|
||||
assert_eq!(wide.displayed(&session, 1.).unwrap(), text);
|
||||
assert_eq!(wide.retained_source(&session, 1.).unwrap(), 0..text.len());
|
||||
assert_eq!(wide.bounds.size(), paragraph_size(text, 1., style));
|
||||
let narrow = cache.resolve(&session, text, 80., 1., style).unwrap();
|
||||
assert_ne!(narrow.displayed, text);
|
||||
assert!(narrow.bounds.width <= 80.);
|
||||
assert_eq!(narrow.bounds.size(), paragraph_size(&narrow.displayed, 1., style));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_visible_prefix_maps_to_original_bytes_without_claiming_hidden_operation() {
|
||||
let raw = " \t1. call · Pump\r\n · Operation: Need\u{301}le ";
|
||||
let expected = "1. call · Pump · Operation: Need\u{301}le";
|
||||
let n = Normalized::new(raw);
|
||||
assert_eq!(n.text, expected);
|
||||
let prefix = "1. call · Pump ";
|
||||
assert_eq!(n.source_end(prefix.len()), raw.find("· Operation").unwrap());
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let wide = cache.resolve(&session, raw, 1600., 1., Style::default()).unwrap();
|
||||
assert_eq!(wide.displayed, expected);
|
||||
assert_eq!(wide.retained_source.end, raw.trim_end().len());
|
||||
let field = raw.find("Need").unwrap()..raw.trim_end().len();
|
||||
assert!(wide.retains_any(&session, 1., field.clone()).unwrap());
|
||||
assert!(!wide.retains_any(&session, 1., 0..2).unwrap(), "normalized whitespace is not a visible field");
|
||||
let small = cache.resolve(&session, raw, 70., 1., Style::default()).unwrap();
|
||||
assert!(!small.retains_any(&session, 1., field).unwrap());
|
||||
assert!(small.retained_source.end < raw.find("Operation").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grapheme_prefixes_and_zoom_match_actual_paragraph_layout() {
|
||||
let text = "call · 戻り値 圧力 e\u{301} 👩🔬 safety valve verification";
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
for zoom in [0.66, 1., 1.75] {
|
||||
let label = cache.resolve(&session, text, 105., zoom, Style::default()).unwrap();
|
||||
assert!(label.bounds.width <= 105.);
|
||||
assert_eq!(label.bounds.size(), paragraph_size(&label.displayed, zoom, Style::default()));
|
||||
assert!(text.grapheme_indices(true).any(|(i, _)| i == label.retained_source.end)
|
||||
|| label.retained_source.end == text.len());
|
||||
let painted = label.canvas_text(&session, zoom, Point::new(400., 90.), Color::BLACK).unwrap();
|
||||
assert_eq!(painted.content, label.displayed);
|
||||
assert!(painted.max_width.is_infinite());
|
||||
assert_eq!(label.bounds_at(&session, zoom, painted.position).unwrap().size(), label.bounds.size());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_too_narrow_labels_never_claim_source_visibility() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
for text in ["", " \t\n", "Operation: Pump"] {
|
||||
let label = cache.resolve(&session, text, 0., 1., Style::default()).unwrap();
|
||||
assert_eq!(label.displayed, "");
|
||||
assert_eq!(label.retained_source, 0..0);
|
||||
assert_eq!(label.bounds.size(), Size::ZERO);
|
||||
assert!(!label.retains_any(&session, 1., 0..text.len()).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redraws_reuse_resolution_but_width_zoom_and_style_are_dependencies() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let text = "call · Pressure verification with a descriptive operation label";
|
||||
let style = Style::default();
|
||||
let a = cache.resolve(&session, text, 100., 1., style).unwrap();
|
||||
let measured = cache.allocations;
|
||||
let b = cache.resolve(&session, text, 100., 1., style).unwrap();
|
||||
assert!(Arc::ptr_eq(&a, &b));
|
||||
assert_eq!(cache.allocations, measured);
|
||||
for (width, zoom, style) in [(120., 1., style), (100., 0.66, style),
|
||||
(100., 1., Style { size: Pixels(14.), ..style })] {
|
||||
let other = cache.resolve(&session, text, width, zoom, style).unwrap();
|
||||
assert!(!Arc::ptr_eq(&a, &other));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resident_entry_is_remeasured_for_a_fresh_settled_render_session() {
|
||||
let mut cache = Cache::default();
|
||||
let old_session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let text = "call · Pressure verification";
|
||||
let a = cache.resolve(&old_session, text, 100., 1., Style::default()).unwrap();
|
||||
assert_eq!(cache.entries.len(), 1);
|
||||
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &a));
|
||||
let measured = cache.allocations;
|
||||
assert!(measured > 0);
|
||||
assert!(Arc::ptr_eq(&a, &cache.resolve(&old_session, text, 100., 1., Style::default()).unwrap()));
|
||||
assert_eq!(cache.allocations, measured);
|
||||
// A fresh renderer session invalidates a still-resident entry. This does
|
||||
// not mutate global fonts or purport to test dynamic WGPU font loading.
|
||||
let new_session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let refreshed = cache.resolve(&new_session, text, 100., 1., Style::default()).unwrap();
|
||||
assert!(!Arc::ptr_eq(&a, &refreshed));
|
||||
assert!(cache.allocations > measured);
|
||||
assert_eq!(cache.entries.len(), 1);
|
||||
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &refreshed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resident_entry_remeasures_after_local_font_epoch_invalidation() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let text = "call · Resident pressure label";
|
||||
let a = cache.resolve(&session, text, 100., 1., Style::default()).unwrap();
|
||||
assert_eq!(cache.entries.len(), 1);
|
||||
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &a));
|
||||
let measured = cache.allocations;
|
||||
assert!(measured > 0);
|
||||
// Exercise the local memo epoch branch while a is still resident. No
|
||||
// intervening width/zoom/style resolution can cause incidental eviction.
|
||||
// This is not an actual font-load or renderer-cache acceptance test.
|
||||
cache.version = None;
|
||||
let refreshed = cache.resolve(&session, text, 100., 1., Style::default()).unwrap();
|
||||
assert!(!Arc::ptr_eq(&a, &refreshed));
|
||||
assert!(cache.allocations > measured);
|
||||
assert_eq!(cache.entries.len(), 1);
|
||||
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &refreshed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_artifact_rejects_other_zoom_and_render_session_for_every_consumer() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let label = cache.resolve(&session, "Operation: Pressure", 200., 1., Style::default()).unwrap();
|
||||
let other = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
for (context, zoom, expected) in [(&session, 0.66, Error::StaleZoom), (&other, 1., Error::StaleRenderSession)] {
|
||||
assert_eq!(label.displayed(context, zoom).unwrap_err(), expected);
|
||||
assert_eq!(label.retained_source(context, zoom).unwrap_err(), expected);
|
||||
assert_eq!(label.retains_any(context, zoom, 0..9).unwrap_err(), expected);
|
||||
assert_eq!(label.bounds_at(context, zoom, Point::ORIGIN).unwrap_err(), expected);
|
||||
assert_eq!(label.canvas_text(context, zoom, Point::ORIGIN, Color::BLACK).unwrap_err(), expected);
|
||||
}
|
||||
assert!(label.canvas_text(&session, 1., Point::ORIGIN, Color::BLACK).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_scaled_metrics_cannot_escape_as_infinite_world_dimensions() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let style = Style { size: Pixels(2.), line_height: LineHeight::Relative(f32::MAX), ..Style::default() };
|
||||
// At zoom 0.5 the scaled size=1 and line height=MAX are finite. The
|
||||
// required world line height is not; reject before passing it to shaping.
|
||||
let scaled = style.scaled(0.5);
|
||||
assert!(scaled.0.is_finite() && scaled.1.is_finite());
|
||||
assert_eq!(cache.resolve(&session, "call", 100., 0.5, style).unwrap_err(), Error::InvalidMetrics);
|
||||
assert_eq!(cache.allocations, 0);
|
||||
assert!(cache.entries.is_empty());
|
||||
// Independently exercise validation after division for actual measured
|
||||
// dimensions: finite scaled width/height do not imply finite world values.
|
||||
assert_eq!(world_size(Size::new(f32::MAX, 1.), 0.5), Err(Error::InvalidMeasurement));
|
||||
assert_eq!(world_size(Size::new(1., f32::MAX), 0.5), Err(Error::InvalidMeasurement));
|
||||
assert_eq!(world_size(Size::new(20., 8.), 0.5).unwrap(), Size::new(40., 16.));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_metrics_and_explicit_experiment_budgets_fail_without_a_cached_candidate() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
for (width, zoom) in [(f32::NAN, 1.), (-1., 1.), (100., 0.), (100., f32::INFINITY)] {
|
||||
assert_eq!(cache.resolve(&session, "call", width, zoom, Style::default()).unwrap_err(), Error::InvalidMetrics);
|
||||
}
|
||||
assert_eq!(cache.resolve(&session, &"x".repeat(MAX_SOURCE_BYTES + 1), 100., 1., Style::default()).unwrap_err(), Error::SourceBudget);
|
||||
assert_eq!(cache.resolve(&session, &"x".repeat(MAX_GRAPHEMES + 1), 100., 1., Style::default()).unwrap_err(), Error::GraphemeBudget);
|
||||
assert!(cache.entries.is_empty());
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Unwired corridor-policy candidate for the measured scenario-label experiment.
|
||||
//!
|
||||
//! The caller supplies a finite structural label band AFTER endpoint/activation
|
||||
//! alignment, with padding already removed from its edges. Blockers include
|
||||
//! padded activation/card/header bounds over the complete text band, including
|
||||
//! stroke/ink clearance. This function does not infer a self-message corridor,
|
||||
//! alter scene extents, change zoom, or establish raster-ink containment.
|
||||
use iced::Rectangle;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) struct InvalidGeometry;
|
||||
|
||||
fn edges(rect: Rectangle) -> Result<(f32, f32, f32, f32), InvalidGeometry> {
|
||||
let right = rect.x + rect.width;
|
||||
let bottom = rect.y + rect.height;
|
||||
if [rect.x, rect.y, rect.width, rect.height, right, bottom]
|
||||
.into_iter()
|
||||
.any(|value| !value.is_finite())
|
||||
|| rect.width < 0.
|
||||
|| rect.height < 0.
|
||||
{
|
||||
return Err(InvalidGeometry);
|
||||
}
|
||||
Ok((rect.x, right, rect.y, bottom))
|
||||
}
|
||||
|
||||
/// Select one connected clear interval; never assemble text across blockers.
|
||||
/// Prefer greater width, then proximity to the previous label anchor, then the
|
||||
/// leftmost interval. Input order has no influence. A zero-area band has no
|
||||
/// corridor. Touching the padded vertical band is conservatively obstructing;
|
||||
/// a zero-area blocker has no area and must receive stroke padding upstream.
|
||||
pub(super) fn widest_clear(
|
||||
band: Rectangle,
|
||||
blockers: &[Rectangle],
|
||||
preferred_anchor: f32,
|
||||
) -> Result<Option<Rectangle>, InvalidGeometry> {
|
||||
let (left, right, top, bottom) = edges(band)?;
|
||||
if !preferred_anchor.is_finite() {
|
||||
return Err(InvalidGeometry);
|
||||
}
|
||||
let mut blocked = Vec::new();
|
||||
for &blocker in blockers {
|
||||
let (x0, x1, y0, y1) = edges(blocker)?;
|
||||
if blocker.width > 0. && blocker.height > 0. && y1 >= top && y0 <= bottom {
|
||||
let interval = (x0.max(left), x1.min(right));
|
||||
if interval.0 < interval.1 {
|
||||
blocked.push(interval);
|
||||
}
|
||||
}
|
||||
}
|
||||
if band.width == 0. || band.height == 0. {
|
||||
return Ok(None);
|
||||
}
|
||||
blocked.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1)));
|
||||
let mut best: Option<Rectangle> = None;
|
||||
let mut consider = |start: f32, end: f32| {
|
||||
if start >= end {
|
||||
return;
|
||||
}
|
||||
let candidate = Rectangle { x: start, width: end - start, ..band };
|
||||
// Use f64 only for comparison distance: subtracting two admitted f32
|
||||
// world coordinates can overflow even though each rectangle is finite.
|
||||
let distance = |x: f32| (f64::from(x) - f64::from(preferred_anchor)).abs();
|
||||
if best.is_none_or(|old| {
|
||||
candidate.width > old.width
|
||||
|| (candidate.width == old.width
|
||||
&& (distance(candidate.x) < distance(old.x)
|
||||
|| (distance(candidate.x) == distance(old.x) && candidate.x < old.x)))
|
||||
}) {
|
||||
best = Some(candidate);
|
||||
}
|
||||
};
|
||||
let mut cursor = left;
|
||||
for (start, end) in blocked {
|
||||
consider(cursor, start);
|
||||
cursor = cursor.max(end);
|
||||
}
|
||||
consider(cursor, right);
|
||||
Ok(best)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rect(x: f32, y: f32, width: f32, height: f32) -> Rectangle {
|
||||
Rectangle { x, y, width, height }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_message_does_not_cross_an_intermediate_execution() {
|
||||
let band = rect(20., 100., 700., 22.);
|
||||
let blockers = [rect(250., 80., 16., 80.), rect(490., 90., 28., 140.)];
|
||||
let result = widest_clear(band, &blockers, band.x).unwrap().unwrap();
|
||||
assert_eq!(result, rect(20., 100., 230., 22.));
|
||||
for blocker in blockers {
|
||||
assert!(result.x + result.width <= blocker.x || result.x >= blocker.x + blocker.width);
|
||||
}
|
||||
assert_eq!(widest_clear(band, &[], band.x).unwrap(), Some(band));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_unsorted_blockers_merge_and_widest_gap_wins() {
|
||||
let band = rect(-100., 10., 600., 20.);
|
||||
let blockers = [rect(50., 0., 80., 60.), rect(0., 5., 70., 30.), rect(450., 0., 100., 60.)];
|
||||
let expected = Some(rect(130., 10., 320., 20.));
|
||||
assert_eq!(widest_clear(band, &blockers, -100.).unwrap(), expected);
|
||||
let mut reversed = blockers;
|
||||
reversed.reverse();
|
||||
assert_eq!(widest_clear(band, &reversed, -100.).unwrap(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_padded_band_and_touching_boundaries_are_obstructed() {
|
||||
let band = rect(0., 100., 200., 20.);
|
||||
// The first blocker ends at the band top: checking only a baseline
|
||||
// would miss it. The second starts at the band bottom.
|
||||
let blockers = [rect(0., 70., 60., 30.), rect(150., 120., 50., 30.)];
|
||||
assert_eq!(widest_clear(band, &blockers, 0.).unwrap(), Some(rect(60., 100., 90., 20.)));
|
||||
let outside = [rect(0., 70., 200., 29.), rect(0., 121., 200., 30.)];
|
||||
assert_eq!(widest_clear(band, &outside, 0.).unwrap(), Some(band));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ties_keep_the_nearest_anchor_then_choose_leftmost() {
|
||||
let band = rect(0., 0., 300., 20.);
|
||||
let blockers = [rect(100., 0., 100., 20.)];
|
||||
assert_eq!(widest_clear(band, &blockers, 190.).unwrap(), Some(rect(200., 0., 100., 20.)));
|
||||
assert_eq!(widest_clear(band, &blockers, 100.).unwrap(), Some(rect(0., 0., 100., 20.)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caller_defined_self_and_reversed_corridors_keep_their_geometry() {
|
||||
// Direction and self-loop policy are explicit caller inputs. Neither
|
||||
// a zero endpoint gap nor screen width can silently supply a corridor.
|
||||
for band in [rect(420., 40., 180., 22.), rect(-400., 90., 350., 22.)] {
|
||||
assert_eq!(widest_clear(band, &[], band.x).unwrap(), Some(band));
|
||||
}
|
||||
assert_eq!(widest_clear(rect(420., 40., 0., 22.), &[], 420.).unwrap(), None);
|
||||
assert_eq!(widest_clear(rect(420., 40., 180., 0.), &[], 420.).unwrap(), None);
|
||||
assert_eq!(widest_clear(rect(0., 0., 100., 20.), &[rect(-10., -10., 200., 50.)], 0.).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_or_overflowing_geometry_never_enters_a_corridor() {
|
||||
let band = rect(0., 0., 100., 20.);
|
||||
for invalid in [rect(f32::NAN, 0., 10., 20.), rect(0., 0., -1., 20.),
|
||||
rect(f32::MAX, 0., f32::MAX, 20.), rect(0., f32::MAX, 10., f32::MAX)] {
|
||||
assert_eq!(widest_clear(invalid, &[], 0.), Err(InvalidGeometry));
|
||||
assert_eq!(widest_clear(band, &[invalid], 0.), Err(InvalidGeometry));
|
||||
}
|
||||
assert_eq!(widest_clear(band, &[], f32::INFINITY), Err(InvalidGeometry));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[
|
||||
{
|
||||
"name": "scenario_label.rs",
|
||||
"bytes": 13548,
|
||||
"sha256": "b452249c45b1225760d1b9bd8f2d9241c4d599e8a68d7328641d61381c28cbbd"
|
||||
},
|
||||
{
|
||||
"name": "tests.rs",
|
||||
"bytes": 15683,
|
||||
"sha256": "1af0115850ae826f3af211499006c26bd290c17cc186d08b41be0ee40981a83b"
|
||||
},
|
||||
{
|
||||
"name": "corridor.rs",
|
||||
"bytes": 6657,
|
||||
"sha256": "94747ab1b161564c8901f3d0702034e43f14f263decd8e7b823c801fbcb80dd7"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,281 @@
|
||||
//! 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())))
|
||||
}
|
||||
|
||||
/// 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<Rectangle, Error> {
|
||||
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<Text, Error> {
|
||||
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<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)
|
||||
}
|
||||
|
||||
fn translated_bounds(position: Point, size: Size) -> Result<Rectangle, Error> {
|
||||
if !position.x.is_finite() || !position.y.is_finite()
|
||||
|| !(position.x + size.width).is_finite() || !(position.y + size.height).is_finite() {
|
||||
return Err(Error::InvalidPosition);
|
||||
}
|
||||
Ok(Rectangle::new(position, size))
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -0,0 +1,271 @@
|
||||
//! Unexecuted evaluator candidates; the module is intentionally not wired yet.
|
||||
use super::*;
|
||||
use iced::advanced::{graphics::text::Paragraph, text::{Paragraph as _, Text as ParagraphText, Wrapping}};
|
||||
|
||||
// Independent paragraph construction: no candidate cache, normalization, search,
|
||||
// or character-width estimate is used by this measurement oracle.
|
||||
fn paragraph_size(content: &str, zoom: f32, style: Style) -> Size {
|
||||
let size = Pixels(style.size.0 * zoom);
|
||||
let line_height = match style.line_height {
|
||||
LineHeight::Relative(factor) => LineHeight::Relative(factor),
|
||||
LineHeight::Absolute(pixels) => LineHeight::Absolute(Pixels(pixels.0 * zoom)),
|
||||
};
|
||||
let paragraph = Paragraph::with_text(ParagraphText { content, bounds: Size::INFINITE,
|
||||
size, line_height, font: style.font, align_x: Alignment::Default,
|
||||
align_y: iced::alignment::Vertical::Top, shaping: style.shaping, wrapping: Wrapping::default() });
|
||||
assert_eq!(paragraph.buffer().layout_runs().count(), 1);
|
||||
Size::new(paragraph.min_width() / zoom, paragraph.min_height() / zoom)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adequate_corridor_reveals_full_realistic_names_and_distinguishing_suffixes() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let style = Style::default();
|
||||
for text in [
|
||||
"1. call · Request valve repair · Technician service conversation",
|
||||
"2. reply · Return pressure after repair · Technician service conversation",
|
||||
"1. call · Request redundant hydraulic pressure calibration channel A",
|
||||
"1. call · Request redundant hydraulic pressure calibration channel B",
|
||||
] {
|
||||
let wide = cache.resolve(&session, text, 1600., 1., style).unwrap();
|
||||
assert_eq!(wide.displayed(&session, 1.).unwrap(), text);
|
||||
assert_eq!(wide.retained_source(&session, 1.).unwrap(), 0..text.len());
|
||||
assert_eq!(wide.bounds.size(), paragraph_size(text, 1., style));
|
||||
let narrow = cache.resolve(&session, text, 80., 1., style).unwrap();
|
||||
assert_ne!(narrow.displayed, text);
|
||||
assert!(narrow.bounds.width <= 80.);
|
||||
assert_eq!(narrow.bounds.size(), paragraph_size(&narrow.displayed, 1., style));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_visible_prefix_maps_to_original_bytes_without_claiming_hidden_operation() {
|
||||
let raw = " \t1. call · Pump\r\n · Operation: Need\u{301}le ";
|
||||
let expected = "1. call · Pump · Operation: Need\u{301}le";
|
||||
let n = Normalized::new(raw);
|
||||
assert_eq!(n.text, expected);
|
||||
let prefix = "1. call · Pump ";
|
||||
assert_eq!(n.source_end(prefix.len()), raw.find("· Operation").unwrap());
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let wide = cache.resolve(&session, raw, 1600., 1., Style::default()).unwrap();
|
||||
assert_eq!(wide.displayed, expected);
|
||||
assert_eq!(wide.retained_source.end, raw.trim_end().len());
|
||||
let field = raw.find("Need").unwrap()..raw.trim_end().len();
|
||||
assert!(wide.retains_any(&session, 1., field.clone()).unwrap());
|
||||
assert!(!wide.retains_any(&session, 1., 0..2).unwrap(), "normalized whitespace is not a visible field");
|
||||
let small = cache.resolve(&session, raw, 70., 1., Style::default()).unwrap();
|
||||
assert!(!small.retains_any(&session, 1., field).unwrap());
|
||||
assert!(small.retained_source.end < raw.find("Operation").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grapheme_prefixes_and_zoom_match_actual_paragraph_layout() {
|
||||
let text = "call · 戻り値 圧力 e\u{301} 👩🔬 safety valve verification";
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
for zoom in [0.66, 1., 1.75] {
|
||||
let label = cache.resolve(&session, text, 105., zoom, Style::default()).unwrap();
|
||||
assert!(label.bounds.width <= 105.);
|
||||
assert_eq!(label.bounds.size(), paragraph_size(&label.displayed, zoom, Style::default()));
|
||||
assert!(text.grapheme_indices(true).any(|(i, _)| i == label.retained_source.end)
|
||||
|| label.retained_source.end == text.len());
|
||||
let painted = label.canvas_text(&session, zoom, Point::new(400., 90.), Color::BLACK).unwrap();
|
||||
assert_eq!(painted.content, label.displayed);
|
||||
assert!(painted.max_width.is_infinite());
|
||||
assert_eq!(label.bounds_at(&session, zoom, painted.position).unwrap().size(), label.bounds.size());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_too_narrow_labels_never_claim_source_visibility() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
for text in ["", " \t\n", "Operation: Pump"] {
|
||||
let label = cache.resolve(&session, text, 0., 1., Style::default()).unwrap();
|
||||
assert_eq!(label.displayed, "");
|
||||
assert_eq!(label.retained_source, 0..0);
|
||||
assert_eq!(label.bounds.size(), Size::ZERO);
|
||||
assert!(!label.retains_any(&session, 1., 0..text.len()).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redraws_reuse_resolution_but_width_zoom_and_style_are_dependencies() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let text = "call · Pressure verification with a descriptive operation label";
|
||||
let style = Style::default();
|
||||
let a = cache.resolve(&session, text, 100., 1., style).unwrap();
|
||||
let measured = cache.allocations;
|
||||
let b = cache.resolve(&session, text, 100., 1., style).unwrap();
|
||||
assert!(Arc::ptr_eq(&a, &b));
|
||||
assert_eq!(cache.allocations, measured);
|
||||
for (width, zoom, style) in [(120., 1., style), (100., 0.66, style),
|
||||
(100., 1., Style { size: Pixels(14.), ..style })] {
|
||||
let other = cache.resolve(&session, text, width, zoom, style).unwrap();
|
||||
assert!(!Arc::ptr_eq(&a, &other));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resident_entry_is_remeasured_for_a_fresh_settled_render_session() {
|
||||
let mut cache = Cache::default();
|
||||
let old_session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let text = "call · Pressure verification";
|
||||
let a = cache.resolve(&old_session, text, 100., 1., Style::default()).unwrap();
|
||||
assert_eq!(cache.entries.len(), 1);
|
||||
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &a));
|
||||
let measured = cache.allocations;
|
||||
assert!(measured > 0);
|
||||
assert!(Arc::ptr_eq(&a, &cache.resolve(&old_session, text, 100., 1., Style::default()).unwrap()));
|
||||
assert_eq!(cache.allocations, measured);
|
||||
// A fresh renderer session invalidates a still-resident entry. This does
|
||||
// not mutate global fonts or purport to test dynamic WGPU font loading.
|
||||
let new_session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let refreshed = cache.resolve(&new_session, text, 100., 1., Style::default()).unwrap();
|
||||
assert!(!Arc::ptr_eq(&a, &refreshed));
|
||||
assert!(cache.allocations > measured);
|
||||
assert_eq!(cache.entries.len(), 1);
|
||||
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &refreshed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resident_entry_remeasures_after_local_font_epoch_invalidation() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let text = "call · Resident pressure label";
|
||||
let a = cache.resolve(&session, text, 100., 1., Style::default()).unwrap();
|
||||
assert_eq!(cache.entries.len(), 1);
|
||||
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &a));
|
||||
let measured = cache.allocations;
|
||||
assert!(measured > 0);
|
||||
// Exercise the local memo epoch branch while a is still resident. No
|
||||
// intervening width/zoom/style resolution can cause incidental eviction.
|
||||
// This is not an actual font-load or renderer-cache acceptance test.
|
||||
cache.version = None;
|
||||
let refreshed = cache.resolve(&session, text, 100., 1., Style::default()).unwrap();
|
||||
assert!(!Arc::ptr_eq(&a, &refreshed));
|
||||
assert!(cache.allocations > measured);
|
||||
assert_eq!(cache.entries.len(), 1);
|
||||
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &refreshed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_artifact_rejects_other_zoom_and_render_session_for_every_consumer() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let label = cache.resolve(&session, "Operation: Pressure", 200., 1., Style::default()).unwrap();
|
||||
let other = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
for (context, zoom, expected) in [(&session, 0.66, Error::StaleZoom), (&other, 1., Error::StaleRenderSession)] {
|
||||
assert_eq!(label.displayed(context, zoom).unwrap_err(), expected);
|
||||
assert_eq!(label.retained_source(context, zoom).unwrap_err(), expected);
|
||||
assert_eq!(label.retains_any(context, zoom, 0..9).unwrap_err(), expected);
|
||||
assert_eq!(label.bounds_at(context, zoom, Point::ORIGIN).unwrap_err(), expected);
|
||||
assert_eq!(label.canvas_text(context, zoom, Point::ORIGIN, Color::BLACK).unwrap_err(), expected);
|
||||
}
|
||||
assert!(label.canvas_text(&session, 1., Point::ORIGIN, Color::BLACK).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_scaled_metrics_cannot_escape_as_infinite_world_dimensions() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let style = Style { size: Pixels(2.), line_height: LineHeight::Relative(f32::MAX), ..Style::default() };
|
||||
// At zoom 0.5 the scaled size=1 and line height=MAX are finite. The
|
||||
// required world line height is not; reject before passing it to shaping.
|
||||
let scaled = style.scaled(0.5);
|
||||
assert!(scaled.0.is_finite() && scaled.1.is_finite());
|
||||
assert_eq!(cache.resolve(&session, "call", 100., 0.5, style).unwrap_err(), Error::InvalidMetrics);
|
||||
assert_eq!(cache.allocations, 0);
|
||||
assert!(cache.entries.is_empty());
|
||||
// Independently exercise validation after division for actual measured
|
||||
// dimensions: finite scaled width/height do not imply finite world values.
|
||||
assert_eq!(world_size(Size::new(f32::MAX, 1.), 0.5), Err(Error::InvalidMeasurement));
|
||||
assert_eq!(world_size(Size::new(1., f32::MAX), 0.5), Err(Error::InvalidMeasurement));
|
||||
assert_eq!(world_size(Size::new(20., 8.), 0.5).unwrap(), Size::new(40., 16.));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_metrics_and_explicit_experiment_budgets_fail_without_a_cached_candidate() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
for (width, zoom) in [(f32::NAN, 1.), (-1., 1.), (100., 0.), (100., f32::INFINITY)] {
|
||||
assert_eq!(cache.resolve(&session, "call", width, zoom, Style::default()).unwrap_err(), Error::InvalidMetrics);
|
||||
}
|
||||
assert_eq!(cache.resolve(&session, &"x".repeat(MAX_SOURCE_BYTES + 1), 100., 1., Style::default()).unwrap_err(), Error::SourceBudget);
|
||||
assert_eq!(cache.resolve(&session, &"x".repeat(MAX_GRAPHEMES + 1), 100., 1., Style::default()).unwrap_err(), Error::GraphemeBudget);
|
||||
assert!(cache.entries.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_translation_rejects_nonfinite_world_positions_and_endpoints() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let label = cache.resolve(&session, "call", 100., 1., Style::default()).unwrap();
|
||||
for position in [Point::new(f32::NAN, 0.), Point::new(0., f32::INFINITY)] {
|
||||
assert_eq!(label.bounds_at(&session, 1., position).unwrap_err(), Error::InvalidPosition);
|
||||
assert_eq!(label.canvas_text(&session, 1., position, Color::BLACK).unwrap_err(), Error::InvalidPosition);
|
||||
}
|
||||
assert_eq!(translated_bounds(Point::new(f32::MAX, 0.), Size::new(f32::MAX, 1.)), Err(Error::InvalidPosition));
|
||||
assert_eq!(translated_bounds(Point::new(0., f32::MAX), Size::new(1., f32::MAX)), Err(Error::InvalidPosition));
|
||||
assert_eq!(label.bounds_at(&session, 1., Point::new(-20., -10.)).unwrap().position(), Point::new(-20., -10.));
|
||||
}
|
||||
|
||||
/// Must run as the ONLY selected test in a dedicated, fresh test process.
|
||||
/// The ignored marker keeps this global-font mutation out of normal parallel
|
||||
/// desktop tests. See the evidence directory's exact executable runner.
|
||||
#[test]
|
||||
#[ignore = "mutates the global font database; run only this exact test in a fresh process"]
|
||||
fn actual_font_load_rejects_retained_label_and_old_session() {
|
||||
use std::borrow::Cow;
|
||||
use graphics_text::cosmic_text::fontdb;
|
||||
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let source = "Operation: Pressure";
|
||||
let label = cache.resolve(&session, source, 200., 1., Style::default()).unwrap();
|
||||
assert_eq!(label.displayed(&session, 1.).unwrap(), source);
|
||||
assert!(label.canvas_text(&session, 1., Point::ORIGIN, Color::BLACK).is_ok());
|
||||
let measured = cache.allocations;
|
||||
assert!(measured > 0);
|
||||
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &label));
|
||||
|
||||
let (version_before, version_after, faces_before, faces_after) = {
|
||||
let mut fonts = graphics_text::font_system().write().unwrap();
|
||||
let face = fonts.raw().db().faces()
|
||||
.find(|face| face.families.iter().any(|(family, _)| family == "Iced-Icons"))
|
||||
.expect("Iced 0.14 embeds and loads its known Iced-Icons.ttf face").id;
|
||||
let bytes = fonts.raw().db().with_face_data(face, |data, _| data.to_vec())
|
||||
.expect("the built-in binary font has accessible bytes");
|
||||
assert!(!bytes.is_empty());
|
||||
// load_font increments Version even for invalid bytes. Independently
|
||||
// establish actual face admission so this cannot pass with invalid data.
|
||||
let mut probe = fontdb::Database::new();
|
||||
probe.load_font_data(bytes.clone());
|
||||
let admitted = probe.faces().filter(|face|
|
||||
face.families.iter().any(|(family, _)| family == "Iced-Icons")).count();
|
||||
assert!(admitted > 0, "the extracted bytes parse as the expected real font");
|
||||
let before = fonts.version();
|
||||
assert_eq!(before, session.version);
|
||||
let count = fonts.raw().db().faces().count();
|
||||
// Owned bytes intentionally bypass Borrowed-address deduplication.
|
||||
// This is an actual valid duplicate-face load, not a claim the chosen
|
||||
// face or rendered metrics change; the stale-epoch rule is the oracle.
|
||||
fonts.load_font(Cow::Owned(bytes));
|
||||
(before, fonts.version(), count, fonts.raw().db().faces().count())
|
||||
};
|
||||
assert_ne!(version_before, version_after);
|
||||
assert!(faces_after > faces_before, "actual font admission must accompany the version increment");
|
||||
assert_eq!(label.displayed(&session, 1.).unwrap_err(), Error::FontSetChanged);
|
||||
assert_eq!(label.retained_source(&session, 1.).unwrap_err(), Error::FontSetChanged);
|
||||
assert_eq!(label.retains_any(&session, 1., 0..9).unwrap_err(), Error::FontSetChanged);
|
||||
assert_eq!(label.bounds_at(&session, 1., Point::ORIGIN).unwrap_err(), Error::FontSetChanged);
|
||||
assert_eq!(label.canvas_text(&session, 1., Point::ORIGIN, Color::BLACK).unwrap_err(), Error::FontSetChanged);
|
||||
assert_eq!(cache.resolve(&session, source, 200., 1., Style::default()).unwrap_err(), Error::FontSetChanged);
|
||||
assert_eq!(cache.allocations, measured, "an invalid session must not remeasure and imply renderer repair");
|
||||
println!("Valid Iced-Icons load: faces {faces_before}->{faces_after}, font Version {version_before:?}->{version_after:?}; old label and session rejected");
|
||||
// Do not create a fresh session or claim dynamic WGPU cache recovery here.
|
||||
// Process exit is the isolation boundary; no other test may run afterward.
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
# Runs an ALREADY BUILT desktop test executable. This script never invokes Cargo.
|
||||
# Supply the matching runtime DLL environment prepared by the compiler owner.
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][string]$TestExecutable,
|
||||
[Parameter(Mandatory=$true)][ValidatePattern('^[0-9a-fA-F]{64}$')][string]$ExpectedSha256,
|
||||
[Parameter(Mandatory=$true)][string]$RunDirectory,
|
||||
[ValidateSet('behavior::scenario_label::tests::actual_font_load_rejects_retained_label_and_old_session', 'scenario_label::tests::actual_font_load_rejects_retained_label_and_old_session')]
|
||||
[string]$TestName = 'behavior::scenario_label::tests::actual_font_load_rejects_retained_label_and_old_session'
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$fontTestName = $TestName
|
||||
$fontTestExe = (Resolve-Path -LiteralPath $TestExecutable).Path
|
||||
$fontTestHash = (Get-FileHash -LiteralPath $fontTestExe -Algorithm SHA256).Hash
|
||||
if ($fontTestHash -ne $ExpectedSha256) { throw 'Test executable SHA256 differs from the supplied compiler receipt' }
|
||||
if (Test-Path -LiteralPath $RunDirectory) { throw 'Use a fresh evidence directory; refusing to overwrite a previous run' }
|
||||
$fontTestOutput = (New-Item -ItemType Directory -Path $RunDirectory).FullName
|
||||
|
||||
# Listing is a separate process and runs no test. Refuse zero/multiple matches
|
||||
# before launching the one process that will mutate global font state.
|
||||
$fontTestList = & $fontTestExe --list --ignored --exact $fontTestName 2>&1
|
||||
$fontTestListExit = $LASTEXITCODE
|
||||
$fontTestListText = $fontTestList | Out-String
|
||||
$fontTestListText | Set-Content -LiteralPath (Join-Path $fontTestOutput 'selection.log') -Encoding utf8
|
||||
$fontTestSelected = $fontTestListExit -eq 0 -and
|
||||
$fontTestListText -match ('(?m)^' + [regex]::Escape($fontTestName) + ': test\s*$') -and
|
||||
$fontTestListText -match '(?m)^1 test, 0 benchmarks\s*$'
|
||||
if (-not $fontTestSelected) { throw 'The exact ignored-test selection did not list exactly one test; inspect selection.log' }
|
||||
|
||||
# Fresh dedicated executable invocation; no normal/other ignored test is chosen.
|
||||
$fontTestStarted = (Get-Date).ToUniversalTime().ToString('o')
|
||||
$fontTestRun = & $fontTestExe --ignored --exact $fontTestName --nocapture --test-threads=1 2>&1
|
||||
$fontTestExit = $LASTEXITCODE
|
||||
$fontTestRunText = $fontTestRun | Out-String
|
||||
$fontTestRunText | Set-Content -LiteralPath (Join-Path $fontTestOutput 'test.log') -Encoding utf8
|
||||
$fontTestPassed = $fontTestExit -eq 0 -and
|
||||
$fontTestRunText -match 'test result: ok\. 1 passed; 0 failed; 0 ignored;'
|
||||
@{
|
||||
test = $fontTestName
|
||||
executable = $fontTestExe
|
||||
sha256 = $fontTestHash
|
||||
exactSelection = $fontTestSelected
|
||||
startedUtc = $fontTestStarted
|
||||
finishedUtc = (Get-Date).ToUniversalTime().ToString('o')
|
||||
exitCode = $fontTestExit
|
||||
passedExactlyOne = $fontTestPassed
|
||||
scope = 'Actual global font Version invalidation and artifact rejection; no WGPU dynamic cache recovery claim'
|
||||
} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $fontTestOutput 'receipt.json') -Encoding utf8
|
||||
Write-Output $fontTestRunText
|
||||
if (-not $fontTestPassed) { throw 'Isolated font-version test did not pass exactly one test; inspect test.log and receipt.json' }
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Isolated evaluator only: does not wire the desktop renderer.
|
||||
#[path = "../../../crates/oa-desktop/src/behavior/scenario_label.rs"]
|
||||
mod scenario_label;
|
||||
#[path = "../../../crates/oa-desktop/src/behavior/scenario_label/corridor.rs"]
|
||||
mod scenario_label_corridor;
|
||||
Reference in New Issue
Block a user