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/// Vertical sizing behavior applied by the renderer.
71#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
72pub enum HeightPolicy {
73    /// Allocate content height in widget order, subject to
74    /// [`WidgetLayout::max_height`] and the remaining terminal height.
75    #[default]
76    OrderedContent,
77    /// Take at most an equal share of the remaining height, then shrink to the
78    /// content height without redistributing the unused share.
79    FairContent,
80    /// Share the height left after ordered-content widgets fairly with other
81    /// fill widgets, subject to [`WidgetLayout::max_height`].
82    FairFill,
83}
84
85/// Layout constraints requested by a widget.
86///
87/// `max_height` is a preference rather than a terminal allocation. The renderer
88/// combines it with the laid-out content height, terminal height, and the other
89/// non-empty widgets. [`HeightPolicy::OrderedContent`] uses that content-derived
90/// height in widget order, while [`HeightPolicy::FairFill`] shares the remaining
91/// terminal height equally with other fair-sized widgets.
92/// [`HeightPolicy::FairContent`] uses the same initial fair allocation but stops
93/// at its content height without redistributing unused rows. `width_mode`
94/// controls whether each logical row wraps or is truncated with an ellipsis.
95#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
96pub struct WidgetLayout {
97    pub height_policy: HeightPolicy,
98    pub max_height: Option<usize>,
99    pub width_mode: WidthMode,
100}
101
102/// Width-independent content and metadata created by a widget.
103///
104/// `graphemes` normally contains the widget's complete content. Widgets with
105/// large backing stores may return a bounded projection from
106/// [`Widget::create_graphemes_in_viewport`].
107#[derive(Clone, Debug, Default, PartialEq, Eq)]
108pub struct CreatedGraphemes {
109    pub graphemes: StyledGraphemes,
110    pub layout: WidgetLayout,
111    pub cursor: Option<ContentPosition>,
112}
113
114impl From<StyledGraphemes> for CreatedGraphemes {
115    fn from(graphemes: StyledGraphemes) -> Self {
116        Self {
117            graphemes,
118            ..Self::default()
119        }
120    }
121}
122
123/// A viewport assigned to one renderer item.
124///
125/// `content_row` is a row in the terminal-width-dependent visual layout.
126#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
127pub struct WidgetViewport {
128    pub screen_row: u16,
129    pub height: u16,
130    pub content_row: usize,
131}
132
133impl WidgetViewport {
134    /// Scrolls the minimum distance needed to include `position`.
135    ///
136    /// Moving inside the current viewport leaves `content_row` unchanged.
137    pub fn scroll_to_include(&mut self, position: VisualPosition) -> ViewportChange {
138        if self.height == 0 {
139            return ViewportChange::Unchanged;
140        }
141
142        let previous = self.content_row;
143        let height = self.height as usize;
144
145        if position.row < self.content_row {
146            self.content_row = position.row;
147        } else if position.row >= self.content_row.saturating_add(height) {
148            self.content_row = position.row.saturating_add(1).saturating_sub(height);
149        }
150
151        if self.content_row == previous {
152            ViewportChange::Unchanged
153        } else {
154            ViewportChange::Scrolled
155        }
156    }
157}
158
159#[derive(Clone, Copy, Debug, PartialEq, Eq)]
160pub enum ViewportChange {
161    Unchanged,
162    Scrolled,
163}
164
165/// Projects widget state into width-independent styled content.
166pub trait Widget {
167    /// Creates the widget's complete content.
168    fn create_graphemes(&self) -> CreatedGraphemes;
169
170    /// Creates content bounded by a terminal viewport.
171    ///
172    /// The default implementation returns the complete content. Large widgets
173    /// can override this method to avoid projecting rows that cannot be shown.
174    /// Wrapping and truncation remain the renderer's responsibility.
175    fn create_graphemes_in_viewport(&self, _width: u16, _height: u16) -> CreatedGraphemes {
176        self.create_graphemes()
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    mod widget_viewport {
185        use super::*;
186
187        mod scroll_to_include {
188            use super::*;
189
190            #[test]
191            fn does_not_scroll_while_the_position_is_visible() {
192                let mut viewport = WidgetViewport {
193                    height: 3,
194                    content_row: 4,
195                    ..Default::default()
196                };
197
198                assert_eq!(
199                    viewport.scroll_to_include(VisualPosition { row: 6, column: 0 }),
200                    ViewportChange::Unchanged
201                );
202                assert_eq!(viewport.content_row, 4);
203            }
204
205            #[test]
206            fn scrolls_the_minimum_distance_to_include_the_position() {
207                let mut viewport = WidgetViewport {
208                    height: 3,
209                    content_row: 4,
210                    ..Default::default()
211                };
212
213                assert_eq!(
214                    viewport.scroll_to_include(VisualPosition { row: 7, column: 0 }),
215                    ViewportChange::Scrolled
216                );
217                assert_eq!(viewport.content_row, 5);
218
219                assert_eq!(
220                    viewport.scroll_to_include(VisualPosition { row: 2, column: 0 }),
221                    ViewportChange::Scrolled
222                );
223                assert_eq!(viewport.content_row, 2);
224            }
225        }
226    }
227}