Sonar: reduce cognitive complexity in footage reload, edit delete and text layout

This commit is contained in:
Dylan M. Taylor
2026-10-26 16:01:49 -04:00
committed by GitHub
parent 6867f331f2
commit c618429252
3 changed files with 489 additions and 377 deletions
+37 -24
View File
@@ -2,7 +2,7 @@
use effectcraft_color::Label;
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{Comp, Expression, ItemKind, Layer, LayerId, LayerSource, TrackMatte};
use effectcraft_project::{Comp, Expression, ItemId, ItemKind, Layer, LayerId, LayerSource, TrackMatte};
use effectcraft_time::Tick;
use serde_json::{Value, json};
@@ -160,45 +160,62 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
/// at a removed layer and rewrites layer-reference values and keys to `None` in the same undo
/// step; the text-edit session is then sanitized so a removed layer's editor is not reused.
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
if s.state.text_edit.is_some() && p.get("layers").is_none() {
return s.execute("text.delete", json!({}));
if let Some(done) = delete_selection(s, p)? {
return Ok(done);
}
let (cid, ids) = layers_p(s, p)?;
let ids = super::unlocked(s, cid, ids, "edit.clear")?;
clear_layers(s, cid, &ids)?;
// A removed layer may have been the edited one: end that session before another command
// can reuse it. Undo restores a layer as a new editor, never by reviving the old IME session.
s.sanitize_state();
Ok(json!(ids.len()))
}
/// Deletes the selected things that are not layers. `None` when the layers are deleted
/// instead (nothing else is selected, or the command names its layers).
fn delete_selection(s: &mut Session, p: &Value) -> Result<Option<Value>> {
let no_layers = p.get("layers").is_none();
if s.state.text_edit.is_some() && no_layers {
return Ok(Some(s.execute("text.delete", json!({}))?));
}
// Time Remap selected (its name, which selects all its keys) → time remapping off, not the
// layer (After Effects).
if p.get("layers").is_none() {
if no_layers {
let remapped = super::layer_time::selected_time_remap(s);
if !remapped.is_empty() {
return super::layer_time::disable_time_remap(s, &remapped);
return Ok(Some(super::layer_time::disable_time_remap(s, &remapped)?));
}
}
// Keyframes selected → delete keys; mask vertices → delete them; else layers.
if !s.state.selected_keys.is_empty() && p.get("layers").is_none() {
return s.execute("keys.delete", json!({}));
if !s.state.selected_keys.is_empty() && no_layers {
return Ok(Some(s.execute("keys.delete", json!({}))?));
}
// (Points selected with their mask, which selecting a mask does: the mask goes, below.)
let comp = s.active_comp();
let loose = s.state.selected_vertices.iter().any(|v| {
!s.state.selected_props.contains(&(v.layer, v.mask)) && comp.and_then(|c| c.layer(v.layer)).is_some_and(|l| l.props.find_group(v.mask).is_some())
});
if loose && p.get("layers").is_none() {
return s.execute("mask.deleteVertices", json!({}));
if loose && no_layers {
return Ok(Some(s.execute("mask.deleteVertices", json!({}))?));
}
// Puppet pins selected → delete the pins (not their layer).
if p.get("layers").is_none() && !super::puppet::selected_pins(s).is_empty() {
return s.execute("puppet.removePin", json!({}));
if no_layers && !super::puppet::selected_pins(s).is_empty() {
return Ok(Some(s.execute("puppet.removePin", json!({}))?));
}
// Effects selected → remove them.
if p.get("layers").is_none() && !super::effect::selected_effects(s).is_empty() {
return s.execute("effect.remove", json!({}));
if no_layers && !super::effect::selected_effects(s).is_empty() {
return Ok(Some(s.execute("effect.remove", json!({}))?));
}
// Masks, shape items, text animators or trackers selected → remove them.
if p.get("layers").is_none()
&& let Some(n) = super::prop_groups::remove_selected(s)?
{
return Ok(n);
if no_layers && let Some(n) = super::prop_groups::remove_selected(s)? {
return Ok(Some(n));
}
let (cid, ids) = layers_p(s, p)?;
let ids = super::unlocked(s, cid, ids, "edit.clear")?;
Ok(None)
}
/// Removes layers `ids` from comp `cid` in one undoable step, and clears what referred to them.
fn clear_layers(s: &mut Session, cid: ItemId, ids: &[LayerId]) -> Result<()> {
// Children of deleted layers are unparented where they are (like After Effects).
let comp = s.project.comp(cid).ok_or(crate::EngineError::NoComp)?;
let orphans: Vec<LayerId> = comp.layers.iter().filter(|l| !ids.contains(&l.id) && l.parent.is_some_and(|p| ids.contains(&p))).map(|l| l.id).collect();
@@ -236,11 +253,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
st.selected_props.clear();
st.selected_keys.clear();
Ok(())
})?;
// A removed layer may have been the edited one: end that session before another command
// can reuse it. Undo restores a layer as a new editor, never by reviving the old IME session.
s.sanitize_state();
Ok(json!(ids.len()))
})
}
fn clear_clipboards(s: &mut Session) {
+9 -2
View File
@@ -159,6 +159,14 @@ fn reload(s: &mut Session, ids: &[ItemId], undoable: bool) -> crate::Result<(usi
}
}
let n = updates.len();
apply_reloads(s, updates, undoable)?;
forget(s, &paths);
Ok((n, failed))
}
/// Puts reloaded footage into the project: an undoable edit, or (not undoable) a disk change
/// that leaves the project's dirty state as it was.
fn apply_reloads(s: &mut Session, updates: Vec<(ItemId, Footage)>, undoable: bool) -> crate::Result<()> {
if undoable {
s.edit("Reload Footage", None, |proj, _| {
for (i, f) in updates {
@@ -188,8 +196,7 @@ fn reload(s: &mut Session, ids: &[ItemId], undoable: bool) -> crate::Result<(usi
}
}
}
forget(s, &paths);
Ok((n, failed))
Ok(())
}
/// Make the media layer and the layer cache let go of `paths`, so renders read them again.
+443 -351
View File
@@ -20,7 +20,7 @@ use unicode_bidi::{BidiInfo, Level};
use effectcraft_keyframe::{OpenType, text_doc::paragraph_ranges};
use crate::fonts::{self, FaceId, Resolved};
use crate::fonts::{self, FaceId, Resolved, VMetrics};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum Caps {
@@ -597,61 +597,378 @@ fn run_at(runs: &[(Range<usize>, TextStyle)], b: usize) -> usize {
}
/// Lay out one paragraph (no newlines) whose text starts at byte `base` of the whole string.
fn paragraph(
text: &str,
base: usize,
runs: &[(Range<usize>, TextStyle)],
primaries: &[Resolved],
variation_ids: &[u32],
para: &ParagraphStyle,
width: Option<f32>,
) -> Vec<ParaLine> {
let rtl_para = para.rtl == Some(true);
let margins_for = |first: bool| -> (f32, f32) {
let fi = if first { para.indent_first } else { 0.0 };
if rtl_para { (para.indent_end, para.indent_start + fi) } else { (para.indent_start + fi, para.indent_end) }
};
let avail_for = |first: bool| -> Option<f32> {
let (l, r) = margins_for(first);
width.map(|w| (w - l - r).max(1.0))
};
let style_metrics = |si: usize| {
let st = &runs[si].1;
fonts::face(primaries[si].face).metrics(st.glyph_size())
};
let eff_align = |last: bool| -> Align {
if (last || width.is_none()) && para.align == Align::Justify {
if width.is_none() && para.justify_last == Align::Justify { Align::Left } else { para.justify_last }
} else {
para.align
}
};
if text.is_empty() {
let si = run_at(runs, base);
let pm = style_metrics(si);
return vec![ParaLine {
range: base..base,
glyphs: vec![],
carets: vec![(base, 0.0)],
content: 0.0,
left_trim: 0.0,
hang: (0.0, 0.0),
ascent: pm.ascent,
descent: pm.descent,
advance: runs[si].1.line_advance(),
align: eff_align(true),
avail: avail_for(true),
margins: margins_for(true),
underline: None,
}];
/// Margins (left, right) of a paragraph's first or later lines.
fn margins_for(para: &ParagraphStyle, first: bool) -> (f32, f32) {
let fi = if first { para.indent_first } else { 0.0 };
if para.rtl == Some(true) { (para.indent_end, para.indent_start + fi) } else { (para.indent_start + fi, para.indent_end) }
}
/// The width a line may fill (`None` for point text).
fn avail_for(para: &ParagraphStyle, width: Option<f32>, first: bool) -> Option<f32> {
let (l, r) = margins_for(para, first);
width.map(|w| (w - l - r).max(1.0))
}
/// The alignment of a line, justified lines that cannot stretch falling back to `justify_last`.
fn align_for(para: &ParagraphStyle, width: Option<f32>, last: bool) -> Align {
if (last || width.is_none()) && para.align == Align::Justify {
if width.is_none() && para.justify_last == Align::Justify { Align::Left } else { para.justify_last }
} else {
para.align
}
let default_level = para.rtl.map(|r| if r { Level::rtl() } else { Level::ltr() });
let bidi = BidiInfo::new(text, default_level);
let pinfo = &bidi.paragraphs[0];
let base_rtl = pinfo.level.is_rtl();
// per char: (byte, char, shaped char, face, rtl, form, run)
let chars: Vec<(usize, char, char, FaceId, bool, Form, usize)> = text
.char_indices()
}
fn style_metrics(primaries: &[Resolved], runs: &[(Range<usize>, TextStyle)], si: usize) -> VMetrics {
fonts::face(primaries[si].face).metrics(runs[si].1.glyph_size())
}
/// A char's shaping inputs: (byte, char, shaped char, face, rtl, form, run).
type CharInfo = (usize, char, char, FaceId, bool, Form, usize);
/// The glyphs of one line in visual order, where its pen ended, the extent of each cluster by
/// byte, and the underline span.
struct Placed {
glyphs: Vec<Glyph>,
pen: f32,
extents: HashMap<usize, (f32, f32, bool)>,
underline: Option<(f32, f32)>,
}
/// A paragraph shaped and measured, ready to be broken into lines.
struct Para<'a> {
text: &'a str,
base: usize,
runs: &'a [(Range<usize>, TextStyle)],
primaries: &'a [Resolved],
variation_ids: &'a [u32],
para: &'a ParagraphStyle,
width: Option<f32>,
bidi: BidiInfo<'a>,
base_rtl: bool,
chars: Vec<CharInfo>,
items: Vec<Item>,
/// Advance of the glyphs at each byte, and prefix sums over it.
char_adv: Vec<f32>,
prefix_adv: Vec<f64>,
previous_non_ws: Vec<usize>,
}
impl<'a> Para<'a> {
fn new(
text: &'a str,
base: usize,
runs: &'a [(Range<usize>, TextStyle)],
primaries: &'a [Resolved],
variation_ids: &'a [u32],
para: &'a ParagraphStyle,
width: Option<f32>,
) -> Self {
let default_level = para.rtl.map(|r| if r { Level::rtl() } else { Level::ltr() });
let bidi = BidiInfo::new(text, default_level);
let base_rtl = bidi.paragraphs[0].level.is_rtl();
let chars = char_forms(text, base, runs, primaries, &bidi);
let items = build_items(text, &chars, runs);
// Prefix widths and the preceding non-whitespace boundary make candidate line widths O(1).
// Every-line composition evaluates O(n²) candidate breaks; summing each candidate's bytes
// or rescanning its trailing whitespace would otherwise add another factor of text length.
let mut char_adv = vec![0.0f32; text.len() + 1];
for it in &items {
for g in &it.glyphs {
char_adv[g.cluster.min(text.len())] += g.adv + g.pre;
}
}
let mut prefix_adv = vec![0.0f64; text.len() + 1];
for i in 0..text.len() {
prefix_adv[i + 1] = prefix_adv[i] + f64::from(char_adv[i]);
}
let mut previous_non_ws = vec![0usize; text.len() + 1];
let mut non_ws_end = 0usize;
for (b, c) in text.char_indices() {
previous_non_ws[b] = non_ws_end;
if !c.is_whitespace() {
non_ws_end = b + c.len_utf8();
}
previous_non_ws[b + c.len_utf8()] = non_ws_end;
}
Para { text, base, runs, primaries, variation_ids, para, width, bidi, base_rtl, chars, items, char_adv, prefix_adv, previous_non_ws }
}
fn range_w(&self, r: Range<usize>) -> f32 {
(self.prefix_adv[r.end] - self.prefix_adv[r.start]) as f32
}
fn trailing_ws(&self, r: &Range<usize>) -> (usize, f32) {
let end = self.previous_non_ws[r.end].max(r.start).min(r.end);
(end, (self.prefix_adv[r.end] - self.prefix_adv[end]) as f32)
}
/// Hanging punctuation widths (left, right) of a line's content.
fn hang_of(&self, r: &Range<usize>, ce: usize) -> (f32, f32) {
if !self.para.hanging || self.width.is_none() || r.start >= ce {
return (0.0, 0.0);
}
let first = self.text[r.start..ce].chars().next().filter(|c| is_hanging_open(*c)).map_or(0.0, |_| self.char_adv[r.start]);
let last = self.text[r.start..ce].char_indices().next_back().filter(|(_, c)| is_hanging_close(*c)).map_or(0.0, |(b, _)| self.char_adv[r.start + b]);
(first, last)
}
fn fits_w(&self, r: &Range<usize>) -> f32 {
let (ce, _) = self.trailing_ws(r);
let (hl, hr) = self.hang_of(r, ce);
self.range_w(r.start..ce) - hl - hr
}
fn line_avail(&self, line: usize) -> f32 {
avail_for(self.para, self.width, line == 0).unwrap_or(f32::MAX)
}
/// The byte ranges of the lines, for the wrap width.
fn break_ranges(&self) -> Vec<Range<usize>> {
if self.width.is_none() {
return std::iter::once(0..self.text.len()).collect();
}
let opps: Vec<usize> = unicode_linebreak::linebreaks(self.text).map(|(p, _)| p).collect();
let mut ranges = if self.para.every_line && opps.len() > 1 && opps.len() <= 1500 { self.optimal_breaks(&opps) } else { self.greedy_breaks(opps) };
ranges.retain(|r| !r.is_empty());
if ranges.is_empty() {
ranges.push(0..self.text.len());
}
ranges
}
/// Minimises the summed squared slack of every line but the last.
fn optimal_breaks(&self, opps: &[usize]) -> Vec<Range<usize>> {
let nodes: Vec<usize> = std::iter::once(0).chain(opps.iter().copied()).collect();
let m = nodes.len();
let mut best = vec![(f32::INFINITY, 0usize); m];
best[0] = (0.0, 0);
for j in 1..m {
for i in (0..j).rev() {
if !best[i].0.is_finite() {
continue;
}
let r = nodes[i]..nodes[j];
let w = self.fits_w(&r);
let maxw = self.line_avail(usize::from(i != 0));
let over = w > maxw;
if over && i + 1 != j {
break;
}
let slack = maxw - w;
let cost = if over {
1e12 + w
} else if j == m - 1 {
0.0
} else {
slack * slack
};
let total = best[i].0 + cost;
if total < best[j].0 {
best[j] = (total, i);
}
}
}
let mut ranges = Vec::new();
let mut j = m - 1;
while j > 0 {
let i = best[j].1;
ranges.push(nodes[i]..nodes[j]);
j = i;
}
ranges.reverse();
ranges
}
/// Breaks at each opportunity that still fits, else at the last one that did.
fn greedy_breaks(&self, opps: Vec<usize>) -> Vec<Range<usize>> {
let mut ranges = Vec::new();
let mut start = 0usize;
let mut last_ok: Option<usize> = None;
for p in opps {
loop {
let w = self.fits_w(&(start..p));
if w <= self.line_avail(ranges.len()) || last_ok.is_none() {
last_ok = Some(p);
break;
}
let Some(b) = last_ok.take() else { break };
ranges.push(start..b);
start = b;
}
}
ranges.push(start..self.text.len());
ranges
}
/// The line over byte range `r`: the `li`-th of the paragraph, `last` if it is the final one.
fn place_line(&self, li: usize, r: Range<usize>, last: bool) -> ParaLine {
let align = align_for(self.para, self.width, last);
let avail = avail_for(self.para, self.width, li == 0);
let (ce, tw) = self.trailing_ws(&r);
let hang = self.hang_of(&r, ce);
let content = self.range_w(r.start..ce);
// justification
let mut space_extra = 0.0;
if align == Align::Justify
&& let Some(maxw) = avail
{
let spaces = self.text[r.start..ce].chars().filter(|c| *c == ' ').count();
if spaces > 0 {
space_extra = ((maxw - (content - hang.0 - hang.1)) / spaces as f32).max(0.0);
}
}
let placed = self.place_glyphs(&r, space_extra, ce);
let carets = self.carets(&r, &placed.extents, placed.pen);
let (asc, desc, adv) = self.line_metrics(&r, &placed.glyphs);
let content_w = if space_extra > 0.0 { avail.map_or(content, |a| a + hang.0 + hang.1) } else { content };
ParaLine {
range: self.base + r.start..self.base + r.end,
glyphs: placed.glyphs,
carets,
content: content_w,
left_trim: if self.base_rtl { tw } else { 0.0 },
hang,
ascent: asc,
descent: desc,
advance: adv,
align,
avail,
margins: margins_for(self.para, li == 0),
underline: placed.underline,
}
}
fn place_glyphs(&self, r: &Range<usize>, space_extra: f32, ce: usize) -> Placed {
let (levels, vruns) = self.bidi.visual_runs(&self.bidi.paragraphs[0], r.clone());
let mut pen = 0.0f32;
let mut glyphs = Vec::new();
let mut extents: HashMap<usize, (f32, f32, bool)> = HashMap::new();
let mut underline: Option<(f32, f32)> = None;
for run in vruns {
let rtl = levels[run.start].is_rtl();
// Items are ordered and disjoint: binary-search both ends of the overlapping slice.
let run_limit = self.items.partition_point(|item| item.range.start < run.end);
let run_first = self.items[..run_limit].partition_point(|item| item.range.end <= run.start);
let mut idx: Vec<usize> = (run_first..run_limit).collect();
if rtl {
idx.reverse();
}
for k in idx {
for g in self.items[k].glyphs.iter().filter(|g| run.contains(&g.cluster)) {
let st = &self.runs[g.run].1;
let x0 = pen;
pen += g.pre;
glyphs.push(Glyph {
face: g.face,
id: g.id,
x: pen + g.dx,
y: -g.dy - g.rise,
size: g.size,
cluster: self.base + g.cluster,
synth_bold: self.primaries[g.run].synth_bold || st.faux_bold,
synth_italic: self.primaries[g.run].synth_italic || st.faux_italic,
h_scale: st.h_scale,
v_scale: st.v_scale,
run: g.run,
variations: self.variation_ids[g.run],
});
pen += g.adv;
if space_extra > 0.0 && self.text[g.cluster..].starts_with(' ') && g.cluster < ce {
pen += space_extra;
}
if st.underline && g.cluster < ce {
let u = underline.get_or_insert((x0, pen));
u.0 = u.0.min(x0);
u.1 = u.1.max(pen);
}
let e = extents.entry(g.cluster).or_insert((x0, pen, self.items[k].rtl));
e.0 = e.0.min(x0);
e.1 = e.1.max(pen);
}
}
}
Placed { glyphs, pen, extents, underline }
}
/// Caret stops at each char boundary of the line, and at its end.
fn carets(&self, r: &Range<usize>, extents: &HashMap<usize, (f32, f32, bool)>, pen: f32) -> Vec<(usize, f32)> {
let mut carets = Vec::new();
let mut cluster_starts: Vec<usize> = extents.keys().copied().collect();
cluster_starts.sort_unstable();
let char_bytes: Vec<usize> = self.text[r.clone()].char_indices().map(|(b, _)| r.start + b).collect();
let mut char_index = 0usize;
for (ci, &cs) in cluster_starts.iter().enumerate() {
let (x0, x1, rtl) = extents[&cs];
let next = cluster_starts.get(ci + 1).copied().unwrap_or(r.end);
while char_index < char_bytes.len() && char_bytes[char_index] < cs {
char_index += 1;
}
let begin = char_index;
while char_index < char_bytes.len() && char_bytes[char_index] < next {
char_index += 1;
}
let count = (char_index - begin).max(1) as f32;
for (mi, b) in char_bytes[begin..char_index].iter().enumerate() {
let f = mi as f32 / count;
let x = if rtl { x1 - (x1 - x0) * f } else { x0 + (x1 - x0) * f };
carets.push((self.base + b, x));
}
}
// end of line caret
let end_x = match self.chars.iter().rev().find(|c| c.0 < r.end && c.0 >= r.start) {
Some(c) => {
let cs = cluster_starts.iter().rev().find(|s| **s <= c.0).copied();
match cs.and_then(|s| extents.get(&s)) {
Some(&(x0, x1, rtl)) => {
if rtl {
x0
} else {
x1
}
}
None => pen,
}
}
None => 0.0,
};
carets.push((self.base + r.end, end_x));
carets.sort_by_key(|c| c.0);
carets.dedup_by_key(|c| c.0);
carets
}
/// Ascent, descent and advance of the line: the tallest face and the largest leading on it.
fn line_metrics(&self, r: &Range<usize>, glyphs: &[Glyph]) -> (f32, f32, f32) {
let (mut asc, mut desc, mut adv) = (0.0f32, 0.0f32, 0.0f32);
let char_start = self.chars.partition_point(|c| c.0 < r.start);
let char_end = self.chars.partition_point(|c| c.0 < r.end);
let mut seen: Vec<usize> = self.chars[char_start..char_end].iter().map(|c| c.6).collect();
seen.sort_unstable();
seen.dedup();
if seen.is_empty() {
seen.push(self.chars.first().map_or(0, |c| c.6));
}
for &si in &seen {
let m = style_metrics(self.primaries, self.runs, si);
let rise = self.runs[si].1.rise();
asc = asc.max(m.ascent + rise.max(0.0));
desc = desc.max(m.descent - rise.min(0.0));
adv = adv.max(self.runs[si].1.line_advance());
}
for g in glyphs {
if g.face != self.primaries[g.run].face {
let m = fonts::face(g.face).metrics(g.size);
asc = asc.max(m.ascent);
desc = desc.max(m.descent);
}
}
(asc, desc, adv)
}
}
/// Each char with its shaped form, face, direction and run.
fn char_forms(text: &str, base: usize, runs: &[(Range<usize>, TextStyle)], primaries: &[Resolved], bidi: &BidiInfo) -> Vec<CharInfo> {
text.char_indices()
.map(|(b, c)| {
let si = run_at(runs, base + b);
let style = &runs[si].1;
@@ -677,13 +994,15 @@ fn paragraph(
}
(b, c, sc, face, bidi.levels[b].is_rtl(), form, si)
})
.collect();
// items: runs of equal (face, rtl, small, style)
.collect()
}
/// Runs of chars with the same face, direction, form, run and CJK vertical flag, shaped as one.
fn build_items(text: &str, chars: &[CharInfo], runs: &[(Range<usize>, TextStyle)]) -> Vec<Item> {
let key = |c: &CharInfo| (c.3, c.4, c.5, c.6, runs.get(c.6).is_some_and(|run| run.1.vertical) && crate::is_cjk(c.1));
let mut items: Vec<Item> = Vec::new();
let mut i = 0;
while i < chars.len() {
let key =
|c: &(usize, char, char, FaceId, bool, Form, usize)| (c.3, c.4, c.5, c.6, runs.get(c.6).is_some_and(|run| run.1.vertical) && crate::is_cjk(c.1));
let k0 = key(&chars[i]);
let mut j = i + 1;
while j < chars.len() && key(&chars[j]) == k0 {
@@ -703,272 +1022,43 @@ fn paragraph(
items.push(Item { range: chars[i].0..end, rtl, glyphs });
i = j;
}
// advance per char byte (cluster start)
let mut char_adv = vec![0.0f32; text.len() + 1];
for it in &items {
for g in &it.glyphs {
char_adv[g.cluster.min(text.len())] += g.adv + g.pre;
}
items
}
fn paragraph(
text: &str,
base: usize,
runs: &[(Range<usize>, TextStyle)],
primaries: &[Resolved],
variation_ids: &[u32],
para: &ParagraphStyle,
width: Option<f32>,
) -> Vec<ParaLine> {
if text.is_empty() {
let si = run_at(runs, base);
let pm = style_metrics(primaries, runs, si);
return vec![ParaLine {
range: base..base,
glyphs: vec![],
carets: vec![(base, 0.0)],
content: 0.0,
left_trim: 0.0,
hang: (0.0, 0.0),
ascent: pm.ascent,
descent: pm.descent,
advance: runs[si].1.line_advance(),
align: align_for(para, width, true),
avail: avail_for(para, width, true),
margins: margins_for(para, true),
underline: None,
}];
}
// Prefix widths and the preceding non-whitespace boundary make candidate line widths O(1).
// Every-line composition evaluates O(n²) candidate breaks; summing each candidate's bytes
// or rescanning its trailing whitespace would otherwise add another factor of text length.
let mut prefix_adv = vec![0.0f64; text.len() + 1];
for i in 0..text.len() {
prefix_adv[i + 1] = prefix_adv[i] + f64::from(char_adv[i]);
}
let mut previous_non_ws = vec![0usize; text.len() + 1];
let mut non_ws_end = 0usize;
for (b, c) in text.char_indices() {
previous_non_ws[b] = non_ws_end;
if !c.is_whitespace() {
non_ws_end = b + c.len_utf8();
}
previous_non_ws[b + c.len_utf8()] = non_ws_end;
}
let range_w = |r: Range<usize>| -> f32 { (prefix_adv[r.end] - prefix_adv[r.start]) as f32 };
let trailing_ws = |r: &Range<usize>| -> (usize, f32) {
let end = previous_non_ws[r.end].max(r.start).min(r.end);
(end, (prefix_adv[r.end] - prefix_adv[end]) as f32)
};
// Hanging punctuation widths (left, right) of a line's content.
let hang_of = |r: &Range<usize>, ce: usize| -> (f32, f32) {
if !para.hanging || width.is_none() || r.start >= ce {
return (0.0, 0.0);
}
let first = text[r.start..ce].chars().next().filter(|c| is_hanging_open(*c)).map_or(0.0, |_| char_adv[r.start]);
let last = text[r.start..ce].char_indices().next_back().filter(|(_, c)| is_hanging_close(*c)).map_or(0.0, |(b, _)| char_adv[r.start + b]);
(first, last)
};
let fits_w = |r: &Range<usize>| -> f32 {
let (ce, _) = trailing_ws(r);
let (hl, hr) = hang_of(r, ce);
range_w(r.start..ce) - hl - hr
};
// line breaking
let mut ranges: Vec<Range<usize>> = Vec::new();
match width {
None => ranges.push(0..text.len()),
Some(_) => {
let opps: Vec<usize> = unicode_linebreak::linebreaks(text).map(|(p, _)| p).collect();
let avail = |line: usize| avail_for(line == 0).unwrap_or(f32::MAX);
if para.every_line && opps.len() > 1 && opps.len() <= 1500 {
// Minimise the summed squared slack of every line but the last.
let nodes: Vec<usize> = std::iter::once(0).chain(opps.iter().copied()).collect();
let m = nodes.len();
let mut best = vec![(f32::INFINITY, 0usize); m];
best[0] = (0.0, 0);
for j in 1..m {
for i in (0..j).rev() {
if !best[i].0.is_finite() {
continue;
}
let r = nodes[i]..nodes[j];
let w = fits_w(&r);
let maxw = avail(usize::from(i != 0));
let over = w > maxw;
if over && i + 1 != j {
break;
}
let slack = maxw - w;
let cost = if over {
1e12 + w
} else if j == m - 1 {
0.0
} else {
slack * slack
};
let total = best[i].0 + cost;
if total < best[j].0 {
best[j] = (total, i);
}
}
}
let mut j = m - 1;
while j > 0 {
let i = best[j].1;
ranges.push(nodes[i]..nodes[j]);
j = i;
}
ranges.reverse();
} else {
let mut start = 0usize;
let mut last_ok: Option<usize> = None;
for p in opps {
loop {
let w = fits_w(&(start..p));
if w <= avail(ranges.len()) || last_ok.is_none() {
last_ok = Some(p);
break;
}
let Some(b) = last_ok.take() else { break };
ranges.push(start..b);
start = b;
}
}
ranges.push(start..text.len());
}
ranges.retain(|r| !r.is_empty());
if ranges.is_empty() {
ranges.push(0..text.len());
}
}
}
let p = Para::new(text, base, runs, primaries, variation_ids, para, width);
let ranges = p.break_ranges();
let nlines = ranges.len();
let mut out = Vec::new();
for (li, r) in ranges.into_iter().enumerate() {
let last = li + 1 == nlines;
let align = eff_align(last);
let avail = avail_for(li == 0);
let (ce, tw) = trailing_ws(&r);
let hang = hang_of(&r, ce);
let content = range_w(r.start..ce);
// justification
let mut space_extra = 0.0;
if align == Align::Justify
&& let Some(maxw) = avail
{
let spaces = text[r.start..ce].chars().filter(|c| *c == ' ').count();
if spaces > 0 {
space_extra = ((maxw - (content - hang.0 - hang.1)) / spaces as f32).max(0.0);
}
}
let (levels, vruns) = bidi.visual_runs(pinfo, r.clone());
let mut pen = 0.0f32;
let mut glyphs = Vec::new();
let mut extents: HashMap<usize, (f32, f32, bool)> = HashMap::new();
let mut underline: Option<(f32, f32)> = None;
for run in vruns {
let rtl = levels[run.start].is_rtl();
// Items are ordered and disjoint: binary-search both ends of the overlapping slice.
let run_limit = items.partition_point(|item| item.range.start < run.end);
let run_first = items[..run_limit].partition_point(|item| item.range.end <= run.start);
let mut idx: Vec<usize> = (run_first..run_limit).collect();
if rtl {
idx.reverse();
}
for k in idx {
for g in items[k].glyphs.iter().filter(|g| run.contains(&g.cluster)) {
let st = &runs[g.run].1;
let x0 = pen;
pen += g.pre;
glyphs.push(Glyph {
face: g.face,
id: g.id,
x: pen + g.dx,
y: -g.dy - g.rise,
size: g.size,
cluster: base + g.cluster,
synth_bold: primaries[g.run].synth_bold || st.faux_bold,
synth_italic: primaries[g.run].synth_italic || st.faux_italic,
h_scale: st.h_scale,
v_scale: st.v_scale,
run: g.run,
variations: variation_ids[g.run],
});
pen += g.adv;
if space_extra > 0.0 && text[g.cluster..].starts_with(' ') && g.cluster < ce {
pen += space_extra;
}
if st.underline && g.cluster < ce {
let u = underline.get_or_insert((x0, pen));
u.0 = u.0.min(x0);
u.1 = u.1.max(pen);
}
let e = extents.entry(g.cluster).or_insert((x0, pen, items[k].rtl));
e.0 = e.0.min(x0);
e.1 = e.1.max(pen);
}
}
}
// Caret stops for each char boundary in the line.
let mut carets = Vec::new();
let mut cluster_starts: Vec<usize> = extents.keys().copied().collect();
cluster_starts.sort_unstable();
let char_bytes: Vec<usize> = text[r.clone()].char_indices().map(|(b, _)| r.start + b).collect();
let mut char_index = 0usize;
for (ci, &cs) in cluster_starts.iter().enumerate() {
let (x0, x1, rtl) = extents[&cs];
let next = cluster_starts.get(ci + 1).copied().unwrap_or(r.end);
while char_index < char_bytes.len() && char_bytes[char_index] < cs {
char_index += 1;
}
let begin = char_index;
while char_index < char_bytes.len() && char_bytes[char_index] < next {
char_index += 1;
}
let count = (char_index - begin).max(1) as f32;
for (mi, b) in char_bytes[begin..char_index].iter().enumerate() {
let f = mi as f32 / count;
let x = if rtl { x1 - (x1 - x0) * f } else { x0 + (x1 - x0) * f };
carets.push((base + b, x));
}
}
// end of line caret
let end_x = match chars.iter().rev().find(|c| c.0 < r.end && c.0 >= r.start) {
Some(c) => {
let cs = cluster_starts.iter().rev().find(|s| **s <= c.0).copied();
match cs.and_then(|s| extents.get(&s)) {
Some(&(x0, x1, rtl)) => {
if rtl {
x0
} else {
x1
}
}
None => pen,
}
}
None => 0.0,
};
carets.push((base + r.end, end_x));
carets.sort_by_key(|c| c.0);
carets.dedup_by_key(|c| c.0);
// Line metrics: the tallest face and the largest leading on the line.
let (mut asc, mut desc, mut adv) = (0.0f32, 0.0f32, 0.0f32);
let char_start = chars.partition_point(|c| c.0 < r.start);
let char_end = chars.partition_point(|c| c.0 < r.end);
let mut seen: Vec<usize> = chars[char_start..char_end].iter().map(|c| c.6).collect();
seen.sort_unstable();
seen.dedup();
if seen.is_empty() {
seen.push(chars.first().map_or(0, |c| c.6));
}
for &si in &seen {
let m = style_metrics(si);
let rise = runs[si].1.rise();
asc = asc.max(m.ascent + rise.max(0.0));
desc = desc.max(m.descent - rise.min(0.0));
adv = adv.max(runs[si].1.line_advance());
}
for g in &glyphs {
if g.face != primaries[g.run].face {
let m = fonts::face(g.face).metrics(g.size);
asc = asc.max(m.ascent);
desc = desc.max(m.descent);
}
}
let content_w = if space_extra > 0.0 { avail.map_or(content, |a| a + hang.0 + hang.1) } else { content };
out.push(ParaLine {
range: base + r.start..base + r.end,
glyphs,
carets,
content: content_w,
left_trim: if base_rtl { tw } else { 0.0 },
hang,
ascent: asc,
descent: desc,
advance: adv,
align,
avail,
margins: margins_for(li == 0),
underline,
});
}
out
ranges.into_iter().enumerate().map(|(li, r)| p.place_line(li, r, li + 1 == nlines)).collect()
}
/// Lay out `text` (paragraphs separated by CR, LF or CRLF) in one style.
pub fn layout_uncached(text: &str, style: &TextStyle, para: &ParagraphStyle) -> Layout {
layout_rich_uncached(text, &[(0..text.len(), style.clone())], std::slice::from_ref(para))
}
@@ -992,10 +1082,8 @@ pub fn layout_rich_uncached(text: &str, runs: &[(Range<usize>, TextStyle)], para
let primaries: Vec<Resolved> = runs.iter().map(|(_, s)| fonts::resolve(&s.family, &s.style)).collect();
// A named instance of a variable font (`Figtree` `Black`, or `Figtree-Black`) sets the base
// axis values; axes set explicitly in the style keep theirs.
let instanced: Vec<(Range<usize>, TextStyle)>;
let runs = if primaries.iter().any(|p| p.instance.is_some()) {
instanced = runs
.iter()
let instanced = primaries.iter().any(|p| p.instance.is_some()).then(|| {
runs.iter()
.zip(&primaries)
.map(|((r, s), p)| {
let base = fonts::instance_variations(p);
@@ -1005,16 +1093,12 @@ pub fn layout_rich_uncached(text: &str, runs: &[(Range<usize>, TextStyle)], para
}
(r.clone(), s)
})
.collect();
&instanced[..]
} else {
runs
};
.collect::<Vec<_>>()
});
let runs = instanced.as_deref().unwrap_or(runs);
// A location that cannot be interned would outline at the default; shape there too.
let cleared: Vec<(Range<usize>, TextStyle)>;
let runs = if runs.iter().any(|(_, s)| !s.variations.is_empty() && crate::variable::intern(&s.variations) == 0) {
cleared = runs
.iter()
let cleared = runs.iter().any(|(_, s)| !s.variations.is_empty() && crate::variable::intern(&s.variations) == 0).then(|| {
runs.iter()
.map(|(r, s)| {
let mut s = s.clone();
if crate::variable::intern(&s.variations) == 0 {
@@ -1022,11 +1106,9 @@ pub fn layout_rich_uncached(text: &str, runs: &[(Range<usize>, TextStyle)], para
}
(r.clone(), s)
})
.collect();
&cleared[..]
} else {
runs
};
.collect::<Vec<_>>()
});
let runs = cleared.as_deref().unwrap_or(runs);
let variation_ids: Vec<u32> = runs.iter().map(|(_, s)| crate::variable::intern(&s.variations)).collect();
let mut lay = Layout { missing_font: primaries.iter().any(|p| p.missing), text_len: text.len(), ..Default::default() };
let mut plines: Vec<(usize, ParaLine)> = Vec::new();
@@ -1047,17 +1129,7 @@ pub fn layout_rich_uncached(text: &str, runs: &[(Range<usize>, TextStyle)], para
}
baseline += adv;
}
let shown = pl.content - pl.hang.0 - pl.hang.1;
let (ml, mr) = pl.margins;
let shift = match (pl.avail, pl.align) {
(None, Align::Left | Align::Justify) => ml,
(None, Align::Center) => -shown / 2.0 + (ml - mr) / 2.0,
(None, Align::Right) => -shown - mr,
(Some(_), Align::Left | Align::Justify) => ml,
(Some(a), Align::Center) => ml + (a - shown) / 2.0,
(Some(a), Align::Right) => ml + a - shown,
} - pl.left_trim
- pl.hang.0;
let shift = line_shift(&pl);
let g0 = lay.glyphs.len();
lay.glyphs.extend(pl.glyphs.into_iter().map(|mut g| {
g.x += shift;
@@ -1086,6 +1158,27 @@ pub fn layout_rich_uncached(text: &str, runs: &[(Range<usize>, TextStyle)], para
for i in 0..lay.lines.len() {
lay.lines[i].para_end = lay.lines.get(i + 1).is_none_or(|n| n.para != lay.lines[i].para);
}
set_bounds(&mut lay, width);
lay
}
/// Horizontal shift of a line from its paragraph's alignment, margins and hanging punctuation.
fn line_shift(pl: &ParaLine) -> f32 {
let shown = pl.content - pl.hang.0 - pl.hang.1;
let (ml, mr) = pl.margins;
let shift = match (pl.avail, pl.align) {
(None, Align::Left | Align::Justify) => ml,
(None, Align::Center) => -shown / 2.0 + (ml - mr) / 2.0,
(None, Align::Right) => -shown - mr,
(Some(_), Align::Left | Align::Justify) => ml,
(Some(a), Align::Center) => ml + (a - shown) / 2.0,
(Some(a), Align::Right) => ml + a - shown,
};
shift - pl.left_trim - pl.hang.0
}
/// The layout's bounds: the lines' horizontal extent (and the wrap width), and their vertical extent.
fn set_bounds(lay: &mut Layout, width: Option<f32>) {
let (mut x0, mut x1) = (f32::MAX, f32::MIN);
for l in &lay.lines {
x0 = x0.min(l.x);
@@ -1098,7 +1191,6 @@ pub fn layout_rich_uncached(text: &str, runs: &[(Range<usize>, TextStyle)], para
if let (Some(first), Some(last)) = (lay.lines.first(), lay.lines.last()) {
lay.bounds = [x0, first.baseline - first.ascent, x1.max(x0), last.baseline + last.descent];
}
lay
}
fn cache() -> &'static Mutex<HashMap<u64, (u64, Arc<Layout>)>> {