Skip to main content

promkit_core/render/
layout.rs

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