mxr_tui/ui/
compose_picker.rs1use ratatui::prelude::*;
2use ratatui::widgets::*;
3
4#[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 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 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 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 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 pub fn confirm(&mut self) -> String {
106 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 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 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 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 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}