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.
This commit is contained in:
@@ -18,6 +18,9 @@
|
|||||||
//! It measures layout advances, not raster ink; overhang/clip/activation-border
|
//! It measures layout advances, not raster ink; overhang/clip/activation-border
|
||||||
//! acceptance still requires the real WGPU evaluator. The old 32-grapheme/225px
|
//! acceptance still requires the real WGPU evaluator. The old 32-grapheme/225px
|
||||||
//! renderer is deliberately untouched for a later baseline comparison.
|
//! 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::{
|
use iced::advanced::{
|
||||||
graphics::text as graphics_text,
|
graphics::text as graphics_text,
|
||||||
text::{Alignment, LineHeight, Shaping},
|
text::{Alignment, LineHeight, Shaping},
|
||||||
@@ -94,9 +97,121 @@ pub(super) struct Label {
|
|||||||
style: Style,
|
style: Style,
|
||||||
measured_session: RenderSession,
|
measured_session: RenderSession,
|
||||||
measured_zoom: f32,
|
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<FieldCluster>,
|
||||||
|
source_spans: Vec<SourceSpan>,
|
||||||
|
retained_normalized: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub(super) struct FieldLayout {
|
||||||
|
pub rectangles: Vec<Rectangle>,
|
||||||
|
pub covered: Vec<Range<usize>>,
|
||||||
|
pub truncated: Vec<Range<usize>>,
|
||||||
|
pub unresolved: Vec<UnresolvedField>,
|
||||||
|
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<usize>,
|
||||||
|
pub reason: FieldIssue,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct SourceSpan {
|
||||||
|
source: Range<usize>,
|
||||||
|
normalized: Range<usize>,
|
||||||
|
whitespace: bool,
|
||||||
|
non_painting: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct FieldCluster {
|
||||||
|
normalized: Range<usize>,
|
||||||
|
rectangles: Vec<Rectangle>,
|
||||||
|
missing: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Label {
|
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<usize>,
|
||||||
|
) -> Result<FieldLayout, Error> {
|
||||||
|
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> {
|
fn check(&self, session: &RenderSession, zoom: f32) -> Result<(), Error> {
|
||||||
if !Arc::ptr_eq(&self.measured_session.identity, &session.identity) {
|
if !Arc::ptr_eq(&self.measured_session.identity, &session.identity) {
|
||||||
return Err(Error::StaleRenderSession);
|
return Err(Error::StaleRenderSession);
|
||||||
@@ -194,6 +309,7 @@ pub(super) enum Error {
|
|||||||
StaleZoom,
|
StaleZoom,
|
||||||
StaleRenderSession,
|
StaleRenderSession,
|
||||||
FontSetChanged,
|
FontSetChanged,
|
||||||
|
InvalidFieldRange,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Entry {
|
struct Entry {
|
||||||
@@ -286,8 +402,8 @@ impl Cache {
|
|||||||
if cuts.len().saturating_sub(1) > MAX_GRAPHEMES {
|
if cuts.len().saturating_sub(1) > MAX_GRAPHEMES {
|
||||||
return Err(Error::GraphemeBudget);
|
return Err(Error::GraphemeBudget);
|
||||||
}
|
}
|
||||||
// Candidate buffers are temporary. Only the resolved strings/metrics
|
// Candidate buffers are temporary. The winning strings, metrics, source
|
||||||
// survive, bounded by MAX_ENTRIES; renderer caching remains independent.
|
// spans and cluster boxes survive; renderer caching remains independent.
|
||||||
let mut shapes = graphics_text::Cache::new();
|
let mut shapes = graphics_text::Cache::new();
|
||||||
let mut measure = |content: &str| -> Result<Size, Error> {
|
let mut measure = |content: &str| -> Result<Size, Error> {
|
||||||
if content.is_empty() {
|
if content.is_empty() {
|
||||||
@@ -345,6 +461,16 @@ impl Cache {
|
|||||||
best
|
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 {
|
let label = Arc::new(Label {
|
||||||
source: source.into(),
|
source: source.into(),
|
||||||
displayed,
|
displayed,
|
||||||
@@ -353,6 +479,9 @@ impl Cache {
|
|||||||
style,
|
style,
|
||||||
measured_session: session.clone(),
|
measured_session: session.clone(),
|
||||||
measured_zoom: zoom,
|
measured_zoom: zoom,
|
||||||
|
clusters,
|
||||||
|
source_spans: normalized.spans,
|
||||||
|
retained_normalized: retained,
|
||||||
});
|
});
|
||||||
if self.entries.len() == MAX_ENTRIES {
|
if self.entries.len() == MAX_ENTRIES {
|
||||||
self.entries.pop_front();
|
self.entries.pop_front();
|
||||||
@@ -394,12 +523,14 @@ fn translated_bounds(position: Point, size: Size) -> Result<Rectangle, Error> {
|
|||||||
struct Normalized {
|
struct Normalized {
|
||||||
text: String,
|
text: String,
|
||||||
ends: Vec<(usize, usize)>,
|
ends: Vec<(usize, usize)>,
|
||||||
|
spans: Vec<SourceSpan>,
|
||||||
}
|
}
|
||||||
impl Normalized {
|
impl Normalized {
|
||||||
fn new(source: &str) -> Self {
|
fn new(source: &str) -> Self {
|
||||||
let mut result = Self {
|
let mut result = Self {
|
||||||
text: String::new(),
|
text: String::new(),
|
||||||
ends: vec![(0, 0)],
|
ends: vec![(0, 0)],
|
||||||
|
spans: vec![],
|
||||||
};
|
};
|
||||||
let mut separator = false;
|
let mut separator = false;
|
||||||
for (start, c) in source.char_indices() {
|
for (start, c) in source.char_indices() {
|
||||||
@@ -408,11 +539,23 @@ impl Normalized {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if separator {
|
if separator {
|
||||||
|
let normalized_start = result.text.len();
|
||||||
result.text.push(' ');
|
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));
|
result.ends.push((result.text.len(), start));
|
||||||
separator = false;
|
separator = false;
|
||||||
}
|
}
|
||||||
|
let normalized_start = result.text.len();
|
||||||
result.text.push(c);
|
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.ends.push((result.text.len(), start + c.len_utf8()));
|
||||||
}
|
}
|
||||||
result
|
result
|
||||||
@@ -425,6 +568,64 @@ impl Normalized {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn overlaps(a: &Range<usize>, b: &Range<usize>) -> bool {
|
||||||
|
a.start < a.end && b.start < b.end && a.start < b.end && b.start < a.end
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_range(ranges: &mut Vec<Range<usize>>, next: Range<usize>) {
|
||||||
|
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<Vec<FieldCluster>, 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)]
|
#[cfg(test)]
|
||||||
#[path = "scenario_label/tests.rs"]
|
#[path = "scenario_label/tests.rs"]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "scenario_label/field_tests.rs"]
|
||||||
|
mod field_tests;
|
||||||
|
|||||||
@@ -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<Label>) {
|
||||||
|
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||||
|
let label = Cache::default().resolve(&session, text, width, zoom, style).unwrap();
|
||||||
|
(session, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_and_ellipsis_only_have_no_field_coverage() {
|
||||||
|
for text in ["", " \t\n", "Operation: assess"] {
|
||||||
|
let (session, label) = resolve(text, 0., 1., Style::default());
|
||||||
|
assert!(label.displayed.is_empty());
|
||||||
|
let field = label.field_layout(&session, 1., Point::ORIGIN, 0..text.len()).unwrap();
|
||||||
|
assert!(field.rectangles.is_empty());
|
||||||
|
assert!(field.covered.is_empty());
|
||||||
|
assert!(field.unresolved.is_empty());
|
||||||
|
}
|
||||||
|
let style = Style::default();
|
||||||
|
let width = paragraph("…", 1., style).min_width();
|
||||||
|
let text = "Operation: assess";
|
||||||
|
let (session, label) = resolve(text, width, 1., style);
|
||||||
|
assert_eq!(label.displayed, "…", "must exercise synthetic-only displayed content");
|
||||||
|
let field = label.field_layout(&session, 1., Point::ORIGIN, 0..text.len()).unwrap();
|
||||||
|
assert!(field.covered.is_empty());
|
||||||
|
assert!(field.rectangles.is_empty());
|
||||||
|
assert!(!field.truncated.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exact_scalar_mapping_does_not_assign_trimmed_or_collapsed_whitespace_to_text() {
|
||||||
|
let source = " send\t\n assess ";
|
||||||
|
let (session, label) = resolve(source, 900., 1., Style::default());
|
||||||
|
assert_eq!(label.displayed, "send assess");
|
||||||
|
for range in [0..2, 6..10, 16..18] {
|
||||||
|
let result = label.field_layout(&session, 1., Point::ORIGIN, range).unwrap();
|
||||||
|
assert!(result.covered.is_empty());
|
||||||
|
assert!(result.rectangles.is_empty());
|
||||||
|
assert!(result.unresolved.is_empty());
|
||||||
|
}
|
||||||
|
let result = label.field_layout(&session, 1., Point::ORIGIN, 10..16).unwrap();
|
||||||
|
assert_eq!(result.covered, vec![10..16]);
|
||||||
|
assert!(!result.rectangles.is_empty());
|
||||||
|
assert!(result.truncated.is_empty());
|
||||||
|
assert!(result.unresolved.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn late_operation_reports_hidden_partial_and_complete_without_ellipsis_membership() {
|
||||||
|
let source = "1. call - Incident report - assess_pressure";
|
||||||
|
let start = source.find("assess_pressure").unwrap();
|
||||||
|
let style = Style { shaping: Shaping::Advanced, ..Style::default() };
|
||||||
|
let (session, full) = resolve(source, 900., 1., style);
|
||||||
|
let complete = full.field_layout(&session, 1., Point::ORIGIN, start..source.len()).unwrap();
|
||||||
|
assert_eq!(complete.covered, vec![start..source.len()]);
|
||||||
|
assert!(!complete.partially_retained);
|
||||||
|
let (_, hidden) = resolve(source, paragraph("1. call…", 1., style).min_width(), 1., style);
|
||||||
|
let hidden_result = hidden.field_layout(&hidden.measured_session, 1., Point::ORIGIN, start..source.len()).unwrap();
|
||||||
|
assert!(hidden.retained_source.end <= start);
|
||||||
|
assert!(hidden_result.covered.is_empty());
|
||||||
|
assert_eq!(hidden_result.truncated, vec![start..source.len()]);
|
||||||
|
assert!(!hidden_result.partially_retained);
|
||||||
|
let desired = format!("{}assess…", &source[..start]);
|
||||||
|
let (_, partial) = resolve(source, paragraph(&desired, 1., style).min_width(), 1., style);
|
||||||
|
assert!(partial.retained_source.end > start && partial.retained_source.end < source.len(),
|
||||||
|
"actual fitting result must retain a strict operation prefix");
|
||||||
|
let result = partial.field_layout(&partial.measured_session, 1., Point::ORIGIN, start..source.len()).unwrap();
|
||||||
|
assert_eq!(result.covered, vec![start..partial.retained_source.end]);
|
||||||
|
assert_eq!(result.truncated, vec![partial.retained_source.end..source.len()]);
|
||||||
|
assert!(result.partially_retained);
|
||||||
|
assert!(result.unresolved.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn positive_notdef_advance_is_unresolved_not_covered_or_truncated() {
|
||||||
|
let source = "A\u{10ffff}B";
|
||||||
|
let style = Style { shaping: Shaping::Advanced, ..Style::default() };
|
||||||
|
let oracle = paragraph(source, 1., style);
|
||||||
|
assert!(oracle.buffer().layout_runs().flat_map(|run| run.glyphs)
|
||||||
|
.any(|glyph| glyph.start == 1 && glyph.end == 5 && glyph.glyph_id == 0 && glyph.w > 0.),
|
||||||
|
"font precondition: actual positive-width .notdef for the noncharacter");
|
||||||
|
let (session, label) = resolve(source, 900., 1., style);
|
||||||
|
let missing = label.field_layout(&session, 1., Point::ORIGIN, 1..5).unwrap();
|
||||||
|
assert!(missing.covered.is_empty());
|
||||||
|
assert!(missing.rectangles.is_empty());
|
||||||
|
assert!(missing.truncated.is_empty());
|
||||||
|
assert_eq!(missing.unresolved, vec![UnresolvedField { source: 1..5, reason: FieldIssue::MissingGlyph }]);
|
||||||
|
let mixed = label.field_layout(&session, 1., Point::ORIGIN, 0..source.len()).unwrap();
|
||||||
|
assert_eq!(mixed.covered, vec![0..1, 5..6]);
|
||||||
|
assert_eq!(mixed.unresolved, missing.unresolved);
|
||||||
|
assert!(!mixed.rectangles.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn advanced_ligature_partial_field_uses_whole_shared_cluster_not_fraction() {
|
||||||
|
let source = "ffi";
|
||||||
|
let style = Style { shaping: Shaping::Advanced, ..Style::default() };
|
||||||
|
let oracle = paragraph(source, 1., style);
|
||||||
|
let run = oracle.buffer().layout_runs().next().unwrap();
|
||||||
|
let glyph = run.glyphs.iter().find(|glyph| glyph.start == 0 && glyph.end == 3 && glyph.w > 0. && glyph.glyph_id != 0)
|
||||||
|
.expect("font precondition: Advanced must produce the ffi ligature cluster");
|
||||||
|
let expected = Rectangle::new(Point::new(glyph.x, run.line_top + glyph.y),
|
||||||
|
Size::new(glyph.w, glyph.line_height_opt.unwrap_or(run.line_height)));
|
||||||
|
let (session, label) = resolve(source, 900., 1., style);
|
||||||
|
let field = label.field_layout(&session, 1., Point::ORIGIN, 1..2).unwrap();
|
||||||
|
assert_eq!(field.covered, vec![1..2]);
|
||||||
|
assert!(field.shared_cluster);
|
||||||
|
assert!(field.rectangles.contains(&expected));
|
||||||
|
assert_eq!(field.rectangles.iter().map(|rect| rect.width).sum::<f32>(), expected.width);
|
||||||
|
assert!(field.unresolved.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shared_default_ignorable_does_not_inherit_neighbor_coverage() {
|
||||||
|
let source = "a\u{fe0f}";
|
||||||
|
let style = Style { shaping: Shaping::Advanced, ..Style::default() };
|
||||||
|
let oracle = paragraph(source, 1., style);
|
||||||
|
assert!(oracle.buffer().layout_runs().flat_map(|run| run.glyphs)
|
||||||
|
.any(|glyph| glyph.start == 0 && glyph.end == source.len() && glyph.w > 0. && glyph.glyph_id != 0),
|
||||||
|
"precondition: variation selector shares a real positive-width base cluster");
|
||||||
|
let (session, label) = resolve(source, 900., 1., style);
|
||||||
|
let field = label.field_layout(&session, 1., Point::ORIGIN, 1..source.len()).unwrap();
|
||||||
|
assert!(field.covered.is_empty());
|
||||||
|
assert!(field.rectangles.is_empty());
|
||||||
|
assert!(field.truncated.is_empty());
|
||||||
|
assert!(field.shared_cluster);
|
||||||
|
assert_eq!(field.unresolved, vec![UnresolvedField { source: 1..source.len(), reason: FieldIssue::NonPaintingScalar }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn combining_mark_field_retains_cluster_geometry_without_splitting_the_grapheme() {
|
||||||
|
let source = "e\u{301} next";
|
||||||
|
let style = Style { shaping: Shaping::Advanced, ..Style::default() };
|
||||||
|
let oracle = paragraph(source, 1., style);
|
||||||
|
let run = oracle.buffer().layout_runs().next().unwrap();
|
||||||
|
let cluster = run.glyphs.iter().find(|glyph| glyph.start == 0 && glyph.end == 3 && glyph.w > 0. && glyph.glyph_id != 0)
|
||||||
|
.expect("precondition: composed accent and base share the grapheme cluster");
|
||||||
|
let (session, label) = resolve(source, 900., 1., style);
|
||||||
|
let field = label.field_layout(&session, 1., Point::ORIGIN, 1..3).unwrap();
|
||||||
|
assert_eq!(field.covered, vec![1..3]);
|
||||||
|
assert!(field.shared_cluster);
|
||||||
|
assert!(field.rectangles.iter().any(|rect| rect.x == cluster.x && rect.width == cluster.w));
|
||||||
|
assert!(field.unresolved.is_empty());
|
||||||
|
assert_eq!(label.field_layout(&session, 1., Point::ORIGIN, 2..3), Err(Error::InvalidFieldRange));
|
||||||
|
let (_, short) = resolve(source, paragraph("e\u{301}…", 1., style).min_width(), 1., style);
|
||||||
|
assert_ne!(short.retained_source.end, 1, "the chosen prefix must never split the combining grapheme");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bidi_logical_field_keeps_visual_pieces_without_enclosing_unselected_glyph() {
|
||||||
|
let source = "abc אבג xyz";
|
||||||
|
let style = Style { shaping: Shaping::Advanced, ..Style::default() };
|
||||||
|
let oracle = paragraph(source, 1., style);
|
||||||
|
let run = oracle.buffer().layout_runs().next().unwrap();
|
||||||
|
assert!(run.glyphs.iter().any(|glyph| glyph.level.is_rtl()));
|
||||||
|
assert!(run.glyphs.iter().any(|glyph| !glyph.level.is_rtl()));
|
||||||
|
let excluded = run.glyphs.iter().find(|glyph| glyph.start == 8 && glyph.end == 10 && glyph.w > 0. && glyph.glyph_id != 0).unwrap();
|
||||||
|
let (session, label) = resolve(source, 900., 1., style);
|
||||||
|
let field = label.field_layout(&session, 1., Point::ORIGIN, 1..8).unwrap();
|
||||||
|
assert_eq!(field.covered, vec![1..3, 4..8]);
|
||||||
|
assert!(field.unresolved.is_empty());
|
||||||
|
assert!(field.rectangles.iter().any(|rect| rect.x + rect.width <= excluded.x));
|
||||||
|
assert!(field.rectangles.iter().any(|rect| rect.x >= excluded.x + excluded.w));
|
||||||
|
assert!(field.rectangles.iter().all(|rect|
|
||||||
|
rect.x + rect.width <= excluded.x || rect.x >= excluded.x + excluded.w),
|
||||||
|
"logical selection must not become one envelope over the intervening visual glyph");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auto_ascii_to_ellipsis_advanced_transition_uses_final_buffer() {
|
||||||
|
let style = Style { shaping: Shaping::Auto, ..Style::default() };
|
||||||
|
let source = "ffi office assessment";
|
||||||
|
assert_eq!(graphics_text::to_shaping(style.shaping, source), graphics_text::cosmic_text::Shaping::Basic);
|
||||||
|
let (session, label) = resolve(source, paragraph("ffi office…", 1., style).min_width(), 1., style);
|
||||||
|
assert!(label.displayed.ends_with('…'));
|
||||||
|
assert_eq!(graphics_text::to_shaping(style.shaping, &label.displayed), graphics_text::cosmic_text::Shaping::Advanced);
|
||||||
|
let oracle = paragraph(&label.displayed, 1., style);
|
||||||
|
let run = oracle.buffer().layout_runs().next().unwrap();
|
||||||
|
let first = run.glyphs.iter().find(|glyph| glyph.start == 0 && glyph.end >= 3 && glyph.w > 0. && glyph.glyph_id != 0)
|
||||||
|
.expect("final Advanced shape must expose the ligature precondition");
|
||||||
|
let field = label.field_layout(&session, 1., Point::ORIGIN, 0..3).unwrap();
|
||||||
|
assert_eq!(field.covered, vec![0..3]);
|
||||||
|
assert!(field.rectangles.iter().any(|rect| rect.x == first.x && rect.width == first.w));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn field_geometry_reuses_zoom_session_guards_and_translates_actual_cluster_boxes() {
|
||||||
|
let source = "Z";
|
||||||
|
for zoom in [0.5, 1.75] {
|
||||||
|
let style = Style::default();
|
||||||
|
let oracle = paragraph(source, zoom, style);
|
||||||
|
let run = oracle.buffer().layout_runs().next().unwrap();
|
||||||
|
assert_eq!(run.glyphs.len(), 1);
|
||||||
|
let glyph = &run.glyphs[0];
|
||||||
|
assert!(glyph.glyph_id != 0 && glyph.w > 0.);
|
||||||
|
let (session, label) = resolve(source, 900., zoom, style);
|
||||||
|
let position = Point::new(-300., 90.);
|
||||||
|
let field = label.field_layout(&session, zoom, position, 0..1).unwrap();
|
||||||
|
assert_eq!(field.rectangles, vec![Rectangle::new(
|
||||||
|
Point::new(position.x + glyph.x / zoom, position.y + (run.line_top + glyph.y) / zoom),
|
||||||
|
Size::new(((glyph.x + glyph.w) / zoom - glyph.x / zoom).abs(), glyph.line_height_opt.unwrap_or(run.line_height) / zoom),
|
||||||
|
)]);
|
||||||
|
assert_eq!(label.field_layout(&session, zoom + 0.1, position, 0..1), Err(Error::StaleZoom));
|
||||||
|
let fresh = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||||
|
assert_eq!(label.field_layout(&fresh, zoom, position, 0..1), Err(Error::StaleRenderSession));
|
||||||
|
assert_eq!(label.field_layout(&session, zoom, Point::new(f32::NAN, 0.), 0..1), Err(Error::InvalidPosition));
|
||||||
|
assert_eq!(label.field_layout(&session, zoom, position, 0..2), Err(Error::InvalidFieldRange));
|
||||||
|
let empty = label.field_layout(&session, zoom, position, 0..0).unwrap();
|
||||||
|
assert!(empty.covered.is_empty() && empty.rectangles.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user