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}