Compose saved Scenario authoring with current Text and input authority
This commit is contained in:
@@ -0,0 +1,430 @@
|
||||
//! 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)]
|
||||
#[path = "scenario_label/tests.rs"]
|
||||
mod tests;
|
||||
Reference in New Issue
Block a user