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        let ui = self.theme.ui.on_background(self.theme.ui.surface);
83        Paragraph::new(self.line)
84            .style(Style::new().fg(ui.text_muted).bg(ui.canvas))
85            .render(area, buffer);
86    }
87}
88
89/// Terminal representation of the same semantic action used by graphical buttons.
90pub struct ActionLabel<'a> {
91    key: &'a str,
92    label: &'a str,
93    variant: ButtonVariant,
94    theme: &'a RatatuiTheme,
95}
96impl<'a> ActionLabel<'a> {
97    #[must_use]
98    pub const fn new(key: &'a str, label: &'a str, theme: &'a RatatuiTheme) -> Self {
99        Self {
100            key,
101            label,
102            variant: ButtonVariant::Ghost,
103            theme,
104        }
105    }
106    #[must_use]
107    pub const fn variant(mut self, variant: ButtonVariant) -> Self {
108        self.variant = variant;
109        self
110    }
111
112    #[must_use]
113    pub fn into_span(self) -> Span<'static> {
114        Span::styled(
115            format!("[{}] {}", self.key, self.label),
116            self.theme
117                .ui
118                .control_style(self.variant, ControlState::default()),
119        )
120    }
121}
122impl Widget for ActionLabel<'_> {
123    fn render(self, area: Rect, buffer: &mut Buffer) {
124        Paragraph::new(self.into_span()).render(area, buffer);
125    }
126}
127
128pub(crate) struct Modal<'a> {
129    title: &'a str,
130    hint: Option<&'a str>,
131    size: ModalSize,
132    theme: &'a RatatuiTheme,
133}
134impl<'a> Modal<'a> {
135    pub(crate) const fn new(title: &'a str, size: ModalSize, theme: &'a RatatuiTheme) -> Self {
136        Self {
137            title,
138            hint: None,
139            size,
140            theme,
141        }
142    }
143    pub(crate) const fn hint(mut self, hint: &'a str) -> Self {
144        self.hint = Some(hint);
145        self
146    }
147    pub(crate) fn render(self, area: Rect, buffer: &mut Buffer) -> Rect {
148        let (max_width, max_height) = RatatuiUiTheme::modal_size(self.size);
149        let width = area.width.saturating_sub(4).min(max_width);
150        let height = area.height.saturating_sub(4).min(max_height);
151        let popup = Rect::new(
152            area.x + area.width.saturating_sub(width) / 2,
153            area.y + area.height.saturating_sub(height) / 2,
154            width,
155            height,
156        );
157        Clear.render(popup, buffer);
158        let block = Block::bordered()
159            .title(format!(" {} ", self.title))
160            .title_bottom(
161                self.hint
162                    .map_or_else(Line::default, |hint| Line::from(format!(" {hint} "))),
163            )
164            .style(self.theme.ui.selection_style(SelectionState::None))
165            .border_style(
166                Style::new().fg(self.theme.ui.on_background(self.theme.ui.surface).border),
167            );
168        let inner = block.inner(popup);
169        block.render(popup, buffer);
170        inner
171    }
172}
173
174pub(crate) struct EmptyState<'a> {
175    text: &'a str,
176    tone: NoticeTone,
177    theme: &'a RatatuiTheme,
178}
179impl<'a> EmptyState<'a> {
180    pub(crate) const fn new(text: &'a str, tone: NoticeTone, theme: &'a RatatuiTheme) -> Self {
181        Self { text, tone, theme }
182    }
183}
184impl Widget for EmptyState<'_> {
185    fn render(self, area: Rect, buffer: &mut Buffer) {
186        Paragraph::new(self.text)
187            .style(
188                self.theme
189                    .ui
190                    .notice_style(self.tone)
191                    .bg(self.theme.ui.canvas),
192            )
193            .render(area, buffer);
194    }
195}
196
197pub(crate) fn render_modal_text(
198    area: Rect,
199    buffer: &mut Buffer,
200    text: impl Into<Text<'static>>,
201    theme: &RatatuiTheme,
202) {
203    Paragraph::new(text)
204        .style(theme.ui.selection_style(SelectionState::None))
205        .render(area, buffer);
206}