Three root causes, all fixed and verified on the Pixel 9 Pro: 1. Present phase (backend + renderer): VSYNC_LOCKED_RENDER defers the render to the vsync wake; RENDER_AHEAD presents the previous frame at the start of render() (phase = wake + ~1.1 ms) and holds the new frame one slot. Crash fix: a held wgpu frame must be PRESENTED, never merely dropped, before the next acquire (AlreadyAcquired panic at launch otherwise); discard_pending_frame presents too. 2. Full-res prefetch churn (src/lib.rs): the 8-slot full cache cycled through a ~20-cell viewport forever (137 decodes/20 s) and each eviction free()'d a 50 MB Vec on the UI thread (20-52 ms CPU spikes, ~7/s = the residual SLOWEV NonMain jank). Fix: 60 s tombstone cooldown on evicted paths (idle prefetch skips, urgent tap bypasses) + off-thread reaper for the freed blobs (insert: 21 ms -> 4 us). 3. (earlier) Skia final flush + Mailbox present mode (vendor, M3). Instrumentation (all behind --features perf): OTHR/INVK/INVK_TAG/FLCK lines for cross-thread closure timing and push-site tagging. Warm measured swipe, 8580-file grid: cadence median 8.33 ms (exact 120 Hz), p99 15.5 ms, 5/552 gaps >16.7 ms (0.9%, max 28.3 ms, zero >33 ms); PRES phase median ~994 us, zero late frames. Docs updated (docs/BUGS/scroll-jank-120hz.md, full scorecard + line reference). Also: vendored i-slint-core + wgpu-hal (vkshim interposition patch), vkshim profiling tooling, backend made a non-optional dep, debuggable flag for simpleperf (REMOVE before shipping).
861 lines
30 KiB
Rust
861 lines
30 KiB
Rust
// Copyright © SixtyFPS GmbH <info@slint.dev>
|
|
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
|
|
|
|
// cSpell: ignore unshareable
|
|
pub use parley;
|
|
pub use parley::fontique;
|
|
|
|
use crate::{
|
|
Color,
|
|
graphics::FontRequest,
|
|
item_rendering::PlainOrStyledText,
|
|
items::TextStrokeStyle,
|
|
lengths::{
|
|
LogicalBorderRadius, LogicalLength, LogicalPoint, LogicalRect, LogicalSize, PhysicalPx,
|
|
PointLengths, RectLengths, ScaleFactor, SizeLengths,
|
|
},
|
|
renderer::RendererSealed,
|
|
textlayout::{TextHorizontalAlignment, TextOverflow, TextVerticalAlignment, TextWrap},
|
|
window::WindowAdapter,
|
|
};
|
|
use alloc::rc::Rc;
|
|
use alloc::vec::Vec;
|
|
use core::ops::Range;
|
|
use core::pin::Pin;
|
|
use euclid::num::Zero;
|
|
use i_slint_common::sharedfontique;
|
|
use skrifa::MetadataProvider as _;
|
|
use std::cell::RefCell;
|
|
use std::collections::HashSet;
|
|
use std::sync::Arc;
|
|
|
|
#[derive(derive_more::Deref, derive_more::DerefMut)]
|
|
pub struct FontContext {
|
|
#[deref]
|
|
#[deref_mut]
|
|
pub inner: parley::FontContext,
|
|
/// `(ptr, len)` of each `&'static [u8]` already handed to fontique, so repeat
|
|
/// `register_static_font` calls for the same embedded font are skipped.
|
|
registered_static_fonts: HashSet<(usize, usize)>,
|
|
}
|
|
|
|
impl FontContext {
|
|
pub fn new(inner: parley::FontContext) -> Self {
|
|
Self { inner, registered_static_fonts: HashSet::default() }
|
|
}
|
|
|
|
pub fn register_static_font(&mut self, data: &'static [u8]) {
|
|
let key = (data.as_ptr() as usize, data.len());
|
|
if self.registered_static_fonts.insert(key) {
|
|
self.inner.collection.register_fonts(fontique::Blob::new(Arc::new(data)), None);
|
|
}
|
|
}
|
|
|
|
pub fn clear_registered_static_fonts(&mut self) {
|
|
self.registered_static_fonts.clear();
|
|
}
|
|
}
|
|
|
|
pub type PhysicalLength = euclid::Length<f32, PhysicalPx>;
|
|
pub type PhysicalRect = euclid::Rect<f32, PhysicalPx>;
|
|
type PhysicalSize = euclid::Size2D<f32, PhysicalPx>;
|
|
type PhysicalPoint = euclid::Point2D<f32, PhysicalPx>;
|
|
|
|
pub use super::DEFAULT_FONT_SIZE;
|
|
|
|
mod cache;
|
|
mod draw;
|
|
mod layout;
|
|
mod selection;
|
|
mod shaping;
|
|
#[cfg(test)]
|
|
mod tests;
|
|
|
|
pub use cache::TextLayoutCache;
|
|
pub use draw::{GlyphRenderer, RectangleBorder};
|
|
|
|
use cache::cached_paragraphs;
|
|
use layout::{Layout, LayoutOptions, layout};
|
|
use selection::{SelectionRendering, SelectionSpans};
|
|
use shaping::{
|
|
Brush, LayoutWithoutLineBreaksBuilder, create_text_paragraphs, shape_paragraphs,
|
|
shaping_builder,
|
|
};
|
|
|
|
/// Lays out the shaped text of an item and runs `f` over the result.
|
|
///
|
|
/// This is the one place that checks paragraphs out of a [`TextLayoutCache`] entry and hands them
|
|
/// back: `f` only borrows the [`Layout`], so no caller can lose the shaped paragraphs and cost the
|
|
/// next use of the entry a reshape.
|
|
///
|
|
/// The font context is borrowed from `window`'s Slint context for shaping and layout only, and
|
|
/// released before `f` runs -- glyph drawing inside `f` re-enters it, and property bindings
|
|
/// evaluated under `f` must not find it borrowed. The wrap mode and scale factor the cache entry
|
|
/// is keyed on come from `layout_builder`, so shaping and layout cannot disagree about either.
|
|
///
|
|
/// Returns `None` only when `window` has no Slint context yet.
|
|
fn with_text_layout<R>(
|
|
cache: Option<&TextLayoutCache>,
|
|
item_rc: Option<&crate::item_tree::ItemRc>,
|
|
text: Pin<&dyn crate::item_rendering::RenderString>,
|
|
layout_builder: &LayoutWithoutLineBreaksBuilder,
|
|
options: LayoutOptions,
|
|
window: &crate::api::Window,
|
|
f: impl FnOnce(&Layout) -> R,
|
|
) -> Option<R> {
|
|
let ctx = crate::window::WindowInner::from_pub(window).try_context()?;
|
|
let mut font_ctx = ctx.font_context().borrow_mut();
|
|
|
|
let text_wrap = layout_builder.text_wrap;
|
|
let scale_factor = layout_builder.scale_factor;
|
|
let mut guard =
|
|
cached_paragraphs(cache, item_rc, text_wrap, window, &mut font_ctx, &|font_context| {
|
|
shape_paragraphs(text, item_rc, text_wrap, scale_factor, font_context)
|
|
});
|
|
|
|
let line_breaking = guard.take_line_breaking();
|
|
let layout =
|
|
layout(layout_builder, &mut font_ctx, guard.take(), scale_factor, options, line_breaking);
|
|
drop(font_ctx);
|
|
|
|
if layout.broke_lines {
|
|
#[cfg(feature = "testing")]
|
|
if let Some(cache) = cache {
|
|
cache.count_layout_miss();
|
|
}
|
|
}
|
|
|
|
let result = f(&layout);
|
|
let (paragraphs, line_breaking) = layout.dismantle();
|
|
guard.restore(paragraphs, line_breaking);
|
|
Some(result)
|
|
}
|
|
|
|
pub fn draw_text(
|
|
item_renderer: &mut impl GlyphRenderer,
|
|
text: Pin<&dyn crate::item_rendering::RenderText>,
|
|
item_rc: Option<&crate::item_tree::ItemRc>,
|
|
size: LogicalSize,
|
|
cache: Option<&TextLayoutCache>,
|
|
) {
|
|
let max_width = size.width_length();
|
|
let max_height = size.height_length();
|
|
|
|
if max_width.get() <= 0. || max_height.get() <= 0. {
|
|
return;
|
|
}
|
|
|
|
let Some(platform_fill_brush) = item_renderer.platform_text_fill_brush(text.color(), size)
|
|
else {
|
|
// Nothing to draw
|
|
return;
|
|
};
|
|
|
|
let scale_factor = item_renderer.scale_factor();
|
|
|
|
let (stroke_brush, stroke_width, stroke_style) = text.stroke();
|
|
let platform_stroke_brush = if !stroke_brush.is_transparent() {
|
|
let stroke_width = if stroke_width.get() != 0.0 {
|
|
(stroke_width * scale_factor).get()
|
|
} else {
|
|
// Hairline stroke
|
|
1.0
|
|
};
|
|
let stroke_width = match stroke_style {
|
|
TextStrokeStyle::Outside => stroke_width * 2.0,
|
|
TextStrokeStyle::Center => stroke_width,
|
|
};
|
|
item_renderer.platform_text_stroke_brush(stroke_brush, stroke_width, size)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let layout_builder = shaping_builder(text, item_rc, text.wrap(), scale_factor);
|
|
|
|
let window_adapter = item_renderer.window().window_adapter();
|
|
|
|
let (horizontal_align, vertical_align) = text.alignment();
|
|
let text_overflow = text.overflow();
|
|
let text_color = text.color().color();
|
|
|
|
let _ = with_text_layout(
|
|
cache,
|
|
item_rc,
|
|
text,
|
|
&layout_builder,
|
|
LayoutOptions {
|
|
horizontal_align,
|
|
vertical_align,
|
|
max_height: Some(max_height),
|
|
max_width: Some(max_width),
|
|
max_lines: text.line_limit(),
|
|
text_overflow,
|
|
},
|
|
window_adapter.window(),
|
|
|layout| {
|
|
// When `overflow: elide` can't even fit the first line, the line is still drawn
|
|
// (rather than dropped, which would render nothing) but its vertical overflow needs
|
|
// to be clipped like `overflow: clip` would. Horizontal elision still applies, so a
|
|
// line that is both too tall and too wide is clipped vertically and gets an ellipsis
|
|
// horizontally.
|
|
let clip_overflowing_first_line =
|
|
text_overflow == TextOverflow::Elide && layout.first_line_exceeds_height();
|
|
|
|
let render = if text_overflow == TextOverflow::Clip || clip_overflowing_first_line {
|
|
item_renderer.save_state();
|
|
|
|
item_renderer.combine_clip(
|
|
LogicalRect::new(LogicalPoint::default(), size),
|
|
LogicalBorderRadius::zero(),
|
|
)
|
|
} else {
|
|
true
|
|
};
|
|
|
|
if render {
|
|
layout.draw(
|
|
item_renderer,
|
|
platform_fill_brush,
|
|
platform_stroke_brush,
|
|
text_color,
|
|
// `Text` has no selection today; the machinery is shared so wiring one up
|
|
// later is a matter of passing spans here.
|
|
None,
|
|
);
|
|
}
|
|
|
|
if text_overflow == TextOverflow::Clip || clip_overflowing_first_line {
|
|
item_renderer.restore_state();
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
#[cfg(feature = "std")]
|
|
pub fn link_under_cursor(
|
|
scale_factor: ScaleFactor,
|
|
text: Pin<&dyn crate::item_rendering::RenderText>,
|
|
item_rc: &crate::item_tree::ItemRc,
|
|
size: LogicalSize,
|
|
cursor: PhysicalPoint,
|
|
window: &crate::api::Window,
|
|
cache: Option<&TextLayoutCache>,
|
|
) -> Option<std::string::String> {
|
|
let layout_builder = shaping_builder(text, Some(item_rc), text.wrap(), scale_factor);
|
|
|
|
let (horizontal_align, vertical_align) = text.alignment();
|
|
|
|
with_text_layout(
|
|
cache,
|
|
Some(item_rc),
|
|
text,
|
|
&layout_builder,
|
|
LayoutOptions {
|
|
horizontal_align,
|
|
vertical_align,
|
|
max_height: Some(size.height_length()),
|
|
max_width: Some(size.width_length()),
|
|
max_lines: text.line_limit(),
|
|
text_overflow: text.overflow(),
|
|
},
|
|
window,
|
|
|layout| link_in_layout(layout, cursor),
|
|
)
|
|
.flatten()
|
|
}
|
|
|
|
fn link_in_layout(layout: &Layout, cursor: PhysicalPoint) -> Option<std::string::String> {
|
|
layout.paragraph_by_y(cursor.y_length()).and_then(|paragraph| {
|
|
let paragraph_y: f64 = paragraph.y.cast::<f64>().get();
|
|
|
|
paragraph
|
|
.links
|
|
.iter()
|
|
.find(|(range, _)| {
|
|
let start = parley::editing::Cursor::from_byte_index(
|
|
¶graph.layout,
|
|
range.start,
|
|
Default::default(),
|
|
);
|
|
let end = parley::editing::Cursor::from_byte_index(
|
|
¶graph.layout,
|
|
range.end,
|
|
Default::default(),
|
|
);
|
|
let mut clicked = false;
|
|
let link_range = parley::Selection::new(start, end);
|
|
link_range.geometry_with(¶graph.layout, |mut bounding_box, _line| {
|
|
bounding_box.y0 += paragraph_y;
|
|
bounding_box.y1 += paragraph_y;
|
|
clicked = bounding_box.union(parley::BoundingBox::new(
|
|
cursor.x.into(),
|
|
cursor.y.into(),
|
|
cursor.x.into(),
|
|
cursor.y.into(),
|
|
)) == bounding_box;
|
|
});
|
|
clicked
|
|
})
|
|
.map(|(_, link)| link.clone())
|
|
})
|
|
}
|
|
|
|
pub fn draw_text_input(
|
|
item_renderer: &mut impl GlyphRenderer,
|
|
text_input: Pin<&crate::items::TextInput>,
|
|
item_rc: &crate::item_tree::ItemRc,
|
|
size: LogicalSize,
|
|
cache: &TextLayoutCache,
|
|
) {
|
|
let width = size.width_length();
|
|
let height = size.height_length();
|
|
if width.get() <= 0. || height.get() <= 0. {
|
|
return;
|
|
}
|
|
|
|
let visual_representation = text_input.visual_representation();
|
|
|
|
let text_color = visual_representation.text_color.color();
|
|
let Some(platform_fill_brush) =
|
|
item_renderer.platform_text_fill_brush(visual_representation.text_color.clone(), size)
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let selection_range = if !visual_representation.preedit_range.is_empty() {
|
|
visual_representation.preedit_range.start..visual_representation.preedit_range.end
|
|
} else {
|
|
visual_representation.selection_range.start..visual_representation.selection_range.end
|
|
};
|
|
|
|
let scale_factor = item_renderer.scale_factor();
|
|
|
|
let layout_builder =
|
|
shaping_builder(text_input, Some(item_rc), text_input.wrap(), scale_factor);
|
|
|
|
let window_adapter = item_renderer.window().window_adapter();
|
|
|
|
// The visual text shapes through the shared cache entry like any other text: a selection
|
|
// doesn't make the entry unshareable, because it is applied when drawing, by clipping the
|
|
// runs it cuts across, and never reaches shaping. Cluster ranges and advances are identical
|
|
// with and without one, so a selected `TextInput` hits the same entry as an unselected one --
|
|
// which is what keeps dragging a selection, or composing with an IME, from re-shaping the
|
|
// document on every event. A password field shapes a substituted text, but the substitution
|
|
// is the same everywhere, so it is cacheable too.
|
|
let _ = with_text_layout(
|
|
Some(cache),
|
|
Some(item_rc),
|
|
text_input,
|
|
&layout_builder,
|
|
LayoutOptions::new_from_textinput(text_input, Some(width), Some(height)),
|
|
window_adapter.window(),
|
|
|layout| {
|
|
item_renderer.save_state();
|
|
|
|
let render = item_renderer.combine_clip(
|
|
LogicalRect::new(LogicalPoint::default(), size),
|
|
LogicalBorderRadius::zero(),
|
|
);
|
|
|
|
if render {
|
|
// When a piece of text is first selected, it gets an empty range like `1..1`. If
|
|
// the text starts with a multi-byte character then this selection would be within
|
|
// that character and parley would panic, so empty ranges are filtered out. The
|
|
// spans only feed drawing (the highlight fill and the glyph clip), so only the
|
|
// lines the clip lets through need any.
|
|
let selection_spans = if selection_range.is_empty() {
|
|
SelectionSpans::default()
|
|
} else {
|
|
layout.selection_geometry(selection_range, &draw::visible_band(item_renderer))
|
|
};
|
|
// Inside the clip, like the glyphs it sits under: a line box taller than the item
|
|
// would otherwise paint the highlight over whatever follows the input.
|
|
for background in selection_spans.backgrounds() {
|
|
item_renderer.fill_rectangle_with_color(
|
|
background,
|
|
text_input.selection_background_color(),
|
|
);
|
|
}
|
|
|
|
// Selected glyphs are recolored by clipping, not by restyling the layout, so that
|
|
// a boundary landing inside a ligature cuts the glyph instead of recoloring all
|
|
// of it.
|
|
let selection = (!selection_spans.is_empty())
|
|
.then(|| {
|
|
item_renderer
|
|
.platform_brush_for_color(&text_input.selection_foreground_color())
|
|
.map(|foreground| SelectionRendering {
|
|
spans: &selection_spans,
|
|
foreground,
|
|
})
|
|
})
|
|
.flatten();
|
|
|
|
layout.draw(
|
|
item_renderer,
|
|
platform_fill_brush,
|
|
None,
|
|
text_color,
|
|
selection.as_ref(),
|
|
);
|
|
|
|
if let Some(cursor_pos) = visual_representation.cursor_position {
|
|
let cursor_rect = layout.cursor_rect_for_byte_offset(
|
|
cursor_pos,
|
|
visual_representation.cursor_affinity,
|
|
text_input.text_cursor_width() * scale_factor,
|
|
);
|
|
item_renderer
|
|
.fill_rectangle_with_color(cursor_rect, visual_representation.cursor_color);
|
|
}
|
|
}
|
|
|
|
item_renderer.restore_state();
|
|
},
|
|
);
|
|
}
|
|
|
|
// The public entry points taking a renderer are generic so that RendererSealed's default
|
|
// implementations can pass self. Each is a thin shim that extracts what it needs from the
|
|
// renderer and forwards to a monomorphic inner function, so that the layout code is not
|
|
// instantiated (and duplicated in the binary) once per renderer type.
|
|
pub fn text_size(
|
|
renderer: &(impl RendererSealed + ?Sized),
|
|
text_item: Pin<&dyn crate::item_rendering::RenderString>,
|
|
item_rc: &crate::item_tree::ItemRc,
|
|
max_width: Option<LogicalLength>,
|
|
text_wrap: TextWrap,
|
|
cache: Option<&TextLayoutCache>,
|
|
) -> Option<LogicalSize> {
|
|
text_size_impl(
|
|
renderer.scale_factor(),
|
|
renderer.window_adapter(),
|
|
text_item,
|
|
item_rc,
|
|
max_width,
|
|
text_wrap,
|
|
cache,
|
|
)
|
|
}
|
|
|
|
fn text_size_impl(
|
|
scale_factor: Option<ScaleFactor>,
|
|
window_adapter: Option<Rc<dyn WindowAdapter>>,
|
|
text_item: Pin<&dyn crate::item_rendering::RenderString>,
|
|
item_rc: &crate::item_tree::ItemRc,
|
|
max_width: Option<LogicalLength>,
|
|
text_wrap: TextWrap,
|
|
cache: Option<&TextLayoutCache>,
|
|
) -> Option<LogicalSize> {
|
|
let scale_factor = scale_factor?;
|
|
|
|
// Evaluate the properties that `shape_paragraphs` reads before borrowing font_context: they
|
|
// can trigger property bindings that re-enter text_size for other elements, which would panic
|
|
// on a second borrow_mut(). Afterwards they are clean, so shaping can read them again -- now
|
|
// without re-entering -- inside the cache entry's dependency tracker.
|
|
let _ = text_item.font_request(item_rc);
|
|
let _ = text_item.stroke();
|
|
let _ = text_item.link_color();
|
|
let _ = text_item.text();
|
|
|
|
let window_adapter = window_adapter?;
|
|
|
|
// Only `layout()`'s elision glyph reads this, and `TextOverflow::Clip` never asks for one.
|
|
let layout_builder = shaping_builder(text_item, Some(item_rc), text_wrap, scale_factor);
|
|
|
|
with_text_layout(
|
|
cache,
|
|
Some(item_rc),
|
|
text_item,
|
|
&layout_builder,
|
|
LayoutOptions {
|
|
max_width,
|
|
max_height: None,
|
|
max_lines: text_item.line_limit(),
|
|
horizontal_align: TextHorizontalAlignment::Left,
|
|
vertical_align: TextVerticalAlignment::Top,
|
|
text_overflow: TextOverflow::Clip,
|
|
},
|
|
window_adapter.window(),
|
|
|layout| PhysicalSize::from_lengths(layout.max_width, layout.height) / scale_factor,
|
|
)
|
|
}
|
|
|
|
/// The content widths of the text. See [`crate::renderer::ContentWidths`].
|
|
pub fn text_content_widths(
|
|
renderer: &(impl RendererSealed + ?Sized),
|
|
text_item: Pin<&dyn crate::item_rendering::RenderString>,
|
|
item_rc: &crate::item_tree::ItemRc,
|
|
cache: Option<&TextLayoutCache>,
|
|
) -> Option<crate::renderer::ContentWidths> {
|
|
text_content_widths_impl(
|
|
renderer.scale_factor(),
|
|
renderer.window_adapter(),
|
|
renderer.slint_context(),
|
|
text_item,
|
|
item_rc,
|
|
cache,
|
|
)
|
|
}
|
|
|
|
fn text_content_widths_impl(
|
|
scale_factor: Option<ScaleFactor>,
|
|
window_adapter: Option<Rc<dyn WindowAdapter>>,
|
|
ctx: Option<crate::SlintContext>,
|
|
text_item: Pin<&dyn crate::item_rendering::RenderString>,
|
|
item_rc: &crate::item_tree::ItemRc,
|
|
cache: Option<&TextLayoutCache>,
|
|
) -> Option<crate::renderer::ContentWidths> {
|
|
let scale_factor = scale_factor?;
|
|
|
|
// See text_size(): evaluate properties before borrowing font_context. Afterwards they are
|
|
// clean, so `compute` can read them again -- now without re-entering -- inside the cache
|
|
// entry's dependency tracker.
|
|
let _ = text_item.font_request(item_rc);
|
|
let _ = text_item.text();
|
|
|
|
let ctx = ctx?;
|
|
let mut font_ctx = ctx.font_context().borrow_mut();
|
|
|
|
// Reads the text, the font request and the line limit, so the cache entry depends on all
|
|
// three. Nothing else feeds the widths: the wrap mode, the width and the color don't.
|
|
let compute = |font_ctx: &mut parley::FontContext| {
|
|
let layout_builder =
|
|
shaping::content_widths_builder(text_item.font_request(item_rc), scale_factor);
|
|
|
|
let paragraphs_without_linebreaks =
|
|
create_text_paragraphs(&layout_builder, font_ctx, text_item.text(), Color::default());
|
|
|
|
// No line breaking needed: parley derives the content widths from the break
|
|
// opportunities. Paragraphs stack vertically, so both widths are the widest.
|
|
// Without wrapping each paragraph is one line, so a line limit drops the paragraphs
|
|
// that are not drawn, from both widths.
|
|
let (min, max) = paragraphs_without_linebreaks
|
|
.iter()
|
|
.take(text_item.line_limit().unwrap_or(usize::MAX))
|
|
.fold((0., 0.), |(min, max), p| {
|
|
let w = p.layout.calculate_content_widths();
|
|
(f32::max(min, w.min), f32::max(max, w.max))
|
|
});
|
|
crate::renderer::ContentWidths {
|
|
min: PhysicalLength::new(min) / scale_factor,
|
|
max: PhysicalLength::new(max) / scale_factor,
|
|
}
|
|
};
|
|
|
|
// Without a window there is no way to notice a scale factor change, and the shaped
|
|
// advances are in physical pixels, so an entry kept across one would be wrong.
|
|
let Some((cache, window_adapter)) = cache.zip(window_adapter) else {
|
|
return Some(compute(&mut font_ctx));
|
|
};
|
|
cache.clear_if_scale_factor_changed(window_adapter.window());
|
|
Some(cache.content_widths(item_rc, || compute(&mut font_ctx)))
|
|
}
|
|
|
|
pub fn char_size(
|
|
font_ctx: &mut parley::FontContext,
|
|
text_item: Pin<&dyn crate::item_rendering::HasFont>,
|
|
item_rc: &crate::item_tree::ItemRc,
|
|
ch: char,
|
|
) -> Option<LogicalSize> {
|
|
let font_request = text_item.font_request(item_rc);
|
|
let font = font_request.query_fontique(&mut font_ctx.collection, &mut font_ctx.source_cache)?;
|
|
|
|
let char_map = font.charmap()?;
|
|
|
|
let face = skrifa::FontRef::from_index(font.blob.data(), font.index).unwrap();
|
|
|
|
let glyph_index = char_map.map(ch)?;
|
|
|
|
let pixel_size = font_request.pixel_size.unwrap_or(DEFAULT_FONT_SIZE);
|
|
|
|
let location = face.axes().location(font.synthesis.variation_settings());
|
|
|
|
let glyph_metrics = skrifa::metrics::GlyphMetrics::new(
|
|
&face,
|
|
skrifa::instance::Size::new(pixel_size.get()),
|
|
&location,
|
|
);
|
|
|
|
let advance_width = LogicalLength::new(glyph_metrics.advance_width(glyph_index.into())?);
|
|
|
|
let font_metrics = skrifa::metrics::Metrics::new(
|
|
&face,
|
|
skrifa::instance::Size::new(pixel_size.get()),
|
|
&location,
|
|
);
|
|
let natural_line_height = font_metrics.ascent - font_metrics.descent;
|
|
let line_height = font_request
|
|
.line_height_for_natural_height(natural_line_height)
|
|
.unwrap_or(natural_line_height);
|
|
|
|
Some(LogicalSize::from_lengths(advance_width, LogicalLength::new(line_height)))
|
|
}
|
|
|
|
/// The height of one line of text: what a shaped single-line layout reports.
|
|
pub fn text_line_height(
|
|
font_ctx: &mut parley::FontContext,
|
|
font_request: &FontRequest,
|
|
) -> Option<LogicalLength> {
|
|
let pixel_size = font_request.pixel_size.unwrap_or(DEFAULT_FONT_SIZE);
|
|
shaping::line_height_ratio(font_ctx, font_request).map(|ratio| pixel_size * ratio)
|
|
}
|
|
|
|
pub fn font_metrics(
|
|
font_ctx: &mut parley::FontContext,
|
|
font_request: FontRequest,
|
|
) -> crate::items::FontMetrics {
|
|
let logical_pixel_size = font_request.pixel_size.unwrap_or(DEFAULT_FONT_SIZE).get();
|
|
|
|
let Some(font) =
|
|
font_request.query_fontique(&mut font_ctx.collection, &mut font_ctx.source_cache)
|
|
else {
|
|
return crate::items::FontMetrics::default();
|
|
};
|
|
|
|
let face = skrifa::FontRef::from_index(font.blob.data(), font.index).unwrap();
|
|
let location = face.axes().location(font.synthesis.variation_settings());
|
|
let metrics = face.metrics(skrifa::instance::Size::unscaled(), &location);
|
|
|
|
let units_per_em = metrics.units_per_em as f32;
|
|
|
|
crate::items::FontMetrics {
|
|
ascent: metrics.ascent * logical_pixel_size / units_per_em,
|
|
descent: metrics.descent * logical_pixel_size / units_per_em,
|
|
x_height: metrics.x_height.unwrap_or_default() * logical_pixel_size / units_per_em,
|
|
cap_height: metrics.cap_height.unwrap_or_default() * logical_pixel_size / units_per_em,
|
|
}
|
|
}
|
|
|
|
pub fn text_input_byte_offset_for_position(
|
|
renderer: &(impl RendererSealed + ?Sized),
|
|
text_input: Pin<&crate::items::TextInput>,
|
|
item_rc: &crate::item_tree::ItemRc,
|
|
pos: LogicalPoint,
|
|
cache: Option<&TextLayoutCache>,
|
|
) -> (usize, crate::items::TextCursorAffinity) {
|
|
text_input_byte_offset_for_position_impl(
|
|
renderer.scale_factor(),
|
|
renderer.window_adapter(),
|
|
text_input,
|
|
item_rc,
|
|
pos,
|
|
cache,
|
|
)
|
|
}
|
|
|
|
fn text_input_byte_offset_for_position_impl(
|
|
scale_factor: Option<ScaleFactor>,
|
|
window_adapter: Option<Rc<dyn WindowAdapter>>,
|
|
text_input: Pin<&crate::items::TextInput>,
|
|
item_rc: &crate::item_tree::ItemRc,
|
|
pos: LogicalPoint,
|
|
cache: Option<&TextLayoutCache>,
|
|
) -> (usize, crate::items::TextCursorAffinity) {
|
|
let no_hit = (0, crate::items::TextCursorAffinity::NextCharacter);
|
|
let Some(scale_factor) = scale_factor else {
|
|
return no_hit;
|
|
};
|
|
let pos: PhysicalPoint = pos * scale_factor;
|
|
|
|
let width = text_input.width();
|
|
let height = text_input.height();
|
|
if width.get() <= 0. || height.get() <= 0. || pos.y < 0. {
|
|
return no_hit;
|
|
}
|
|
|
|
let layout_builder =
|
|
shaping_builder(text_input, Some(item_rc), text_input.wrap(), scale_factor);
|
|
let visual_representation = text_input.visual_representation();
|
|
|
|
let Some(window_adapter) = window_adapter else {
|
|
return no_hit;
|
|
};
|
|
|
|
let (byte_offset, affinity) = with_text_layout(
|
|
cache,
|
|
Some(item_rc),
|
|
text_input,
|
|
&layout_builder,
|
|
LayoutOptions::new_from_textinput(text_input, Some(width), Some(height)),
|
|
window_adapter.window(),
|
|
|layout| layout.byte_offset_from_point(pos),
|
|
)
|
|
.unwrap_or(no_hit);
|
|
(visual_representation.map_byte_offset_from_visual_text_to_actual_text(byte_offset), affinity)
|
|
}
|
|
|
|
pub fn text_input_cursor_rect_for_byte_offset(
|
|
renderer: &(impl RendererSealed + ?Sized),
|
|
text_input: Pin<&crate::items::TextInput>,
|
|
item_rc: &crate::item_tree::ItemRc,
|
|
byte_offset: usize,
|
|
affinity: crate::items::TextCursorAffinity,
|
|
cache: Option<&TextLayoutCache>,
|
|
) -> LogicalRect {
|
|
text_input_cursor_rect_for_byte_offset_impl(
|
|
renderer.scale_factor(),
|
|
renderer.window_adapter(),
|
|
text_input,
|
|
item_rc,
|
|
byte_offset,
|
|
affinity,
|
|
cache,
|
|
)
|
|
}
|
|
|
|
fn text_input_cursor_rect_for_byte_offset_impl(
|
|
scale_factor: Option<ScaleFactor>,
|
|
window_adapter: Option<Rc<dyn WindowAdapter>>,
|
|
text_input: Pin<&crate::items::TextInput>,
|
|
item_rc: &crate::item_tree::ItemRc,
|
|
byte_offset: usize,
|
|
affinity: crate::items::TextCursorAffinity,
|
|
cache: Option<&TextLayoutCache>,
|
|
) -> LogicalRect {
|
|
let Some(scale_factor) = scale_factor else {
|
|
return LogicalRect::default();
|
|
};
|
|
|
|
let layout_builder =
|
|
shaping_builder(text_input, Some(item_rc), text_input.wrap(), scale_factor);
|
|
|
|
let width = text_input.width();
|
|
let height = text_input.height();
|
|
if width.get() <= 0. || height.get() <= 0. {
|
|
return LogicalRect::new(
|
|
LogicalPoint::default(),
|
|
LogicalSize::from_lengths(LogicalLength::new(1.0), layout_builder.pixel_size),
|
|
);
|
|
}
|
|
|
|
let visual_representation = text_input.visual_representation();
|
|
let cursor_width = text_input.text_cursor_width() * scale_factor;
|
|
|
|
let Some(window_adapter) = window_adapter else {
|
|
return LogicalRect::default();
|
|
};
|
|
|
|
let byte_offset = visual_representation.map_byte_offset_from_actual_to_visual_text(byte_offset);
|
|
|
|
with_text_layout(
|
|
cache,
|
|
Some(item_rc),
|
|
text_input,
|
|
&layout_builder,
|
|
LayoutOptions::new_from_textinput(text_input, Some(width), Some(height)),
|
|
window_adapter.window(),
|
|
|layout| {
|
|
layout.cursor_rect_for_byte_offset(byte_offset, affinity, cursor_width) / scale_factor
|
|
},
|
|
)
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// A `TextInput`'s laid-out text, lent to [`with_text_input_layout`]'s callback for one call.
|
|
#[allow(dead_code)]
|
|
pub struct TextInputLayout<'a> {
|
|
layout: &'a Layout,
|
|
/// The string the paragraphs were shaped from, which [`TextInputParagraph::range`] indexes.
|
|
text: &'a str,
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
impl<'a> TextInputLayout<'a> {
|
|
/// The paragraphs, top to bottom. A hard line break separates two of them and belongs to
|
|
/// neither, since Slint splits the text at `\n` before shaping.
|
|
pub(crate) fn paragraphs(&self) -> impl Iterator<Item = TextInputParagraph<'a>> {
|
|
let (text, y_offset) = (self.text, self.layout.y_offset);
|
|
self.layout.paragraphs.iter().map(move |para| TextInputParagraph {
|
|
range: para.range.clone(),
|
|
text: &text[para.range.clone()],
|
|
layout: ¶.layout,
|
|
y: y_offset + para.y,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// One paragraph of a [`TextInputLayout`].
|
|
#[allow(dead_code)]
|
|
pub(crate) struct TextInputParagraph<'a> {
|
|
/// Byte range within [`TextInputLayout::text`].
|
|
range: Range<usize>,
|
|
/// The slice of that text this paragraph covers.
|
|
text: &'a str,
|
|
/// Its shaped, line-broken and aligned glyphs.
|
|
layout: &'a parley::Layout<Brush>,
|
|
/// Physical y of its top edge, relative to the item's.
|
|
y: PhysicalLength,
|
|
}
|
|
|
|
/// Lays `text_input` out the way `renderer` draws it and lends the result to `f`.
|
|
///
|
|
/// `f` must not lay text out itself: the cache entry stays checked out for the call, and
|
|
/// re-entering it panics.
|
|
///
|
|
/// Returns `None` if the renderer lays no text out through parley, so the caller can tell that
|
|
/// apart from an empty layout.
|
|
pub fn with_text_input_layout<R>(
|
|
renderer: &(impl RendererSealed + ?Sized),
|
|
text_input: Pin<&crate::items::TextInput>,
|
|
item_rc: &crate::item_tree::ItemRc,
|
|
size: LogicalSize,
|
|
f: impl FnOnce(TextInputLayout<'_>) -> R,
|
|
) -> Option<R> {
|
|
if !renderer.text_input_has_parley_layout(text_input, item_rc) {
|
|
return None;
|
|
}
|
|
with_text_input_layout_impl(
|
|
renderer.scale_factor(),
|
|
renderer.window_adapter(),
|
|
renderer.text_layout_cache(),
|
|
text_input,
|
|
item_rc,
|
|
size,
|
|
f,
|
|
)
|
|
}
|
|
|
|
fn with_text_input_layout_impl<R>(
|
|
scale_factor: Option<ScaleFactor>,
|
|
window_adapter: Option<Rc<dyn WindowAdapter>>,
|
|
cache: Option<&TextLayoutCache>,
|
|
text_input: Pin<&crate::items::TextInput>,
|
|
item_rc: &crate::item_tree::ItemRc,
|
|
size: LogicalSize,
|
|
f: impl FnOnce(TextInputLayout<'_>) -> R,
|
|
) -> Option<R> {
|
|
let scale_factor = scale_factor?;
|
|
let window_adapter = window_adapter?;
|
|
|
|
let width = size.width_length();
|
|
let height = size.height_length();
|
|
if width.get() <= 0. || height.get() <= 0. {
|
|
return None;
|
|
}
|
|
|
|
let layout_builder =
|
|
shaping_builder(text_input, Some(item_rc), text_input.wrap(), scale_factor);
|
|
|
|
// `RenderString for TextInput` yields plain text; a styled input doesn't exist.
|
|
let PlainOrStyledText::Plain(text) = crate::item_rendering::RenderString::text(text_input)
|
|
else {
|
|
return None;
|
|
};
|
|
|
|
with_text_layout(
|
|
cache,
|
|
Some(item_rc),
|
|
text_input,
|
|
&layout_builder,
|
|
LayoutOptions::new_from_textinput(text_input, Some(width), Some(height)),
|
|
window_adapter.window(),
|
|
|layout| f(TextInputLayout { layout, text: &text }),
|
|
)
|
|
}
|
|
|
|
#[cfg(feature = "accessibility-text")]
|
|
mod accessibility;
|
|
#[cfg(feature = "accessibility-text")]
|
|
pub use accessibility::CachedTextInputAccessibilityState;
|