Skip to main content

yarli_cli/dashboard/
overlay.rs

1//! Overlay stack — focus management for floating panels (Section 30, 32).
2//!
3//! Manages a stack of floating overlays rendered above dashboard content.
4//! Overlays are dismissible with `Esc` and never block `Ctrl+C`.
5
6use ratatui::layout::Rect;
7use ratatui::style::{Color, Modifier, Style};
8use ratatui::text::{Line, Span};
9use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
10
11/// Identifies an overlay type.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum OverlayKind {
14    /// Help dialog (keyboard shortcuts reference).
15    Help,
16    /// Confirmation dialog (e.g., retry task, unblock).
17    Confirm,
18    /// Gate detail view (expanded gate evaluation info).
19    GateDetail,
20}
21
22impl OverlayKind {
23    /// Title for the overlay border.
24    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/// Content for a confirmation dialog.
34#[derive(Debug, Clone)]
35pub struct ConfirmContent {
36    /// The question/prompt to display.
37    pub message: String,
38    /// Label for the confirm action.
39    pub confirm_label: String,
40    /// Label for the cancel action.
41    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/// Content for a gate detail view.
55#[derive(Debug, Clone)]
56pub struct GateDetailContent {
57    /// Gate name.
58    pub name: String,
59    /// Whether the gate passed.
60    pub passed: bool,
61    /// Reason or detail text.
62    pub detail: String,
63}
64
65/// An overlay entry on the stack.
66#[derive(Debug, Clone)]
67pub struct OverlayEntry {
68    /// What kind of overlay this is.
69    pub kind: OverlayKind,
70    /// Content lines to display.
71    pub content: Vec<String>,
72    /// Width as a fraction of terminal width (0.0-1.0).
73    pub width_fraction: f32,
74    /// Height as a fraction of terminal height (0.0-1.0).
75    pub height_fraction: f32,
76}
77
78impl OverlayEntry {
79    /// Create a help overlay.
80    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    /// Create a confirmation dialog overlay.
90    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    /// Create a gate detail overlay.
105    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    /// Compute the centered rectangle for this overlay within the given area.
122    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
135/// A stack of overlays with focus management.
136///
137/// The topmost overlay receives keyboard input. Overlays are dismissed
138/// with `Esc` (pops the top) and never block `Ctrl+C`.
139pub struct OverlayStack {
140    /// Stack of active overlays (last = topmost = focused).
141    entries: Vec<OverlayEntry>,
142}
143
144impl OverlayStack {
145    /// Create an empty overlay stack.
146    pub fn new() -> Self {
147        Self {
148            entries: Vec::new(),
149        }
150    }
151
152    /// Whether there are any active overlays.
153    pub fn is_empty(&self) -> bool {
154        self.entries.is_empty()
155    }
156
157    /// Number of active overlays.
158    pub fn len(&self) -> usize {
159        self.entries.len()
160    }
161
162    /// Whether the overlay stack has focus (any overlay is showing).
163    pub fn has_focus(&self) -> bool {
164        !self.entries.is_empty()
165    }
166
167    /// Get the topmost overlay kind (if any).
168    pub fn top_kind(&self) -> Option<OverlayKind> {
169        self.entries.last().map(|e| e.kind)
170    }
171
172    /// Push an overlay onto the stack. If an overlay of the same kind
173    /// already exists, it is replaced (moved to top).
174    pub fn push(&mut self, entry: OverlayEntry) {
175        // Remove existing overlay of same kind to avoid duplicates.
176        self.entries.retain(|e| e.kind != entry.kind);
177        self.entries.push(entry);
178    }
179
180    /// Toggle an overlay: if the kind is already showing, dismiss it;
181    /// otherwise push it.
182    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    /// Dismiss (pop) the topmost overlay. Returns the dismissed entry.
192    pub fn pop(&mut self) -> Option<OverlayEntry> {
193        self.entries.pop()
194    }
195
196    /// Dismiss a specific overlay kind.
197    pub fn dismiss(&mut self, kind: OverlayKind) {
198        self.entries.retain(|e| e.kind != kind);
199    }
200
201    /// Dismiss all overlays.
202    pub fn clear(&mut self) {
203        self.entries.clear();
204    }
205
206    /// Render all overlays onto the frame, from bottom to top.
207    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            // Clear the area behind the overlay.
212            frame.render_widget(Clear, overlay_rect);
213
214            // Build the bordered block.
215            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            // Render content lines.
229            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    /// Get an iterator over overlay entries (bottom to top).
239    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        // Should be centered.
353        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        // Should be at least minimum size.
365        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}