i_slint_core/textlayout/sharedparley/
layout.rs1use super::shaping::{Brush, TextParagraph};
8use super::*;
9use crate::items::TextCursorAffinity;
10
11impl From<TextCursorAffinity> for parley::layout::Affinity {
12 fn from(affinity: TextCursorAffinity) -> Self {
13 match affinity {
14 TextCursorAffinity::NextCharacter => Self::Downstream,
15 TextCursorAffinity::PreviousCharacter => Self::Upstream,
16 }
17 }
18}
19
20impl From<parley::layout::Affinity> for TextCursorAffinity {
21 fn from(affinity: parley::layout::Affinity) -> Self {
22 match affinity {
23 parley::layout::Affinity::Downstream => Self::NextCharacter,
24 parley::layout::Affinity::Upstream => Self::PreviousCharacter,
25 }
26 }
27}
28
29#[derive(Default)]
30pub(super) struct LayoutOptions {
31 pub(super) max_width: Option<LogicalLength>,
32 pub(super) max_height: Option<LogicalLength>,
33 pub(super) max_lines: Option<usize>,
35 pub(super) horizontal_align: TextHorizontalAlignment,
36 pub(super) vertical_align: TextVerticalAlignment,
37 pub(super) text_overflow: TextOverflow,
38}
39
40impl LayoutOptions {
41 pub(super) fn new_from_textinput(
42 text_input: Pin<&crate::items::TextInput>,
43 max_width: Option<LogicalLength>,
44 max_height: Option<LogicalLength>,
45 ) -> Self {
46 Self {
47 max_width,
48 max_height,
49 max_lines: None,
50 horizontal_align: text_input.horizontal_alignment(),
51 vertical_align: text_input.vertical_alignment(),
52 text_overflow: TextOverflow::Clip,
53 }
54 }
55}
56
57#[derive(Clone, Copy, PartialEq)]
63struct LineBreakingInputs {
64 max_physical_width: Option<PhysicalLength>,
65 alignment: parley::Alignment,
67 max_lines: Option<usize>,
68 text_overflow: TextOverflow,
69}
70
71impl LineBreakingInputs {
72 fn new(options: &LayoutOptions, max_physical_width: Option<PhysicalLength>) -> Self {
73 Self {
74 max_physical_width,
75 alignment: match options.horizontal_align {
76 TextHorizontalAlignment::Start | TextHorizontalAlignment::Left => {
77 parley::Alignment::Left
78 }
79 TextHorizontalAlignment::Center => parley::Alignment::Center,
80 TextHorizontalAlignment::End | TextHorizontalAlignment::Right => {
81 parley::Alignment::Right
82 }
83 },
84 max_lines: options.max_lines,
85 text_overflow: options.text_overflow,
86 }
87 }
88}
89
90pub(super) struct RetainedLineBreaking {
95 inputs: LineBreakingInputs,
96 line_limit_cut: Option<(usize, usize)>,
97 max_width: PhysicalLength,
98 height: PhysicalLength,
99 elision_info: Option<ElisionInfo>,
100}
101
102fn vertical_offset(
105 max_physical_height: Option<PhysicalLength>,
106 vertical_align: TextVerticalAlignment,
107 height: PhysicalLength,
108) -> PhysicalLength {
109 match (max_physical_height, vertical_align) {
110 (Some(max_height), TextVerticalAlignment::Center) => (max_height - height) / 2.0,
111 (Some(max_height), TextVerticalAlignment::Bottom) => max_height - height,
112 (None, _) | (Some(_), TextVerticalAlignment::Top) => PhysicalLength::new(0.0),
113 }
114}
115
116pub(super) fn layout(
117 layout_builder: &LayoutWithoutLineBreaksBuilder,
118 font_context: &mut parley::FontContext,
119 mut paragraphs: Vec<TextParagraph>,
120 scale_factor: ScaleFactor,
121 options: LayoutOptions,
122 line_breaking: Option<RetainedLineBreaking>,
123) -> Layout {
124 let max_physical_width = options.max_width.map(|max_width| max_width * scale_factor);
125 let max_physical_height = options.max_height.map(|max_height| max_height * scale_factor);
126
127 let inputs = LineBreakingInputs::new(&options, max_physical_width);
128 if let Some(line_breaking) =
129 line_breaking.filter(|line_breaking| line_breaking.inputs == inputs)
130 {
131 return Layout {
132 y_offset: vertical_offset(
133 max_physical_height,
134 options.vertical_align,
135 line_breaking.height,
136 ),
137 paragraphs,
138 max_width: line_breaking.max_width,
139 height: line_breaking.height,
140 max_physical_height,
141 elision_info: line_breaking.elision_info,
142 line_limit_cut: line_breaking.line_limit_cut,
143 line_breaking_inputs: inputs,
144 broke_lines: false,
145 };
146 }
147
148 let get_ellipsis_glyph = |font_context: &mut parley::FontContext| {
150 let mut layout = layout_builder.build(font_context, "…", None, None);
151 layout.break_all_lines(None);
152 let line = layout.lines().next()?;
153 let item = line.items().next()?;
154 let run = match item {
155 parley::layout::PositionedLayoutItem::GlyphRun(run) => Some(run),
156 _ => return None,
157 }?;
158 let glyph = run.positioned_glyphs().next()?;
159 Some((glyph, run.run().font().clone()))
160 };
161
162 let elision_info = if let (TextOverflow::Elide, Some(max_physical_width)) =
163 (options.text_overflow, max_physical_width)
164 {
165 get_ellipsis_glyph(font_context).map(|(ellipsis_glyph, font_for_ellipsis_glyph)| {
166 ElisionInfo { ellipsis_glyph, font_for_ellipsis_glyph, max_physical_width }
167 })
168 } else {
169 None
170 };
171
172 let mut para_y = 0.0;
173 for para in paragraphs.iter_mut() {
174 para.layout.break_all_lines(max_physical_width.map(|width| width.get()));
175 para.layout.align(inputs.alignment, parley::AlignmentOptions::default());
176
177 para.y = PhysicalLength::new(para_y);
178 para_y += para.layout.height();
179 }
180
181 let line_limit_cut =
182 options.max_lines.and_then(|max_lines| line_limit_cut(¶graphs, max_lines));
183 let visible_paragraph_count =
184 line_limit_cut.map_or(paragraphs.len(), |(last_paragraph, _)| last_paragraph + 1);
185
186 let max_width = paragraphs
187 .iter()
188 .take(visible_paragraph_count)
189 .enumerate()
190 .map(|(paragraph_index, p)| {
191 match line_limit_cut {
196 Some((last_paragraph, last_line)) if paragraph_index == last_paragraph => p
200 .layout
201 .lines()
202 .take(last_line + 1)
203 .map(|line| {
204 let metrics = line.metrics();
205 PhysicalLength::new(metrics.inline_min_coord + metrics.advance)
206 })
207 .fold(PhysicalLength::zero(), PhysicalLength::max),
208 _ => PhysicalLength::new(p.layout.full_width()),
209 }
210 })
211 .fold(PhysicalLength::zero(), PhysicalLength::max);
212 let height = match line_limit_cut {
215 Some((last_paragraph, last_line)) => {
216 let para = ¶graphs[last_paragraph];
217 let line = para
218 .layout
219 .lines()
220 .nth(last_line)
221 .expect("line_limit_cut returns an existing line index");
222 para.y + PhysicalLength::new(line.metrics().block_max_coord)
223 }
224 None => paragraphs
225 .last()
226 .map_or(PhysicalLength::zero(), |p| p.y + PhysicalLength::new(p.layout.height())),
227 };
228
229 let y_offset = vertical_offset(max_physical_height, options.vertical_align, height);
230
231 Layout {
232 paragraphs,
233 y_offset,
234 elision_info,
235 max_width,
236 height,
237 max_physical_height,
238 line_limit_cut,
239 line_breaking_inputs: inputs,
240 broke_lines: true,
241 }
242}
243
244fn line_limit_cut(paragraphs: &[TextParagraph], max_lines: usize) -> Option<(usize, usize)> {
248 let total_lines: usize = paragraphs.iter().map(|p| p.layout.lines().len()).sum();
249 if total_lines <= max_lines {
250 return None;
251 }
252
253 let mut seen_lines = 0;
254 for (paragraph_index, para) in paragraphs.iter().enumerate() {
255 let line_count = para.layout.lines().len();
256 if seen_lines + line_count >= max_lines {
259 return Some((paragraph_index, max_lines - seen_lines - 1));
260 }
261 seen_lines += line_count;
262 }
263 unreachable!("total_lines > max_lines, so the paragraph with the last kept line exists")
264}
265
266struct ElisionInfo {
267 ellipsis_glyph: parley::layout::Glyph,
268 font_for_ellipsis_glyph: parley::FontData,
269 max_physical_width: PhysicalLength,
270}
271
272fn line_fits_height(block_max_coord: f32, max_physical_height: PhysicalLength) -> bool {
275 max_physical_height.get().ceil() >= block_max_coord
276}
277
278#[derive(Clone, Copy)]
281pub(super) struct ElisionCut {
282 pub(super) last_paragraph: usize,
284 pub(super) last_line: usize,
286 pub(super) needs_ellipsis: bool,
288}
289
290pub(super) struct Layout {
291 pub(super) paragraphs: Vec<TextParagraph>,
292 pub(super) y_offset: PhysicalLength,
293 pub(super) max_width: PhysicalLength,
294 pub(super) height: PhysicalLength,
295 max_physical_height: Option<PhysicalLength>,
296 elision_info: Option<ElisionInfo>,
297 pub(super) line_limit_cut: Option<(usize, usize)>,
300 line_breaking_inputs: LineBreakingInputs,
302 pub(super) broke_lines: bool,
305}
306
307impl Layout {
308 pub(super) fn dismantle(self) -> (Vec<TextParagraph>, RetainedLineBreaking) {
312 (
313 self.paragraphs,
314 RetainedLineBreaking {
315 inputs: self.line_breaking_inputs,
316 line_limit_cut: self.line_limit_cut,
317 max_width: self.max_width,
318 height: self.height,
319 elision_info: self.elision_info,
320 },
321 )
322 }
323}
324
325impl Layout {
326 pub(super) fn is_eliding(&self) -> bool {
328 self.elision_info.is_some()
329 }
330
331 pub(super) fn visible_paragraphs(&self) -> &[TextParagraph] {
335 match self.line_limit_cut {
336 Some((last_paragraph, _)) => &self.paragraphs[..=last_paragraph],
337 None => &self.paragraphs,
338 }
339 }
340
341 pub(super) fn below_line_limit(&self, y: PhysicalLength) -> bool {
346 self.line_limit_cut.is_some() && y >= self.y_offset + self.height
347 }
348
349 pub(super) fn visible_extent(&self) -> Option<ElisionCut> {
353 let line_limit_cut = self.line_limit_cut.map(|(last_paragraph, last_line)| ElisionCut {
354 last_paragraph,
355 last_line,
356 needs_ellipsis: self.elision_info.is_some(),
359 });
360 match (self.elision_extent(), line_limit_cut) {
361 (Some(elision), Some(line_limit)) => {
362 Some(core::cmp::min_by_key(elision, line_limit, |cut| {
363 (cut.last_paragraph, cut.last_line)
364 }))
365 }
366 (elision, line_limit) => elision.or(line_limit),
367 }
368 }
369
370 pub(super) fn first_line_exceeds_height(&self) -> bool {
375 let Some(max_physical_height) = self.max_physical_height else {
376 return false;
377 };
378 self.paragraphs.first().and_then(|paragraph| paragraph.layout.lines().next()).is_some_and(
379 |line| !line_fits_height(line.metrics().block_max_coord, max_physical_height),
380 )
381 }
382
383 pub(super) fn paragraph_line_within_box(
388 &self,
389 paragraph: &TextParagraph,
390 block_min: f32,
391 block_max: f32,
392 ) -> bool {
393 match self.max_physical_height {
394 Some(max_physical_height) if self.elision_info.is_some() => {
395 let para_y = self.y_offset + paragraph.y;
396 line_fits_height(para_y.get() + block_max, max_physical_height)
399 && para_y.get() + block_min >= -0.5
400 }
401 _ => true,
402 }
403 }
404
405 fn elision_extent(&self) -> Option<ElisionCut> {
412 self.max_physical_height?;
413 self.elision_info.as_ref()?;
414
415 let last_within_box = self.paragraphs.iter().enumerate().rev().find_map(|(pi, para)| {
419 para.layout
420 .lines()
421 .enumerate()
422 .rev()
423 .find(|(_, line)| {
424 let m = line.metrics();
425 self.paragraph_line_within_box(para, m.block_min_coord, m.block_max_coord)
426 })
427 .map(|(li, _)| (pi, li))
428 });
429
430 let final_line = self
433 .paragraphs
434 .iter()
435 .enumerate()
436 .rev()
437 .find_map(|(pi, para)| para.layout.lines().len().checked_sub(1).map(|li| (pi, li)));
438
439 let (last_paragraph, last_line) = last_within_box.unwrap_or((0, 0));
440 let needs_ellipsis =
441 final_line.is_some_and(|final_line| final_line != (last_paragraph, last_line));
442 Some(ElisionCut { last_paragraph, last_line, needs_ellipsis })
443 }
444
445 fn paragraph_by_byte_offset(&self, byte_offset: usize) -> Option<&TextParagraph> {
449 self.visible_paragraphs().iter().take_while(|p| p.range.start <= byte_offset).last()
450 }
451
452 pub(super) fn paragraph_by_y(&self, y: PhysicalLength) -> Option<&TextParagraph> {
453 if self.below_line_limit(y) {
456 return None;
457 }
458
459 let y = y - self.y_offset;
461
462 if y < PhysicalLength::zero() {
463 return self.visible_paragraphs().first();
464 }
465
466 let idx = self.visible_paragraphs().binary_search_by(|paragraph| {
467 if y < paragraph.y {
468 core::cmp::Ordering::Greater
469 } else if y >= paragraph.y + PhysicalLength::new(paragraph.layout.height()) {
470 core::cmp::Ordering::Less
471 } else {
472 core::cmp::Ordering::Equal
473 }
474 });
475
476 match idx {
477 Ok(i) => self.visible_paragraphs().get(i),
478 Err(_) => self.visible_paragraphs().last(),
479 }
480 }
481
482 pub(super) fn byte_offset_from_point(
483 &self,
484 pos: PhysicalPoint,
485 ) -> (usize, crate::items::TextCursorAffinity) {
486 let Some(paragraph) = self.paragraph_by_y(pos.y_length()) else {
487 return (0, crate::items::TextCursorAffinity::NextCharacter);
488 };
489 let cursor = parley::editing::Cursor::from_point(
490 ¶graph.layout,
491 pos.x,
492 (pos.y_length() - self.y_offset - paragraph.y).get(),
493 );
494 (paragraph.range.start + cursor.index(), cursor.affinity().into())
495 }
496
497 pub(super) fn cursor_rect_for_byte_offset(
498 &self,
499 byte_offset: usize,
500 affinity: crate::items::TextCursorAffinity,
501 cursor_width: PhysicalLength,
502 ) -> PhysicalRect {
503 let Some(paragraph) = self.paragraph_by_byte_offset(byte_offset) else {
504 return PhysicalRect::new(PhysicalPoint::default(), PhysicalSize::new(1.0, 1.0));
505 };
506
507 let local_offset = (byte_offset - paragraph.range.start).min(paragraph.range.len());
508 let cursor = parley::editing::Cursor::from_byte_index(
509 ¶graph.layout,
510 local_offset,
511 affinity.into(),
512 );
513 let rect = cursor.geometry(¶graph.layout, cursor_width.get());
514
515 PhysicalRect::new(
516 PhysicalPoint::from_lengths(
517 PhysicalLength::new(rect.x0 as _),
518 PhysicalLength::new(rect.y0 as _) + self.y_offset + paragraph.y,
519 ),
520 PhysicalSize::new(rect.width() as _, rect.height() as _),
521 )
522 }
523
524 pub(super) fn glyphs_with_elision<'a>(
528 &'a self,
529 glyph_run: &'a parley::layout::GlyphRun<Brush>,
530 force_elision: bool,
533 trailing_whitespace: f32,
537 ) -> (
538 impl Iterator<Item = parley::layout::Glyph> + Clone + 'a,
539 Option<(parley::layout::Glyph, parley::FontData, PhysicalLength)>,
540 ) {
541 let ellipsis_advance =
542 self.elision_info.as_ref().map(|info| info.ellipsis_glyph.advance).unwrap_or(0.0);
543 let max_width = self
544 .elision_info
545 .as_ref()
546 .map(|info| info.max_physical_width)
547 .unwrap_or(PhysicalLength::new(f32::MAX));
548
549 let run_start = PhysicalLength::new(glyph_run.offset());
550 let run_end = PhysicalLength::new(glyph_run.offset() + glyph_run.advance());
551
552 let run_beyond_elision = run_start > max_width;
554 let needs_elision = !run_beyond_elision
556 && (force_elision || run_end.get().floor() > max_width.get().ceil());
557
558 let truncated_glyphs = glyph_run.positioned_glyphs().take_while(move |glyph| {
559 !run_beyond_elision
560 && (!needs_elision
561 || PhysicalLength::new(glyph.x + glyph.advance + ellipsis_advance) <= max_width)
562 });
563
564 let ellipsis = if needs_elision {
565 self.elision_info.as_ref().map(|info| {
566 let ellipsis_x = glyph_run
567 .positioned_glyphs()
568 .find(|glyph| {
569 PhysicalLength::new(glyph.x + glyph.advance + info.ellipsis_glyph.advance)
570 > info.max_physical_width
571 })
572 .map(|g| g.x)
573 .unwrap_or(run_end.get() - trailing_whitespace);
576
577 let mut ellipsis_glyph = info.ellipsis_glyph;
578 ellipsis_glyph.x = ellipsis_x;
579 ellipsis_glyph.y = glyph_run.baseline();
582
583 let font_size = PhysicalLength::new(glyph_run.run().font_size());
584 (ellipsis_glyph, info.font_for_ellipsis_glyph.clone(), font_size)
585 })
586 } else {
587 None
588 };
589
590 (truncated_glyphs, ellipsis)
591 }
592}