Skip to main content

promkit_core/
widget.rs

1//! Width-independent widget output and the coordinate types used during layout.
2//!
3//! Widgets project state into styled content. Alongside that content they return
4//! a [`WidgetLayout`] hint and, when applicable, a logical [`ContentPosition`]
5//! for the cursor. [`crate::render::Renderer`] owns terminal-dependent wrapping,
6//! truncation, vertical viewport allocation, and scrolling.
7//!
8//! Positions deliberately have three coordinate spaces:
9//!
10//! - [`ContentPosition`] addresses newline-delimited widget content. Its column is
11//!   measured in terminal display cells.
12//! - [`VisualPosition`] addresses the rows produced after terminal-width layout.
13//! - [`ScreenPosition`] addresses absolute terminal cells.
14//!
15//! The renderer records the mapping between these spaces after every completed
16//! render so it can support cursor placement and hit testing.
17
18use crate::grapheme::StyledGraphemes;
19
20/// A widget's position in its width-independent, newline-delimited content.
21///
22/// `column` is a terminal display-cell offset, not a character index.
23#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
24pub struct ContentPosition {
25    pub row: usize,
26    pub column: usize,
27}
28
29/// A position after the content has been wrapped for a terminal width.
30#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
31pub struct VisualPosition {
32    pub row: usize,
33    pub column: usize,
34}
35
36/// A position on the terminal screen.
37#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
38pub struct ScreenPosition {
39    pub row: u16,
40    pub column: u16,
41}
42
43/// A content position associated with a renderer item.
44#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct WidgetPosition<K> {
46    pub index: K,
47    pub row: usize,
48    pub column: usize,
49}
50
51impl<K> WidgetPosition<K> {
52    pub fn content_position(&self) -> ContentPosition {
53        ContentPosition {
54            row: self.row,
55            column: self.column,
56        }
57    }
58}
59
60/// Horizontal overflow behavior applied by the renderer.
61#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
62pub enum WidthMode {
63    /// Continue content on subsequent visual rows.
64    #[default]
65    Wrap,
66    /// Keep one visual row per logical row and append an ellipsis when needed.
67    Truncate,
68}
69
70/// Layout constraints requested by a widget.
71///
72/// `max_height` is a preference rather than a terminal allocation. The renderer
73/// combines it with the laid-out content height, terminal height, and the other
74/// non-empty widgets. `width_mode` controls whether each logical row wraps or is
75/// truncated with an ellipsis.
76#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
77pub struct WidgetLayout {
78    pub max_height: Option<usize>,
79    pub width_mode: WidthMode,
80}
81
82/// Width-independent content and metadata created by a widget.
83///
84/// `graphemes` normally contains the widget's complete content. Widgets with
85/// large backing stores may return a bounded projection from
86/// [`Widget::create_graphemes_in_viewport`].
87#[derive(Clone, Debug, Default, PartialEq, Eq)]
88pub struct CreatedGraphemes {
89    pub graphemes: StyledGraphemes,
90    pub layout: WidgetLayout,
91    pub cursor: Option<ContentPosition>,
92}
93
94impl From<StyledGraphemes> for CreatedGraphemes {
95    fn from(graphemes: StyledGraphemes) -> Self {
96        Self {
97            graphemes,
98            ..Self::default()
99        }
100    }
101}
102
103/// A viewport assigned to one renderer item.
104///
105/// `content_row` is a row in the terminal-width-dependent visual layout.
106#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
107pub struct WidgetViewport {
108    pub screen_row: u16,
109    pub height: u16,
110    pub content_row: usize,
111}
112
113impl WidgetViewport {
114    /// Scrolls the minimum distance needed to include `position`.
115    ///
116    /// Moving inside the current viewport leaves `content_row` unchanged.
117    pub fn scroll_to_include(&mut self, position: VisualPosition) -> ViewportChange {
118        if self.height == 0 {
119            return ViewportChange::Unchanged;
120        }
121
122        let previous = self.content_row;
123        let height = self.height as usize;
124
125        if position.row < self.content_row {
126            self.content_row = position.row;
127        } else if position.row >= self.content_row.saturating_add(height) {
128            self.content_row = position.row.saturating_add(1).saturating_sub(height);
129        }
130
131        if self.content_row == previous {
132            ViewportChange::Unchanged
133        } else {
134            ViewportChange::Scrolled
135        }
136    }
137}
138
139#[derive(Clone, Copy, Debug, PartialEq, Eq)]
140pub enum ViewportChange {
141    Unchanged,
142    Scrolled,
143}
144
145/// Projects widget state into width-independent styled content.
146pub trait Widget {
147    /// Creates the widget's complete content.
148    fn create_graphemes(&self) -> CreatedGraphemes;
149
150    /// Creates content bounded by a terminal viewport.
151    ///
152    /// The default implementation returns the complete content. Large widgets
153    /// can override this method to avoid projecting rows that cannot be shown.
154    /// Wrapping and truncation remain the renderer's responsibility.
155    fn create_graphemes_in_viewport(&self, _width: u16, _height: u16) -> CreatedGraphemes {
156        self.create_graphemes()
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    mod widget_viewport {
165        use super::*;
166
167        mod scroll_to_include {
168            use super::*;
169
170            #[test]
171            fn does_not_scroll_while_the_position_is_visible() {
172                let mut viewport = WidgetViewport {
173                    height: 3,
174                    content_row: 4,
175                    ..Default::default()
176                };
177
178                assert_eq!(
179                    viewport.scroll_to_include(VisualPosition { row: 6, column: 0 }),
180                    ViewportChange::Unchanged
181                );
182                assert_eq!(viewport.content_row, 4);
183            }
184
185            #[test]
186            fn scrolls_the_minimum_distance_to_include_the_position() {
187                let mut viewport = WidgetViewport {
188                    height: 3,
189                    content_row: 4,
190                    ..Default::default()
191                };
192
193                assert_eq!(
194                    viewport.scroll_to_include(VisualPosition { row: 7, column: 0 }),
195                    ViewportChange::Scrolled
196                );
197                assert_eq!(viewport.content_row, 5);
198
199                assert_eq!(
200                    viewport.scroll_to_include(VisualPosition { row: 2, column: 0 }),
201                    ViewportChange::Scrolled
202                );
203                assert_eq!(viewport.content_row, 2);
204            }
205        }
206    }
207}