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}