1use 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
81pub 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}