Skip to main content

rab/agent/ui/components/
confirm_overlay.rs

1//! ConfirmOverlay — generic confirmation dialog (matches pi's confirm dialog pattern).
2//!
3//! Shows a titled message with [Y]es / [N]o / Enter choices.
4//! Communicates the result via a callback.
5
6use crate::agent::ui::theme::current_theme;
7use crate::tui::Component;
8use crate::tui::keybindings::{ACTION_SELECT_CANCEL, ACTION_SELECT_CONFIRM, get_keybindings};
9use crossterm::event::KeyEvent;
10
11/// Confirmation overlay with yes/no buttons.
12pub struct ConfirmOverlay {
13    title: String,
14    message: String,
15    on_confirm: Option<Box<dyn FnOnce()>>,
16    on_cancel: Option<Box<dyn FnOnce()>>,
17    selected: bool, // true = Yes selected, false = No selected
18    done: bool,
19}
20
21impl ConfirmOverlay {
22    pub fn new(title: impl Into<String>, message: impl Into<String>) -> Self {
23        Self {
24            title: title.into(),
25            message: message.into(),
26            on_confirm: None,
27            on_cancel: None,
28            selected: true, // Default to Yes
29            done: false,
30        }
31    }
32
33    /// Set the confirmation callback.
34    pub fn on_confirm<F>(&mut self, f: F)
35    where
36        F: FnOnce() + 'static,
37    {
38        self.on_confirm = Some(Box::new(f));
39    }
40
41    /// Set the cancel callback.
42    pub fn on_cancel<F>(&mut self, f: F)
43    where
44        F: FnOnce() + 'static,
45    {
46        self.on_cancel = Some(Box::new(f));
47    }
48}
49
50impl Component for ConfirmOverlay {
51    fn render(&mut self, width: usize) -> Vec<String> {
52        let theme = current_theme();
53        let mut lines: Vec<String> = Vec::new();
54
55        // Top border
56        lines.push(theme.dim(&"─".repeat(width.saturating_sub(2))));
57        lines.push(String::new());
58
59        // Title
60        lines.push(format!("  {}", theme.bold(&theme.accent(&self.title))));
61        lines.push(String::new());
62
63        // Message (word-wrapped to fit width)
64        let max_text_width = width.saturating_sub(4); // 2 spaces padding each side
65        if max_text_width > 10 {
66            let mut remaining = self.message.as_str();
67            while !remaining.is_empty() {
68                let break_at = if remaining.len() <= max_text_width {
69                    remaining.len()
70                } else {
71                    // Try to break at a space
72                    let slice = &remaining[..max_text_width];
73                    let last_space = slice.rfind(' ').unwrap_or(max_text_width);
74                    if last_space == 0 {
75                        max_text_width
76                    } else {
77                        last_space
78                    }
79                };
80                lines.push(format!("  {}", &remaining[..break_at]));
81                remaining = remaining[break_at..].trim_start();
82            }
83        } else {
84            lines.push(format!("  {}", self.message));
85        }
86        lines.push(String::new());
87
88        // Yes/No buttons
89        let yes_style = if self.selected {
90            theme.bold(&theme.fg_key(crate::agent::ui::theme::ThemeKey::Success, "[Y] Yes"))
91        } else {
92            theme.dim("[Y] Yes")
93        };
94        let no_style = if !self.selected {
95            theme.bold(&theme.fg_key(crate::agent::ui::theme::ThemeKey::Error, "[N] No"))
96        } else {
97            theme.dim("[N] No")
98        };
99
100        lines.push(format!("  {}    {}", yes_style, no_style));
101        lines.push(String::new());
102
103        // Key hints
104        lines.push(format!(
105            "  {}",
106            theme.dim("Tab/← →: switch · Enter: confirm · Esc: cancel")
107        ));
108
109        lines.push(String::new());
110        // Bottom border
111        lines.push(theme.dim(&"─".repeat(width.saturating_sub(2))));
112
113        lines
114    }
115
116    fn handle_input(&mut self, key: &KeyEvent) -> bool {
117        if self.done {
118            return false;
119        }
120
121        let kb = get_keybindings();
122
123        // Escape cancels
124        if kb.matches(key, ACTION_SELECT_CANCEL) {
125            self.done = true;
126            if let Some(cb) = self.on_cancel.take() {
127                cb();
128            }
129            return false;
130        }
131
132        // Enter or 'y' confirms (when Yes selected)
133        if kb.matches(key, ACTION_SELECT_CONFIRM)
134            || key.code == crossterm::event::KeyCode::Char('y')
135        {
136            self.done = true;
137            if let Some(cb) = self.on_confirm.take() {
138                cb();
139            }
140            return false;
141        }
142
143        // 'n' cancels
144        if key.code == crossterm::event::KeyCode::Char('n') {
145            self.done = true;
146            if let Some(cb) = self.on_cancel.take() {
147                cb();
148            }
149            return false;
150        }
151
152        // Tab / Right / Left toggles selection
153        if key.code == crossterm::event::KeyCode::Tab
154            || key.code == crossterm::event::KeyCode::Right
155            || key.code == crossterm::event::KeyCode::Left
156        {
157            self.selected = !self.selected;
158            return true;
159        }
160
161        false
162    }
163}