Skip to main content

wisp/view/
widgets.rs

1use crate::view::edit_buffer::EditBuffer;
2use crate::theme::Theme;
3use crate::view::wrap::{as_u16, fit_prefix};
4use ratatui::buffer::Buffer;
5use ratatui::layout::{Constraint, Layout, Position, Rect};
6use ratatui::style::{Modifier, Style};
7use ratatui::text::{Line, Span};
8use ratatui::widgets::{Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Widget};
9use std::borrow::Cow;
10use unicode_width::UnicodeWidthStr;
11
12/// A key and what it does, for [`key_hints`]. Borrowed for the fixed labels most
13/// footers use, owned for the ones that interpolate live state.
14pub type KeyHint = (&'static str, Cow<'static, str>);
15
16/// Columns a vertical scrollbar track occupies.
17pub const SCROLLBAR_WIDTH: u16 = 1;
18
19/// Splits `area` into the rows and the scrollbar track beside them, so the
20/// track never sits on top of a row. Without a scrollbar the rows get it all.
21///
22/// Every scrolling pane in the UI — list, document, and form — carves its area
23/// up this way, and builds its rows to the width this leaves.
24pub fn rows_and_track(area: Rect, scrollbar: bool) -> (Rect, Rect) {
25    let track_width = if scrollbar { SCROLLBAR_WIDTH } else { 0 };
26    let [rows, track] = Layout::horizontal([Constraint::Min(0), Constraint::Length(track_width)]).areas(area);
27    (rows, track)
28}
29
30/// The full-width, one-row `Rect` for the `index`-th visible row of `area`, or
31/// nothing once `index` falls past the bottom.
32pub fn row_area(area: Rect, index: usize) -> Option<Rect> {
33    let offset = as_u16(index);
34    (offset < area.height).then(|| Rect { y: area.y + offset, height: 1, ..area })
35}
36
37/// Draws already-built rows directly into a buffer area.
38#[derive(Clone)]
39pub struct RowsView<'a> {
40    lines: Cow<'a, [Line<'static>]>,
41}
42
43impl<'a> RowsView<'a> {
44    pub fn new(lines: &'a [Line<'static>]) -> Self {
45        Self { lines: Cow::Borrowed(lines) }
46    }
47
48    pub fn from_lines(lines: impl IntoIterator<Item = Line<'static>>) -> Self {
49        Self { lines: Cow::Owned(lines.into_iter().collect()) }
50    }
51}
52
53impl Widget for RowsView<'_> {
54    fn render(self, area: Rect, buf: &mut Buffer) {
55        for (index, line) in self.lines.iter().enumerate() {
56            let Some(row) = row_area(area, index) else {
57                return;
58            };
59            line.render(row, buf);
60        }
61    }
62}
63
64/// Draws the vertical scrollbar for `content_rows` of content scrolled to
65/// `offset`, sized against the rows `area` can actually show.
66///
67/// The track length is the number of reachable scroll positions rather than the
68/// row count, so the thumb reaches the bottom exactly when the last row is
69/// visible.
70pub fn render_vertical_scrollbar(area: Rect, buf: &mut Buffer, content_rows: usize, offset: usize) {
71    let scrollable = content_rows.saturating_sub(usize::from(area.height));
72    let mut state = ScrollbarState::new(scrollable).position(offset);
73    StatefulWidget::render(Scrollbar::new(ScrollbarOrientation::VerticalRight), area, buf, &mut state);
74}
75
76/// A footer's key hints, each key in the accent colour ahead of its muted
77/// description. Every full-screen route labels its keys the same way.
78///
79/// A single space separates the pairs: the highlighted keys already break them
80/// up, and these footers are long enough that anything wider wraps at 80
81/// columns.
82pub fn key_hints(hints: &[(impl AsRef<str>, impl AsRef<str>)], theme: &Theme) -> Line<'static> {
83    let key_style = Style::new().fg(theme.accent).add_modifier(Modifier::BOLD);
84    let muted = Style::new().fg(theme.muted);
85    let mut spans = Vec::with_capacity(hints.len() * 3);
86    for (key, description) in hints {
87        if !spans.is_empty() {
88            spans.push(Span::raw(" "));
89        }
90        spans.push(Span::styled(key.as_ref().to_string(), key_style));
91        spans.push(Span::styled(format!(" {}", description.as_ref()), muted));
92    }
93    Line::from(spans)
94}
95
96#[derive(Clone, Copy)]
97pub struct TextInput<'a> {
98    buffer: &'a EditBuffer,
99    prefix: &'a str,
100    style: Style,
101    prefix_style: Style,
102}
103
104impl<'a> TextInput<'a> {
105    pub fn new(buffer: &'a EditBuffer) -> Self {
106        Self { buffer, prefix: "", style: Style::default(), prefix_style: Style::default() }
107    }
108
109    pub fn prefix(mut self, prefix: &'a str) -> Self {
110        self.prefix = prefix;
111        self
112    }
113
114    pub fn style(mut self, style: Style) -> Self {
115        self.style = style;
116        self
117    }
118
119    pub fn prefix_style(mut self, style: Style) -> Self {
120        self.prefix_style = style;
121        self
122    }
123
124    pub fn cursor_position(self, area: Rect) -> Position {
125        let prefix_width = self.prefix.width().min(usize::from(area.width));
126        let available = usize::from(area.width).saturating_sub(prefix_width);
127        let (_, cursor_column) = visible_window(self.buffer, available);
128        Position::new(area.x.saturating_add(u16::try_from(prefix_width + cursor_column).unwrap_or(u16::MAX)), area.y)
129    }
130}
131
132impl Widget for TextInput<'_> {
133    fn render(self, area: Rect, buffer: &mut Buffer) {
134        (&self).render(area, buffer);
135    }
136}
137
138impl Widget for &TextInput<'_> {
139    fn render(self, area: Rect, buffer: &mut Buffer) {
140        let prefix_width = self.prefix.width().min(usize::from(area.width));
141        let available = usize::from(area.width).saturating_sub(prefix_width);
142        let (visible, _) = visible_window(self.buffer, available);
143        Paragraph::new(Line::from(vec![
144            Span::styled(self.prefix, self.prefix_style),
145            Span::styled(visible, self.style),
146        ]))
147        .render(area, buffer);
148    }
149}
150
151/// The widest slice of `buffer` that keeps the cursor visible in `width`
152/// columns, with the cursor's column inside that slice. Shared by every
153/// single-line text input so rendering and cursor placement cannot disagree.
154pub(crate) fn visible_window(buffer: &EditBuffer, width: usize) -> (String, usize) {
155    if width == 0 {
156        return (String::new(), 0);
157    }
158    let text = buffer.text();
159    let cursor_column = text[..buffer.cursor()].width();
160    let start_column = cursor_column.saturating_sub(width.saturating_sub(1));
161    let (start, _) = fit_prefix(text, start_column);
162    let cursor_width = text[start..buffer.cursor()].width();
163    let (visible_len, _) = fit_prefix(&text[start..], width);
164    (text[start..start + visible_len].to_string(), cursor_width.min(width.saturating_sub(1)))
165}
166
167#[cfg(test)]
168mod tests {
169    use super::TextInput;
170    use crate::view::edit_buffer::EditBuffer;
171    use ratatui::Terminal;
172    use ratatui::backend::TestBackend;
173    use ratatui::layout::Rect;
174
175    #[test]
176    fn scrolls_unicode_text_to_keep_cursor_visible() {
177        let input = EditBuffer::new("a界bcdef");
178        let widget = TextInput::new(&input).prefix("> ");
179        assert_eq!(widget.cursor_position(Rect::new(0, 0, 6, 1)).x, 5);
180
181        let mut terminal = Terminal::new(TestBackend::new(6, 1)).unwrap();
182        terminal.draw(|frame| frame.render_widget(widget, frame.area())).unwrap();
183        let rendered =
184            terminal.backend().buffer().content.iter().map(ratatui::buffer::Cell::symbol).collect::<String>();
185        assert!(rendered.contains("def"));
186    }
187}