Skip to main content

clankerdiff_ratatui/
ui.rs

1//! Renderer-native components backed by shared semantic design roles.
2
3use crate::{RatatuiTheme, RatatuiUiTheme};
4use clankerdiff_theme::ControlState;
5pub use clankerdiff_theme::{ButtonVariant, ModalSize, NoticeTone, SelectionState};
6use ratatui::{
7    buffer::Buffer,
8    layout::{Constraint, Layout, Rect},
9    style::Style,
10    text::{Line, Span, Text},
11    widgets::{Block, Borders, Clear, Paragraph, Widget},
12};
13
14pub(crate) const ACTION_BAR_HEIGHT: u16 = 1;
15
16#[derive(Debug, Clone, Copy)]
17pub(crate) struct FrameRegions {
18    pub body: Rect,
19    pub footer: Rect,
20}
21
22pub(crate) struct AppFrame<'a> {
23    title: &'a str,
24    borders: bool,
25    theme: &'a RatatuiTheme,
26}
27impl<'a> AppFrame<'a> {
28    pub(crate) const fn new(title: &'a str, borders: bool, theme: &'a RatatuiTheme) -> Self {
29        Self {
30            title,
31            borders,
32            theme,
33        }
34    }
35    pub(crate) fn render(self, area: Rect, buffer: &mut Buffer) -> FrameRegions {
36        buffer.set_style(
37            area,
38            Style::new().fg(self.theme.ui.text).bg(self.theme.ui.canvas),
39        );
40        let inner = if self.borders {
41            let block = Block::new()
42                .borders(Borders::ALL)
43                .title(format!(" {} ", self.title))
44                .border_style(Style::new().fg(self.theme.ui.accent));
45            let inner = block.inner(area);
46            block.render(area, buffer);
47            inner
48        } else {
49            area
50        };
51        let [body, footer] = Layout::vertical([
52            Constraint::Min(0),
53            Constraint::Length(ACTION_BAR_HEIGHT.min(inner.height)),
54        ])
55        .areas(inner);
56        FrameRegions { body, footer }
57    }
58}
59
60pub(crate) struct ActionBar<'a> {
61    line: Line<'a>,
62    theme: &'a RatatuiTheme,
63}
64impl<'a> ActionBar<'a> {
65    pub(crate) const fn new(line: Line<'a>, theme: &'a RatatuiTheme) -> Self {
66        Self { line, theme }
67    }
68}
69impl Widget for ActionBar<'_> {
70    fn render(self, area: Rect, buffer: &mut Buffer) {
71        Paragraph::new(self.line)
72            .style(
73                Style::new()
74                    .fg(self.theme.ui.text_muted)
75                    .bg(self.theme.ui.surface),
76            )
77            .render(area, buffer);
78    }
79}
80
81/// Terminal representation of the same semantic action used by graphical buttons.
82pub struct ActionLabel<'a> {
83    key: &'a str,
84    label: &'a str,
85    variant: ButtonVariant,
86    theme: &'a RatatuiTheme,
87}
88impl<'a> ActionLabel<'a> {
89    #[must_use]
90    pub const fn new(key: &'a str, label: &'a str, theme: &'a RatatuiTheme) -> Self {
91        Self {
92            key,
93            label,
94            variant: ButtonVariant::Ghost,
95            theme,
96        }
97    }
98    #[must_use]
99    pub const fn variant(mut self, variant: ButtonVariant) -> Self {
100        self.variant = variant;
101        self
102    }
103
104    #[must_use]
105    pub fn into_span(self) -> Span<'static> {
106        Span::styled(
107            format!("[{}] {}", self.key, self.label),
108            self.theme
109                .ui
110                .control_style(self.variant, ControlState::default()),
111        )
112    }
113}
114impl Widget for ActionLabel<'_> {
115    fn render(self, area: Rect, buffer: &mut Buffer) {
116        Paragraph::new(self.into_span()).render(area, buffer);
117    }
118}
119
120pub(crate) struct SelectableRow<'a> {
121    line: Line<'a>,
122    state: SelectionState,
123    theme: &'a RatatuiTheme,
124}
125impl<'a> SelectableRow<'a> {
126    pub(crate) const fn new(
127        line: Line<'a>,
128        state: SelectionState,
129        theme: &'a RatatuiTheme,
130    ) -> Self {
131        Self { line, state, theme }
132    }
133}
134impl Widget for SelectableRow<'_> {
135    fn render(self, area: Rect, buffer: &mut Buffer) {
136        Paragraph::new(self.line)
137            .style(self.theme.ui.selection_style(self.state))
138            .render(area, buffer);
139    }
140}
141
142pub(crate) struct Modal<'a> {
143    title: &'a str,
144    hint: Option<&'a str>,
145    size: ModalSize,
146    theme: &'a RatatuiTheme,
147}
148impl<'a> Modal<'a> {
149    pub(crate) const fn new(title: &'a str, theme: &'a RatatuiTheme) -> Self {
150        Self {
151            title,
152            hint: None,
153            size: ModalSize::Medium,
154            theme,
155        }
156    }
157    pub(crate) const fn hint(mut self, hint: &'a str) -> Self {
158        self.hint = Some(hint);
159        self
160    }
161    pub(crate) const fn size(mut self, size: ModalSize) -> Self {
162        self.size = size;
163        self
164    }
165    pub(crate) fn render(self, area: Rect, buffer: &mut Buffer) -> Rect {
166        let (max_width, max_height) = RatatuiUiTheme::modal_size(self.size);
167        let width = area.width.saturating_sub(4).min(max_width);
168        let height = area.height.saturating_sub(4).min(max_height);
169        let popup = Rect::new(
170            area.x + area.width.saturating_sub(width) / 2,
171            area.y + area.height.saturating_sub(height) / 2,
172            width,
173            height,
174        );
175        Clear.render(popup, buffer);
176        let block = Block::bordered()
177            .title(format!(" {} ", self.title))
178            .title_bottom(
179                self.hint
180                    .map_or_else(Line::default, |hint| Line::from(format!(" {hint} "))),
181            )
182            .style(
183                Style::new()
184                    .fg(self.theme.ui.text)
185                    .bg(self.theme.ui.surface),
186            )
187            .border_style(Style::new().fg(self.theme.ui.border));
188        let inner = block.inner(popup);
189        block.render(popup, buffer);
190        inner
191    }
192}
193
194pub(crate) struct EmptyState<'a> {
195    text: &'a str,
196    tone: NoticeTone,
197    theme: &'a RatatuiTheme,
198}
199impl<'a> EmptyState<'a> {
200    pub(crate) const fn new(text: &'a str, tone: NoticeTone, theme: &'a RatatuiTheme) -> Self {
201        Self { text, tone, theme }
202    }
203}
204impl Widget for EmptyState<'_> {
205    fn render(self, area: Rect, buffer: &mut Buffer) {
206        Paragraph::new(self.text)
207            .style(
208                self.theme
209                    .ui
210                    .notice_style(self.tone)
211                    .bg(self.theme.ui.canvas),
212            )
213            .render(area, buffer);
214    }
215}
216
217pub(crate) fn render_modal_text(
218    area: Rect,
219    buffer: &mut Buffer,
220    text: impl Into<Text<'static>>,
221    theme: &RatatuiTheme,
222) {
223    Paragraph::new(text)
224        .style(Style::new().fg(theme.ui.text).bg(theme.ui.surface))
225        .render(area, buffer);
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use clankerdiff_theme::DiffTheme;
232
233    #[test]
234    fn component_gallery_renders_shared_states() {
235        let theme = RatatuiTheme::from(&DiffTheme::default());
236        let area = Rect::new(0, 0, 48, 16);
237        let mut buffer = Buffer::empty(area);
238        let regions = AppFrame::new("Components", true, &theme).render(area, &mut buffer);
239        SelectableRow::new(Line::from("Selected row"), SelectionState::Focused, &theme).render(
240            Rect::new(regions.body.x, regions.body.y, regions.body.width, 1),
241            &mut buffer,
242        );
243        ActionLabel::new("a", "Approve", &theme)
244            .variant(ButtonVariant::Primary)
245            .render(regions.footer, &mut buffer);
246        let modal = Modal::new("Dialog", &theme)
247            .hint("Esc close")
248            .render(area, &mut buffer);
249        EmptyState::new("Problem", NoticeTone::Error, &theme).render(modal, &mut buffer);
250        assert!(buffer.content().iter().any(|cell| cell.symbol() == "A"));
251        assert!(buffer.content().iter().any(|cell| cell.symbol() == "P"));
252    }
253}