Skip to main content

promkit_core/render/
layout.rs

1use std::collections::BTreeMap;
2
3use crate::{
4    grapheme::StyledGraphemes,
5    widget::{
6        ContentPosition, CreatedGraphemes, HeightPolicy, ScreenPosition, VisualPosition,
7        WidgetLayout, WidgetViewport, WidthMode,
8    },
9};
10
11mod height;
12use height::HeightRequest;
13
14/// Terminal-size-dependent renderer layout without terminal I/O.
15///
16/// The layout keeps each pane's vertical viewport offset between calls. This
17/// mirrors [`super::Renderer`] behavior while allowing layout performance to be
18/// measured independently from terminal size queries and stdout writes.
19#[derive(Debug)]
20pub struct RendererLayout<K> {
21    viewport_rows: BTreeMap<K, usize>,
22}
23
24impl<K> Default for RendererLayout<K> {
25    fn default() -> Self {
26        Self {
27            viewport_rows: BTreeMap::new(),
28        }
29    }
30}
31
32#[derive(Clone, Debug)]
33pub(super) struct VisualRow {
34    pub(super) content_row: usize,
35    pub(super) content_column: usize,
36    pub(super) graphemes: StyledGraphemes,
37}
38
39#[derive(Clone, Debug)]
40struct LaidOutPane<K> {
41    index: K,
42    layout: WidgetLayout,
43    cursor: Option<ContentPosition>,
44    rows: Vec<VisualRow>,
45}
46
47impl<K> LaidOutPane<K> {
48    fn occupies_space(&self) -> bool {
49        !self.rows.is_empty() && self.layout.max_height != Some(0)
50    }
51
52    fn height_request(&self) -> HeightRequest {
53        HeightRequest::new(
54            self.layout.height_policy,
55            self.rows.len(),
56            self.layout.max_height,
57        )
58    }
59}
60
61#[derive(Clone, Debug)]
62pub(super) struct LayoutEntry<K> {
63    pub(super) index: K,
64    pub(super) viewport: WidgetViewport,
65    pub(super) rows: Vec<VisualRow>,
66}
67
68#[derive(Clone, Debug)]
69pub(super) struct LayoutSnapshot<K> {
70    pub(super) origin: ScreenPosition,
71    pub(super) terminal_width: u16,
72    pub(super) entries: Vec<LayoutEntry<K>>,
73}
74
75/// A renderer frame after wrapping, viewport allocation, and clipping.
76///
77/// It intentionally exposes only aggregate information and views of the visible
78/// panes.
79/// Coordinate mappings are installed by [`super::Renderer`] after the panes
80/// have been drawn at a known screen origin.
81#[derive(Debug)]
82pub struct PreparedLayout<K> {
83    pub(super) terminal_width: u16,
84    pub(super) entries: Vec<LayoutEntry<K>>,
85}
86
87impl<K> PreparedLayout<K> {
88    /// Returns views of the visible rows grouped by renderer pane.
89    pub fn panes(&self) -> Vec<Vec<&StyledGraphemes>> {
90        self.entries
91            .iter()
92            .map(|entry| {
93                entry
94                    .rows
95                    .iter()
96                    .skip(entry.viewport.content_row)
97                    .take(entry.viewport.height as usize)
98                    .map(|row| &row.graphemes)
99                    .collect()
100            })
101            .collect()
102    }
103
104    /// Returns the number of non-empty panes allocated in this frame.
105    pub fn pane_count(&self) -> usize {
106        self.entries.len()
107    }
108
109    /// Returns the number of visual rows retained before viewport clipping,
110    /// including empty rows reserved by fill-sized panes.
111    pub fn visual_row_count(&self) -> usize {
112        self.entries.iter().map(|entry| entry.rows.len()).sum()
113    }
114
115    /// Returns the number of rows that will be written to the terminal.
116    pub fn visible_row_count(&self) -> usize {
117        self.entries
118            .iter()
119            .map(|entry| {
120                entry
121                    .rows
122                    .len()
123                    .saturating_sub(entry.viewport.content_row)
124                    .min(entry.viewport.height as usize)
125            })
126            .sum()
127    }
128
129    pub(super) fn into_snapshot(mut self, origin: ScreenPosition) -> LayoutSnapshot<K> {
130        let mut screen_row = origin.row;
131        for entry in &mut self.entries {
132            entry.viewport.screen_row = screen_row;
133            screen_row = screen_row.saturating_add(entry.viewport.height);
134        }
135
136        LayoutSnapshot {
137            origin,
138            terminal_width: self.terminal_width,
139            entries: self.entries,
140        }
141    }
142}
143
144impl<K: Clone + Ord> RendererLayout<K> {
145    /// Lays out a renderer frame for a known terminal size.
146    ///
147    /// This performs the same content wrapping, truncation, pane allocation,
148    /// cursor scrolling, and viewport clipping used by [`super::Renderer::render`],
149    /// but performs no terminal I/O.
150    pub fn layout<I>(
151        &mut self,
152        contents: I,
153        terminal_width: u16,
154        terminal_height: u16,
155    ) -> anyhow::Result<PreparedLayout<K>>
156    where
157        I: IntoIterator<Item = (K, CreatedGraphemes)>,
158    {
159        let laid_out = contents
160            .into_iter()
161            .map(|(index, created)| {
162                let CreatedGraphemes {
163                    graphemes,
164                    layout,
165                    cursor,
166                } = created;
167                let rows = layout_content(graphemes, layout.width_mode, terminal_width as usize);
168                LaidOutPane {
169                    index,
170                    layout,
171                    cursor,
172                    rows,
173                }
174            })
175            .filter(LaidOutPane::occupies_space)
176            .collect::<Vec<_>>();
177
178        if laid_out.len() > terminal_height as usize {
179            return Err(anyhow::anyhow!("Insufficient space to display all panes"));
180        }
181
182        let pane_count = laid_out.len();
183        let height_requests = laid_out
184            .iter()
185            .map(LaidOutPane::height_request)
186            .collect::<Vec<_>>();
187        let heights = height::allocate(&height_requests, terminal_height as usize);
188        let mut entries = Vec::with_capacity(pane_count);
189
190        for (mut pane, height) in laid_out.into_iter().zip(heights) {
191            if pane.layout.height_policy == HeightPolicy::FairFill && pane.rows.len() < height {
192                pad_rows_to_height(&mut pane.rows, height);
193            }
194            let mut viewport = WidgetViewport {
195                height: height as u16,
196                content_row: self
197                    .viewport_rows
198                    .get(&pane.index)
199                    .copied()
200                    .unwrap_or_default(),
201                ..Default::default()
202            };
203
204            let max_content_row = pane.rows.len().saturating_sub(height);
205            viewport.content_row = viewport.content_row.min(max_content_row);
206
207            if let Some(cursor) = pane.cursor
208                && let Some(position) = visual_position(&pane.rows, cursor)
209            {
210                viewport.scroll_to_include(position);
211                viewport.content_row = viewport.content_row.min(max_content_row);
212            }
213
214            self.viewport_rows
215                .insert(pane.index.clone(), viewport.content_row);
216            entries.push(LayoutEntry {
217                index: pane.index,
218                viewport,
219                rows: pane.rows,
220            });
221        }
222
223        Ok(PreparedLayout {
224            terminal_width,
225            entries,
226        })
227    }
228
229    pub(super) fn remove(&mut self, index: &K) {
230        self.viewport_rows.remove(index);
231    }
232}
233
234fn pad_rows_to_height(rows: &mut Vec<VisualRow>, height: usize) {
235    let first_padding_row = rows
236        .last()
237        .map_or(0, |row| row.content_row.saturating_add(1));
238    rows.extend(
239        (first_padding_row..)
240            .take(height.saturating_sub(rows.len()))
241            .map(|content_row| VisualRow {
242                content_row,
243                content_column: 0,
244                graphemes: StyledGraphemes::default(),
245            }),
246    );
247}
248
249fn layout_content(
250    graphemes: StyledGraphemes,
251    width_mode: WidthMode,
252    width: usize,
253) -> Vec<VisualRow> {
254    if width == 0 {
255        return Vec::new();
256    }
257
258    into_logical_lines(graphemes)
259        .into_iter()
260        .enumerate()
261        .flat_map(|(content_row, line)| match width_mode {
262            WidthMode::Wrap => wrap_line(content_row, line, width),
263            WidthMode::Truncate => vec![VisualRow {
264                content_row,
265                content_column: 0,
266                graphemes: truncate_line(line, width),
267            }],
268        })
269        .collect()
270}
271
272fn into_logical_lines(graphemes: StyledGraphemes) -> Vec<StyledGraphemes> {
273    if graphemes.is_empty() {
274        return Vec::new();
275    }
276
277    let mut lines = Vec::new();
278    let mut line = StyledGraphemes::default();
279    let mut last_was_newline = false;
280
281    for styled in graphemes.0 {
282        if styled.character() == '\n' {
283            lines.push(line);
284            line = StyledGraphemes::default();
285            last_was_newline = true;
286        } else {
287            line.push_back(styled);
288            last_was_newline = false;
289        }
290    }
291
292    if !line.is_empty() || last_was_newline {
293        lines.push(line);
294    }
295
296    lines
297}
298
299fn wrap_line(content_row: usize, line: StyledGraphemes, width: usize) -> Vec<VisualRow> {
300    if line.is_empty() {
301        return vec![VisualRow {
302            content_row,
303            content_column: 0,
304            graphemes: line,
305        }];
306    }
307
308    let mut rows = Vec::new();
309    let mut row = StyledGraphemes::default();
310    let mut row_width = 0usize;
311    let mut content_column = 0usize;
312    let mut row_column = 0usize;
313
314    for grapheme in line.0 {
315        let grapheme_width = grapheme.width();
316        if grapheme_width > width {
317            if !row.is_empty() {
318                rows.push(VisualRow {
319                    content_row,
320                    content_column: row_column,
321                    graphemes: row,
322                });
323                row = StyledGraphemes::default();
324                row_width = 0;
325            }
326
327            // Keep the replacement on its own visual row: it occupies one screen
328            // cell while the original grapheme still advances by its logical width.
329            rows.push(VisualRow {
330                content_row,
331                content_column,
332                graphemes: StyledGraphemes::from("…"),
333            });
334            content_column = content_column.saturating_add(grapheme_width);
335            row_column = content_column;
336            continue;
337        }
338
339        if !row.is_empty() && row_width.saturating_add(grapheme_width) > width {
340            rows.push(VisualRow {
341                content_row,
342                content_column: row_column,
343                graphemes: row,
344            });
345            row = StyledGraphemes::default();
346            row_width = 0;
347            row_column = content_column;
348        }
349
350        row.push_back(grapheme);
351        row_width = row_width.saturating_add(grapheme_width);
352        content_column = content_column.saturating_add(grapheme_width);
353    }
354
355    if !row.is_empty() {
356        rows.push(VisualRow {
357            content_row,
358            content_column: row_column,
359            graphemes: row,
360        });
361    }
362
363    rows
364}
365
366fn truncate_line(line: StyledGraphemes, width: usize) -> StyledGraphemes {
367    if line.widths() <= width {
368        return line;
369    }
370
371    if width == 0 {
372        return StyledGraphemes::default();
373    }
374
375    let mut ellipsis = StyledGraphemes::from("…");
376    let ellipsis_width = ellipsis.widths();
377    if width <= ellipsis_width {
378        return ellipsis;
379    }
380
381    let mut truncated = StyledGraphemes::default();
382    let mut current_width = 0usize;
383    for grapheme in line.0 {
384        if current_width
385            .saturating_add(grapheme.width())
386            .saturating_add(ellipsis_width)
387            > width
388        {
389            break;
390        }
391        current_width = current_width.saturating_add(grapheme.width());
392        truncated.push_back(grapheme);
393    }
394    truncated.append(&mut ellipsis);
395    truncated
396}
397
398pub(super) fn visual_position(
399    rows: &[VisualRow],
400    position: ContentPosition,
401) -> Option<VisualPosition> {
402    let matching = rows
403        .iter()
404        .enumerate()
405        .filter(|(_, row)| row.content_row == position.row)
406        .collect::<Vec<_>>();
407
408    let (row_index, row) = matching
409        .iter()
410        .copied()
411        .find(|(_, row)| {
412            let end = row.content_column.saturating_add(row.graphemes.widths());
413            position.column >= row.content_column && position.column < end
414        })
415        .or_else(|| matching.last().copied())?;
416
417    Some(VisualPosition {
418        row: row_index,
419        column: position.column.saturating_sub(row.content_column),
420    })
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426    use crate::widget::WidgetLayout;
427
428    mod visual_position {
429        use super::*;
430
431        #[test]
432        fn maps_a_logical_cursor_to_its_wrapped_row() {
433            let created = CreatedGraphemes {
434                graphemes: StyledGraphemes::from("abcdefghij"),
435                cursor: Some(ContentPosition { row: 0, column: 8 }),
436                ..Default::default()
437            };
438            let cursor = created.cursor.unwrap();
439            let rows = layout_content(created.graphemes, created.layout.width_mode, 4);
440
441            assert_eq!(rows.len(), 3);
442            assert_eq!(
443                visual_position(&rows, cursor),
444                Some(VisualPosition { row: 2, column: 0 })
445            );
446        }
447    }
448
449    mod wrap_line {
450        use super::*;
451
452        #[test]
453        fn preserves_a_grapheme_wider_than_the_terminal() {
454            let created = CreatedGraphemes {
455                graphemes: StyledGraphemes::from("界"),
456                cursor: Some(ContentPosition { row: 0, column: 0 }),
457                ..Default::default()
458            };
459
460            let cursor = created.cursor.unwrap();
461            let rows = layout_content(created.graphemes, created.layout.width_mode, 1);
462
463            assert_eq!(rows.len(), 1);
464            assert_eq!(rows[0].content_row, 0);
465            assert_eq!(rows[0].content_column, 0);
466            assert_eq!(rows[0].graphemes.to_string(), "…");
467            assert_eq!(
468                visual_position(&rows, cursor),
469                Some(VisualPosition { row: 0, column: 0 })
470            );
471        }
472
473        #[test]
474        fn preserves_columns_after_a_grapheme_wider_than_the_terminal() {
475            let created = CreatedGraphemes {
476                graphemes: StyledGraphemes::from("界a"),
477                cursor: Some(ContentPosition { row: 0, column: 2 }),
478                ..Default::default()
479            };
480
481            let cursor = created.cursor.unwrap();
482            let rows = layout_content(created.graphemes, created.layout.width_mode, 1);
483
484            assert_eq!(rows.len(), 2);
485            assert_eq!(rows[0].content_column, 0);
486            assert_eq!(rows[0].graphemes.to_string(), "…");
487            assert_eq!(rows[1].content_column, 2);
488            assert_eq!(rows[1].graphemes.to_string(), "a");
489            assert_eq!(
490                visual_position(&rows, cursor),
491                Some(VisualPosition { row: 1, column: 0 })
492            );
493        }
494    }
495
496    mod truncate_line {
497        use super::*;
498
499        #[test]
500        fn keeps_one_visual_row_per_logical_row() {
501            let created = CreatedGraphemes {
502                graphemes: StyledGraphemes::from("abcdefghij\nsecond"),
503                layout: WidgetLayout {
504                    width_mode: WidthMode::Truncate,
505                    ..Default::default()
506                },
507                cursor: None,
508            };
509            let rows = layout_content(created.graphemes, created.layout.width_mode, 4);
510
511            assert_eq!(rows.len(), 2);
512            assert_eq!(rows[0].graphemes.to_string(), "abc…");
513            assert_eq!(rows[1].graphemes.to_string(), "sec…");
514        }
515    }
516
517    mod into_logical_lines {
518        use super::*;
519
520        #[test]
521        fn preserves_empty_logical_rows() {
522            let rows = layout_content(StyledGraphemes::from("first\n\n"), WidthMode::Wrap, 80);
523            let text = rows
524                .iter()
525                .map(|row| row.graphemes.to_string())
526                .collect::<Vec<_>>();
527
528            assert_eq!(text, ["first", "", ""]);
529        }
530    }
531
532    mod renderer_layout {
533        use super::*;
534
535        mod layout {
536            use super::*;
537
538            #[test]
539            fn allocates_height_and_preserves_the_viewport_between_frames() {
540                let created = CreatedGraphemes {
541                    graphemes: StyledGraphemes::from("first\nsecond\nthird"),
542                    layout: WidgetLayout {
543                        max_height: Some(2),
544                        ..Default::default()
545                    },
546                    cursor: Some(ContentPosition { row: 2, column: 0 }),
547                };
548                let mut layout = RendererLayout::default();
549
550                let first = layout.layout([(0, created.clone())], 80, 24).unwrap();
551                assert_eq!(first.pane_count(), 1);
552                assert_eq!(first.visual_row_count(), 3);
553                assert_eq!(first.visible_row_count(), 2);
554                let first_panes = first.panes();
555                assert_eq!(first_panes[0][0].to_string(), "second");
556
557                let second = layout.layout([(0, created)], 80, 24).unwrap();
558                let second_panes = second.panes();
559                assert_eq!(second_panes[0][0].to_string(), "second");
560            }
561
562            #[test]
563            fn fills_the_allocated_height_beyond_content() {
564                let created = || CreatedGraphemes {
565                    graphemes: StyledGraphemes::from("content"),
566                    layout: WidgetLayout {
567                        height_policy: HeightPolicy::FairFill,
568                        ..Default::default()
569                    },
570                    cursor: None,
571                };
572                let mut layout = RendererLayout::default();
573
574                let prepared = layout
575                    .layout([(0, created()), (1, created())], 80, 6)
576                    .unwrap();
577
578                assert_eq!(
579                    prepared.panes().iter().map(Vec::len).collect::<Vec<_>>(),
580                    [3, 3]
581                );
582            }
583
584            #[test]
585            fn does_not_pad_fair_content_beyond_content_height() {
586                let created = || CreatedGraphemes {
587                    graphemes: StyledGraphemes::from("content"),
588                    layout: WidgetLayout {
589                        height_policy: HeightPolicy::FairContent,
590                        ..Default::default()
591                    },
592                    cursor: None,
593                };
594                let mut layout = RendererLayout::default();
595
596                let prepared = layout
597                    .layout([(0, created()), (1, created())], 80, 6)
598                    .unwrap();
599
600                assert_eq!(
601                    prepared.panes().iter().map(Vec::len).collect::<Vec<_>>(),
602                    [1, 1]
603                );
604            }
605        }
606    }
607}