1use ratatui::layout::Rect;
7use ratatui::style::{Color, Modifier, Style};
8use ratatui::text::{Line, Span};
9use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum OverlayKind {
14 Help,
16 Confirm,
18 GateDetail,
20}
21
22impl OverlayKind {
23 pub fn title(self) -> &'static str {
25 match self {
26 OverlayKind::Help => " Help ",
27 OverlayKind::Confirm => " Confirm ",
28 OverlayKind::GateDetail => " Gate Details ",
29 }
30 }
31}
32
33#[derive(Debug, Clone)]
35pub struct ConfirmContent {
36 pub message: String,
38 pub confirm_label: String,
40 pub cancel_label: String,
42}
43
44impl Default for ConfirmContent {
45 fn default() -> Self {
46 Self {
47 message: String::new(),
48 confirm_label: "Yes (y)".into(),
49 cancel_label: "No (n/Esc)".into(),
50 }
51 }
52}
53
54#[derive(Debug, Clone)]
56pub struct GateDetailContent {
57 pub name: String,
59 pub passed: bool,
61 pub detail: String,
63}
64
65#[derive(Debug, Clone)]
67pub struct OverlayEntry {
68 pub kind: OverlayKind,
70 pub content: Vec<String>,
72 pub width_fraction: f32,
74 pub height_fraction: f32,
76}
77
78impl OverlayEntry {
79 pub fn help(content: Vec<String>) -> Self {
81 Self {
82 kind: OverlayKind::Help,
83 content,
84 width_fraction: 0.6,
85 height_fraction: 0.7,
86 }
87 }
88
89 pub fn confirm(confirm: &ConfirmContent) -> Self {
91 let content = vec![
92 confirm.message.clone(),
93 String::new(),
94 format!(" {} / {}", confirm.confirm_label, confirm.cancel_label),
95 ];
96 Self {
97 kind: OverlayKind::Confirm,
98 content,
99 width_fraction: 0.4,
100 height_fraction: 0.25,
101 }
102 }
103
104 pub fn gate_detail(detail: &GateDetailContent) -> Self {
106 let status = if detail.passed { "PASSED" } else { "FAILED" };
107 let content = vec![
108 format!("Gate: {}", detail.name),
109 format!("Status: {status}"),
110 String::new(),
111 detail.detail.clone(),
112 ];
113 Self {
114 kind: OverlayKind::GateDetail,
115 content,
116 width_fraction: 0.5,
117 height_fraction: 0.4,
118 }
119 }
120
121 pub fn compute_rect(&self, area: Rect) -> Rect {
123 let width = ((area.width as f32 * self.width_fraction) as u16)
124 .max(20)
125 .min(area.width.saturating_sub(2));
126 let height = ((area.height as f32 * self.height_fraction) as u16)
127 .max(5)
128 .min(area.height.saturating_sub(2));
129 let x = (area.width.saturating_sub(width)) / 2;
130 let y = (area.height.saturating_sub(height)) / 2;
131 Rect::new(x, y, width, height)
132 }
133}
134
135pub struct OverlayStack {
140 entries: Vec<OverlayEntry>,
142}
143
144impl OverlayStack {
145 pub fn new() -> Self {
147 Self {
148 entries: Vec::new(),
149 }
150 }
151
152 pub fn is_empty(&self) -> bool {
154 self.entries.is_empty()
155 }
156
157 pub fn len(&self) -> usize {
159 self.entries.len()
160 }
161
162 pub fn has_focus(&self) -> bool {
164 !self.entries.is_empty()
165 }
166
167 pub fn top_kind(&self) -> Option<OverlayKind> {
169 self.entries.last().map(|e| e.kind)
170 }
171
172 pub fn push(&mut self, entry: OverlayEntry) {
175 self.entries.retain(|e| e.kind != entry.kind);
177 self.entries.push(entry);
178 }
179
180 pub fn toggle(&mut self, entry: OverlayEntry) {
183 let kind = entry.kind;
184 if self.entries.iter().any(|e| e.kind == kind) {
185 self.dismiss(kind);
186 } else {
187 self.push(entry);
188 }
189 }
190
191 pub fn pop(&mut self) -> Option<OverlayEntry> {
193 self.entries.pop()
194 }
195
196 pub fn dismiss(&mut self, kind: OverlayKind) {
198 self.entries.retain(|e| e.kind != kind);
199 }
200
201 pub fn clear(&mut self) {
203 self.entries.clear();
204 }
205
206 pub fn render(&self, frame: &mut ratatui::Frame, area: Rect) {
208 for entry in &self.entries {
209 let overlay_rect = entry.compute_rect(area);
210
211 frame.render_widget(Clear, overlay_rect);
213
214 let block = Block::default()
216 .borders(Borders::ALL)
217 .border_style(Style::default().fg(Color::Cyan))
218 .title(Span::styled(
219 entry.kind.title(),
220 Style::default()
221 .fg(Color::Cyan)
222 .add_modifier(Modifier::BOLD),
223 ));
224
225 let inner = block.inner(overlay_rect);
226 frame.render_widget(block, overlay_rect);
227
228 let lines: Vec<Line<'_>> = entry
230 .content
231 .iter()
232 .map(|s| Line::from(s.as_str()))
233 .collect();
234 frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
235 }
236 }
237
238 pub fn iter(&self) -> impl Iterator<Item = &OverlayEntry> {
240 self.entries.iter()
241 }
242}
243
244impl Default for OverlayStack {
245 fn default() -> Self {
246 Self::new()
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253
254 fn help_lines() -> Vec<String> {
255 vec![
256 "q: quit".into(),
257 "Tab: focus next".into(),
258 "?: toggle help".into(),
259 ]
260 }
261
262 #[test]
263 fn new_stack_is_empty() {
264 let stack = OverlayStack::new();
265 assert!(stack.is_empty());
266 assert_eq!(stack.len(), 0);
267 assert!(!stack.has_focus());
268 assert_eq!(stack.top_kind(), None);
269 }
270
271 #[test]
272 fn push_and_top() {
273 let mut stack = OverlayStack::new();
274 stack.push(OverlayEntry::help(help_lines()));
275 assert!(!stack.is_empty());
276 assert_eq!(stack.len(), 1);
277 assert!(stack.has_focus());
278 assert_eq!(stack.top_kind(), Some(OverlayKind::Help));
279 }
280
281 #[test]
282 fn push_replaces_same_kind() {
283 let mut stack = OverlayStack::new();
284 stack.push(OverlayEntry::help(vec!["old".into()]));
285 stack.push(OverlayEntry::help(vec!["new".into()]));
286 assert_eq!(stack.len(), 1);
287 assert_eq!(stack.entries[0].content[0], "new");
288 }
289
290 #[test]
291 fn push_stacks_different_kinds() {
292 let mut stack = OverlayStack::new();
293 stack.push(OverlayEntry::help(help_lines()));
294 stack.push(OverlayEntry::confirm(&ConfirmContent {
295 message: "Retry?".into(),
296 ..Default::default()
297 }));
298 assert_eq!(stack.len(), 2);
299 assert_eq!(stack.top_kind(), Some(OverlayKind::Confirm));
300 }
301
302 #[test]
303 fn pop_removes_topmost() {
304 let mut stack = OverlayStack::new();
305 stack.push(OverlayEntry::help(help_lines()));
306 stack.push(OverlayEntry::confirm(&ConfirmContent::default()));
307 let popped = stack.pop().unwrap();
308 assert_eq!(popped.kind, OverlayKind::Confirm);
309 assert_eq!(stack.top_kind(), Some(OverlayKind::Help));
310 }
311
312 #[test]
313 fn dismiss_by_kind() {
314 let mut stack = OverlayStack::new();
315 stack.push(OverlayEntry::help(help_lines()));
316 stack.push(OverlayEntry::confirm(&ConfirmContent::default()));
317 stack.dismiss(OverlayKind::Help);
318 assert_eq!(stack.len(), 1);
319 assert_eq!(stack.top_kind(), Some(OverlayKind::Confirm));
320 }
321
322 #[test]
323 fn clear_removes_all() {
324 let mut stack = OverlayStack::new();
325 stack.push(OverlayEntry::help(help_lines()));
326 stack.push(OverlayEntry::confirm(&ConfirmContent::default()));
327 stack.clear();
328 assert!(stack.is_empty());
329 }
330
331 #[test]
332 fn toggle_pushes_when_absent() {
333 let mut stack = OverlayStack::new();
334 stack.toggle(OverlayEntry::help(help_lines()));
335 assert_eq!(stack.len(), 1);
336 assert_eq!(stack.top_kind(), Some(OverlayKind::Help));
337 }
338
339 #[test]
340 fn toggle_dismisses_when_present() {
341 let mut stack = OverlayStack::new();
342 stack.push(OverlayEntry::help(help_lines()));
343 stack.toggle(OverlayEntry::help(help_lines()));
344 assert!(stack.is_empty());
345 }
346
347 #[test]
348 fn compute_rect_centered() {
349 let area = Rect::new(0, 0, 100, 50);
350 let entry = OverlayEntry::help(help_lines());
351 let rect = entry.compute_rect(area);
352 assert!(rect.x > 0);
354 assert!(rect.y > 0);
355 assert!(rect.x + rect.width <= area.width);
356 assert!(rect.y + rect.height <= area.height);
357 }
358
359 #[test]
360 fn compute_rect_small_terminal() {
361 let area = Rect::new(0, 0, 30, 10);
362 let entry = OverlayEntry::help(help_lines());
363 let rect = entry.compute_rect(area);
364 assert!(rect.width >= 20);
366 assert!(rect.height >= 5);
367 }
368
369 #[test]
370 fn overlay_kind_titles() {
371 assert_eq!(OverlayKind::Help.title(), " Help ");
372 assert_eq!(OverlayKind::Confirm.title(), " Confirm ");
373 assert_eq!(OverlayKind::GateDetail.title(), " Gate Details ");
374 }
375
376 #[test]
377 fn confirm_entry_content() {
378 let confirm = ConfirmContent {
379 message: "Retry this task?".into(),
380 confirm_label: "Yes (y)".into(),
381 cancel_label: "No (n)".into(),
382 };
383 let entry = OverlayEntry::confirm(&confirm);
384 assert_eq!(entry.kind, OverlayKind::Confirm);
385 assert!(entry.content[0].contains("Retry"));
386 }
387
388 #[test]
389 fn gate_detail_entry_content() {
390 let detail = GateDetailContent {
391 name: "tests_passed".into(),
392 passed: false,
393 detail: "Exit code 1".into(),
394 };
395 let entry = OverlayEntry::gate_detail(&detail);
396 assert_eq!(entry.kind, OverlayKind::GateDetail);
397 assert!(entry.content.iter().any(|l| l.contains("FAILED")));
398 }
399
400 #[test]
401 fn default_stack_is_empty() {
402 let stack = OverlayStack::default();
403 assert!(stack.is_empty());
404 }
405
406 #[test]
407 fn iter_returns_bottom_to_top() {
408 let mut stack = OverlayStack::new();
409 stack.push(OverlayEntry::help(help_lines()));
410 stack.push(OverlayEntry::confirm(&ConfirmContent::default()));
411 let kinds: Vec<_> = stack.iter().map(|e| e.kind).collect();
412 assert_eq!(kinds, vec![OverlayKind::Help, OverlayKind::Confirm]);
413 }
414}