From c3ad6f130ba6dda271fe8417db46df29e4033ad8 Mon Sep 17 00:00:00 2001 From: Codex-ytr83r Date: Sun, 13 Sep 2026 02:50:52 -0700 Subject: [PATCH] Experiment with unwired shaped label field coverage Preserve winning-buffer cluster geometry and exact scalar mappings. Distinguish truncated source from unresolved glyph coverage, keep ellipsis unmapped, and add ten precondition-asserted field evaluators. Source-only self-review and diff check; not formatted, compiled or executed. No production module wiring or navigation policy adoption. --- .../oa-desktop/src/behavior/scenario_label.rs | 205 +++++++++++++++- .../behavior/scenario_label/field_tests.rs | 230 ++++++++++++++++++ 2 files changed, 433 insertions(+), 2 deletions(-) create mode 100644 crates/oa-desktop/src/behavior/scenario_label/field_tests.rs diff --git a/crates/oa-desktop/src/behavior/scenario_label.rs b/crates/oa-desktop/src/behavior/scenario_label.rs index b707789e..0565fdef 100644 --- a/crates/oa-desktop/src/behavior/scenario_label.rs +++ b/crates/oa-desktop/src/behavior/scenario_label.rs @@ -18,6 +18,9 @@ //! 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. +//! Field geometry is a second unwired experiment: exact retained source spans +//! and winning-buffer cluster boxes, with unresolved glyph coverage distinct +//! from truncation. It does not adopt a production navigation policy. use iced::advanced::{ graphics::text as graphics_text, text::{Alignment, LineHeight, Shaping}, @@ -94,9 +97,121 @@ pub(super) struct Label { style: Style, measured_session: RenderSession, measured_zoom: f32, + // Snapshot of the final displayed Cache entry, after alignment. These are + // cluster advance boxes, never glyph ink or inferred character cells. + clusters: Vec, + source_spans: Vec, + retained_normalized: usize, +} + +#[derive(Clone, Debug, PartialEq)] +pub(super) struct FieldLayout { + pub rectangles: Vec, + pub covered: Vec>, + pub truncated: Vec>, + pub unresolved: Vec, + pub partially_retained: bool, + pub shared_cluster: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum FieldIssue { + MissingGlyph, + NoAdvanceGeometry, + NonPaintingScalar, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct UnresolvedField { + pub source: Range, + pub reason: FieldIssue, +} + +#[derive(Clone, Debug)] +struct SourceSpan { + source: Range, + normalized: Range, + whitespace: bool, + non_painting: bool, +} + +#[derive(Clone, Debug)] +struct FieldCluster { + normalized: Range, + rectangles: Vec, + missing: bool, } impl Label { + /// Geometry and coverage are separate: truncation is not missing-font + /// coverage, and a positive .notdef advance is not represented field text. + /// Shared ligatures use whole cluster boxes; no sub-cluster fraction is + /// invented. Returned ranges refer only to the requested original source. + pub fn field_layout( + &self, + session: &RenderSession, + zoom: f32, + position: Point, + field: Range, + ) -> Result { + self.check(session, zoom)?; + translated_bounds(position, Size::ZERO)?; + if field.start > field.end || self.source.get(field.clone()).is_none() { + return Err(Error::InvalidFieldRange); + } + let mut result = FieldLayout { + rectangles: vec![], covered: vec![], truncated: vec![], + unresolved: vec![], partially_retained: false, shared_cluster: false, + }; + let mut selected = vec![false; self.clusters.len()]; + for span in &self.source_spans { + if span.whitespace || !overlaps(&span.source, &field) { continue; } + // Source spans for text are exact UTF-8 scalars, not intervals + // between ends (which could assign trimmed whitespace to text). + if span.normalized.end > self.retained_normalized { + append_range(&mut result.truncated, span.source.clone()); + continue; + } + let matching: Vec<_> = self.clusters.iter().enumerate() + .filter(|(_, cluster)| overlaps(&cluster.normalized, &span.normalized)) + .collect(); + result.shared_cluster |= matching.iter().any(|(_, cluster)| { + cluster.normalized.end > self.retained_normalized + || self.source_spans.iter().any(|other| { + overlaps(&other.normalized, &cluster.normalized) + && !overlaps(&other.source, &field) + }) + }); + let issue = if span.non_painting { + Some(FieldIssue::NonPaintingScalar) + } else if matching.iter().any(|(_, cluster)| cluster.missing) { + Some(FieldIssue::MissingGlyph) + } else if matching.is_empty() || matching.iter().all(|(_, cluster)| cluster.rectangles.is_empty()) { + Some(FieldIssue::NoAdvanceGeometry) + } else { None }; + if let Some(reason) = issue { + result.unresolved.push(UnresolvedField { source: span.source.clone(), reason }); + continue; + } + append_range(&mut result.covered, span.source.clone()); + for (index, _) in matching { + selected[index] = true; + } + } + for (cluster, selected) in self.clusters.iter().zip(selected) { + if !selected { continue; } + for bounds in &cluster.rectangles { + let translated = translated_bounds( + Point::new(position.x + bounds.x, position.y + bounds.y), bounds.size(), + )?; + if !result.rectangles.contains(&translated) { result.rectangles.push(translated); } + } + } + result.partially_retained = !result.truncated.is_empty() + && (!result.covered.is_empty() || !result.unresolved.is_empty()); + Ok(result) + } + fn check(&self, session: &RenderSession, zoom: f32) -> Result<(), Error> { if !Arc::ptr_eq(&self.measured_session.identity, &session.identity) { return Err(Error::StaleRenderSession); @@ -194,6 +309,7 @@ pub(super) enum Error { StaleZoom, StaleRenderSession, FontSetChanged, + InvalidFieldRange, } struct Entry { @@ -286,8 +402,8 @@ impl Cache { 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. + // Candidate buffers are temporary. The winning strings, metrics, source + // spans and cluster boxes survive; renderer caching remains independent. let mut shapes = graphics_text::Cache::new(); let mut measure = |content: &str| -> Result { if content.is_empty() { @@ -345,6 +461,16 @@ impl Cache { best } }; + let clusters = if displayed.is_empty() { + // measure("") deliberately creates no cache entry. + vec![] + } else { + let (_, entry) = shapes.allocate(fonts.raw(), graphics_text::cache::Key { + content: &displayed, size, line_height, font: style.font, + bounds: Size::INFINITE, shaping: style.shaping, align_x: Alignment::Default, + }); + field_clusters(entry, zoom)? + }; let label = Arc::new(Label { source: source.into(), displayed, @@ -353,6 +479,9 @@ impl Cache { style, measured_session: session.clone(), measured_zoom: zoom, + clusters, + source_spans: normalized.spans, + retained_normalized: retained, }); if self.entries.len() == MAX_ENTRIES { self.entries.pop_front(); @@ -394,12 +523,14 @@ fn translated_bounds(position: Point, size: Size) -> Result { struct Normalized { text: String, ends: Vec<(usize, usize)>, + spans: Vec, } impl Normalized { fn new(source: &str) -> Self { let mut result = Self { text: String::new(), ends: vec![(0, 0)], + spans: vec![], }; let mut separator = false; for (start, c) in source.char_indices() { @@ -408,11 +539,23 @@ impl Normalized { continue; } if separator { + let normalized_start = result.text.len(); result.text.push(' '); + result.spans.push(SourceSpan { + source: result.ends.last().expect("initial boundary").1..start, + normalized: normalized_start..result.text.len(), + whitespace: true, non_painting: false, + }); result.ends.push((result.text.len(), start)); separator = false; } + let normalized_start = result.text.len(); result.text.push(c); + result.spans.push(SourceSpan { + source: start..start + c.len_utf8(), + normalized: normalized_start..result.text.len(), + whitespace: false, non_painting: non_painting_scalar(c), + }); result.ends.push((result.text.len(), start + c.len_utf8())); } result @@ -425,6 +568,64 @@ impl Normalized { } } +fn overlaps(a: &Range, b: &Range) -> bool { + a.start < a.end && b.start < b.end && a.start < b.end && b.start < a.end +} + +fn append_range(ranges: &mut Vec>, next: Range) { + if let Some(last) = ranges.last_mut() + && last.end == next.start { + last.end = next.end; + return; + } + ranges.push(next); +} + +// Conservative non-membership for controls/default-ignorables, even when a +// shaper assigns them a neighbor's positive-width cluster. Unicode 14 ranges +// documented in pinned harfrust 0.3.2 src/hb/unicode.rs:615–650. Include its +// spacing-filler exceptions conservatively; this is not an ink classifier. +fn non_painting_scalar(c: char) -> bool { + c.is_control() || matches!(c as u32, + 0x00ad | 0x034f | 0x061c | 0x115f..=0x1160 | 0x17b4..=0x17b5 | + 0x180b..=0x180f | 0x200b..=0x200f | 0x202a..=0x202e | 0x2060..=0x206f | + 0x3164 | 0xfe00..=0xfe0f | 0xfeff | 0xffa0 | 0xfff0..=0xfff8 | + 0x1bca0..=0x1bca3 | 0x1d173..=0x1d17a | 0xe0000..=0xe0fff) +} + +fn field_clusters(entry: &graphics_text::cache::Entry, zoom: f32) -> Result, Error> { + let mut clusters: std::collections::BTreeMap<(usize, usize), FieldCluster> = Default::default(); + for run in entry.buffer.layout_runs() { + for glyph in run.glyphs { + if glyph.start >= glyph.end || run.text.get(glyph.start..glyph.end).is_none() { + return Err(Error::InvalidMeasurement); + } + let cluster = clusters.entry((glyph.start, glyph.end)).or_insert_with(|| FieldCluster { + normalized: glyph.start..glyph.end, rectangles: vec![], missing: false, + }); + cluster.missing |= glyph.glyph_id == 0; + let x1 = glyph.x / zoom; + let x2 = (glyph.x + glyph.w) / zoom; + let y = (run.line_top + glyph.y) / zoom; + let height = glyph.line_height_opt.unwrap_or(run.line_height) / zoom; + if !x1.is_finite() || !x2.is_finite() || !y.is_finite() + || !height.is_finite() || height < 0. || !(y + height).is_finite() { + return Err(Error::InvalidMeasurement); + } + if x1 != x2 && height > 0. { + let bounds = Rectangle::new(Point::new(x1.min(x2), y), Size::new((x2 - x1).abs(), height)); + if !bounds.width.is_finite() { return Err(Error::InvalidMeasurement); } + if !cluster.rectangles.contains(&bounds) { cluster.rectangles.push(bounds); } + } + } + } + Ok(clusters.into_values().collect()) +} + #[cfg(test)] #[path = "scenario_label/tests.rs"] mod tests; + +#[cfg(test)] +#[path = "scenario_label/field_tests.rs"] +mod field_tests; diff --git a/crates/oa-desktop/src/behavior/scenario_label/field_tests.rs b/crates/oa-desktop/src/behavior/scenario_label/field_tests.rs new file mode 100644 index 00000000..bea97253 --- /dev/null +++ b/crates/oa-desktop/src/behavior/scenario_label/field_tests.rs @@ -0,0 +1,230 @@ +//! Unwired, unexecuted field-layout falsifiers. Paragraph is an independent +//! shaping oracle; it never invokes the candidate normalization/extraction. +use super::*; +use iced::advanced::{ + graphics::text::Paragraph, + text::{Paragraph as _, Text as ParagraphText, Wrapping}, +}; + +fn paragraph(text: &str, zoom: f32, style: Style) -> Paragraph { + Paragraph::with_text(ParagraphText { + content: text, bounds: Size::INFINITE, size: Pixels(style.size.0 * zoom), + line_height: match style.line_height { + LineHeight::Relative(value) => LineHeight::Relative(value), + LineHeight::Absolute(value) => LineHeight::Absolute(Pixels(value.0 * zoom)), + }, + font: style.font, align_x: Alignment::Default, + align_y: iced::alignment::Vertical::Top, shaping: style.shaping, + wrapping: Wrapping::default(), + }) +} + +fn resolve(text: &str, width: f32, zoom: f32, style: Style) -> (RenderSession, Arc