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    footer: bool,
26    theme: &'a RatatuiTheme,
27}
28impl<'a> AppFrame<'a> {
29    pub(crate) const fn new(title: &'a str, borders: bool, theme: &'a RatatuiTheme) -> Self {
30        Self {
31            title,
32            borders,
33            footer: true,
34            theme,
35        }
36    }
37    pub(crate) const fn footer(mut self, footer: bool) -> Self {
38        self.footer = footer;
39        self
40    }
41
42    pub(crate) fn render(self, area: Rect, buffer: &mut Buffer) -> FrameRegions {
43        buffer.set_style(
44            area,
45            Style::new().fg(self.theme.ui.text).bg(self.theme.ui.canvas),
46        );
47        let inner = if self.borders {
48            let block = Block::new()
49                .borders(Borders::ALL)
50                .title(format!(" {} ", self.title))
51                .border_style(Style::new().fg(self.theme.ui.accent));
52            let inner = block.inner(area);
53            block.render(area, buffer);
54            inner
55        } else {
56            area
57        };
58        let [body, footer] = Layout::vertical([
59            Constraint::Min(0),
60            Constraint::Length(if self.footer {
61                ACTION_BAR_HEIGHT.min(inner.height)
62            } else {
63                0
64            }),
65        ])
66        .areas(inner);
67        FrameRegions { body, footer }
68    }
69}
70
71pub(crate) struct ActionBar<'a> {
72    line: Line<'a>,
73    theme: &'a RatatuiTheme,
74}
75impl<'a> ActionBar<'a> {
76    pub(crate) const fn new(line: Line<'a>, theme: &'a RatatuiTheme) -> Self {
77        Self { line, theme }
78    }
79}
80impl Widget for ActionBar<'_> {
81    fn render(self, area: Rect, buffer: &mut Buffer) {
82        Paragraph::new(self.line)
83            .style(
84                Style::new()
85                    .fg(self.theme.ui.text_muted)
86                    .bg(self.theme.ui.surface),
87            )
88            .render(area, buffer);
89    }
90}
91
92/// Terminal representation of the same semantic action used by graphical buttons.
93pub struct ActionLabel<'a> {
94    key: &'a str,
95    label: &'a str,
96    variant: ButtonVariant,
97    theme: &'a RatatuiTheme,
98}
99impl<'a> ActionLabel<'a> {
100    #[must_use]
101    pub const fn new(key: &'a str, label: &'a str, theme: &'a RatatuiTheme) -> Self {
102        Self {
103            key,
104            label,
105            variant: ButtonVariant::Ghost,
106            theme,
107        }
108    }
109    #[must_use]
110    pub const fn variant(mut self, variant: ButtonVariant) -> Self {
111        self.variant = variant;
112        self
113    }
114
115    #[must_use]
116    pub fn into_span(self) -> Span<'static> {
117        Span::styled(
118            format!("[{}] {}", self.key, self.label),
119            self.theme
120                .ui
121                .control_style(self.variant, ControlState::default()),
122        )
123    }
124}
125impl Widget for ActionLabel<'_> {
126    fn render(self, area: Rect, buffer: &mut Buffer) {
127        Paragraph::new(self.into_span()).render(area, buffer);
128    }
129}
130
131pub(crate) struct Modal<'a> {
132    title: &'a str,
133    hint: Option<&'a str>,
134    size: ModalSize,
135    theme: &'a RatatuiTheme,
136}
137impl<'a> Modal<'a> {
138    pub(crate) const fn new(title: &'a str, size: ModalSize, theme: &'a RatatuiTheme) -> Self {
139        Self {
140            title,
141            hint: None,
142            size,
143            theme,
144        }
145    }
146    pub(crate) const fn hint(mut self, hint: &'a str) -> Self {
147        self.hint = Some(hint);
148        self
149    }
150    pub(crate) fn render(self, area: Rect, buffer: &mut Buffer) -> Rect {
151        let (max_width, max_height) = RatatuiUiTheme::modal_size(self.size);
152        let width = area.width.saturating_sub(4).min(max_width);
153        let height = area.height.saturating_sub(4).min(max_height);
154        let popup = Rect::new(
155            area.x + area.width.saturating_sub(width) / 2,
156            area.y + area.height.saturating_sub(height) / 2,
157            width,
158            height,
159        );
160        Clear.render(popup, buffer);
161        let block = Block::bordered()
162            .title(format!(" {} ", self.title))
163            .title_bottom(
164                self.hint
165                    .map_or_else(Line::default, |hint| Line::from(format!(" {hint} "))),
166            )
167            .style(
168                Style::new()
169                    .fg(self.theme.ui.text)
170                    .bg(self.theme.ui.surface),
171            )
172            .border_style(Style::new().fg(self.theme.ui.border));
173        let inner = block.inner(popup);
174        block.render(popup, buffer);
175        inner
176    }
177}
178
179pub(crate) struct EmptyState<'a> {
180    text: &'a str,
181    tone: NoticeTone,
182    theme: &'a RatatuiTheme,
183}
184impl<'a> EmptyState<'a> {
185    pub(crate) const fn new(text: &'a str, tone: NoticeTone, theme: &'a RatatuiTheme) -> Self {
186        Self { text, tone, theme }
187    }
188}
189impl Widget for EmptyState<'_> {
190    fn render(self, area: Rect, buffer: &mut Buffer) {
191        Paragraph::new(self.text)
192            .style(
193                self.theme
194                    .ui
195                    .notice_style(self.tone)
196                    .bg(self.theme.ui.canvas),
197            )
198            .render(area, buffer);
199    }
200}
201
202pub(crate) fn render_modal_text(
203    area: Rect,
204    buffer: &mut Buffer,
205    text: impl Into<Text<'static>>,
206    theme: &RatatuiTheme,
207) {
208    Paragraph::new(text)
209        .style(Style::new().fg(theme.ui.text).bg(theme.ui.surface))
210        .render(area, buffer);
211}