Skip to main content

mxr_tui/ui/
compose_picker.rs

1use ratatui::prelude::*;
2use ratatui::widgets::*;
3
4/// A contact entry for autocomplete.
5#[derive(Debug, Clone)]
6pub struct Contact {
7    pub name: String,
8    pub email: String,
9}
10
11impl Contact {
12    pub fn display(&self) -> String {
13        if self.name.is_empty() {
14            self.email.clone()
15        } else {
16            format!("{} <{}>", self.name, self.email)
17        }
18    }
19}
20
21#[derive(Default)]
22pub struct ComposePicker {
23    pub visible: bool,
24    pub input: String,
25    pub contacts: Vec<Contact>,
26    pub filtered: Vec<usize>,
27    pub selected: usize,
28    /// Already-chosen recipients.
29    pub recipients: Vec<String>,
30}
31
32impl ComposePicker {
33    pub fn open(&mut self, contacts: Vec<Contact>) {
34        self.visible = true;
35        self.input.clear();
36        self.selected = 0;
37        self.recipients.clear();
38        self.contacts = contacts;
39        self.update_filtered();
40    }
41
42    pub fn close(&mut self) {
43        self.visible = false;
44        self.input.clear();
45        self.contacts.clear();
46        self.filtered.clear();
47        self.recipients.clear();
48    }
49
50    pub fn on_char(&mut self, c: char) {
51        self.input.push(c);
52        self.selected = 0;
53        self.update_filtered();
54    }
55
56    pub fn on_backspace(&mut self) {
57        if self.input.is_empty() {
58            // Remove last recipient on backspace with empty input
59            self.recipients.pop();
60        } else {
61            self.input.pop();
62            self.selected = 0;
63            self.update_filtered();
64        }
65    }
66
67    pub fn select_next(&mut self) {
68        if !self.filtered.is_empty() {
69            self.selected = (self.selected + 1) % self.filtered.len();
70        }
71    }
72
73    pub fn select_prev(&mut self) {
74        if !self.filtered.is_empty() {
75            self.selected = self
76                .selected
77                .checked_sub(1)
78                .unwrap_or(self.filtered.len() - 1);
79        }
80    }
81
82    /// Add the selected contact (or raw input) to recipients.
83    /// Returns true if added, false if nothing to add.
84    pub fn add_recipient(&mut self) -> bool {
85        let email = if let Some(&idx) = self.filtered.get(self.selected) {
86            self.contacts[idx].email.clone()
87        } else if !self.input.is_empty() {
88            // Use raw input as email if no match selected
89            self.input.clone()
90        } else {
91            return false;
92        };
93
94        if !email.is_empty() && !self.recipients.contains(&email) {
95            self.recipients.push(email);
96        }
97        self.input.clear();
98        self.selected = 0;
99        self.update_filtered();
100        true
101    }
102
103    /// Confirm all recipients. Returns the comma-separated recipient string.
104    /// Returns empty string if no recipients (user will fill in editor).
105    pub fn confirm(&mut self) -> String {
106        // Add any remaining input as a recipient
107        if !self.input.is_empty() {
108            self.add_recipient();
109        }
110        let result = self.recipients.join(", ");
111        self.close();
112        result
113    }
114
115    fn update_filtered(&mut self) {
116        let query = self.input.to_lowercase();
117        self.filtered = self
118            .contacts
119            .iter()
120            .enumerate()
121            .filter(|(_, c)| {
122                // Exclude already-selected recipients
123                if self.recipients.contains(&c.email) {
124                    return false;
125                }
126                if query.is_empty() {
127                    return true;
128                }
129                c.name.to_lowercase().contains(&query) || c.email.to_lowercase().contains(&query)
130            })
131            .map(|(i, _)| i)
132            .collect();
133    }
134}
135
136pub fn draw(frame: &mut Frame, area: Rect, picker: &ComposePicker, theme: &crate::theme::Theme) {
137    if !picker.visible {
138        return;
139    }
140
141    let width = (area.width as u32 * 60 / 100).min(70) as u16;
142    let height = (picker.filtered.len() as u16 + 6)
143        .min(area.height * 60 / 100)
144        .max(8);
145    let x = area.x + (area.width.saturating_sub(width)) / 2;
146    let y = area.y + (area.height.saturating_sub(height)) / 2;
147    let popup_area = Rect::new(x, y, width, height);
148
149    frame.render_widget(Clear, popup_area);
150
151    let block = Block::bordered()
152        .title(" Compose — To: (Tab to add, Enter to compose) ")
153        .border_type(BorderType::Rounded)
154        .border_style(Style::default().fg(theme.accent));
155
156    let inner = block.inner(popup_area);
157    frame.render_widget(block, popup_area);
158
159    if inner.height < 3 {
160        return;
161    }
162
163    // Recipients line
164    let recipients_area = Rect::new(inner.x, inner.y, inner.width, 1);
165    if picker.recipients.is_empty() {
166        frame.render_widget(
167            Paragraph::new("").style(Style::default().fg(theme.text_muted)),
168            recipients_area,
169        );
170    } else {
171        let chips: Vec<Span> = picker
172            .recipients
173            .iter()
174            .flat_map(|r| {
175                vec![
176                    Span::styled(
177                        format!(" {} ", r),
178                        Style::default()
179                            .bg(theme.selection_bg)
180                            .fg(theme.text_primary),
181                    ),
182                    Span::raw(" "),
183                ]
184            })
185            .collect();
186        frame.render_widget(Paragraph::new(Line::from(chips)), recipients_area);
187    }
188
189    // Input line
190    let input_area = Rect::new(inner.x, inner.y + 1, inner.width, 1);
191    let input_line = Paragraph::new(format!("> {}", picker.input))
192        .style(Style::default().fg(theme.text_primary));
193    frame.render_widget(input_line, input_area);
194
195    // Contact suggestions
196    let list_area = Rect::new(
197        inner.x,
198        inner.y + 2,
199        inner.width,
200        inner.height.saturating_sub(2),
201    );
202
203    let items: Vec<ListItem> = picker
204        .filtered
205        .iter()
206        .enumerate()
207        .take(list_area.height as usize)
208        .map(|(i, &idx)| {
209            let contact = &picker.contacts[idx];
210            let display = contact.display();
211            let style = if i == picker.selected {
212                theme.highlight_style()
213            } else {
214                Style::default()
215            };
216            ListItem::new(format!("  {}", display)).style(style)
217        })
218        .collect();
219
220    frame.render_widget(List::new(items), list_area);
221}