1use crate::app::ActivePane;
2use crate::theme::Theme;
3use mxr_core::types::{Label, LabelKind, SavedSearch};
4use ratatui::prelude::*;
5use ratatui::widgets::*;
6
7pub struct SidebarView<'a> {
8 pub labels: &'a [Label],
9 pub active_pane: &'a ActivePane,
10 pub saved_searches: &'a [SavedSearch],
11 pub sidebar_selected: usize,
12 pub all_mail_active: bool,
13 pub subscriptions_active: bool,
14 pub subscription_count: usize,
15 pub system_expanded: bool,
16 pub user_expanded: bool,
17 pub saved_searches_expanded: bool,
18 pub active_label: Option<&'a mxr_core::LabelId>,
19}
20
21#[derive(Debug, Clone)]
22enum SidebarEntry<'a> {
23 Separator,
24 Header { title: &'static str, expanded: bool },
25 AllMail,
26 Subscriptions { count: usize },
27 Label(&'a Label),
28 SavedSearch(&'a SavedSearch),
29}
30
31pub fn draw(frame: &mut Frame, area: Rect, view: &SidebarView<'_>, theme: &Theme) {
32 let is_focused = *view.active_pane == ActivePane::Sidebar;
33 let border_style = theme.border_style(is_focused);
34
35 let inner_width = area.width.saturating_sub(2) as usize;
36 let entries = build_sidebar_entries(
37 view.labels,
38 view.saved_searches,
39 view.subscription_count,
40 view.system_expanded,
41 view.user_expanded,
42 view.saved_searches_expanded,
43 );
44 let selected_visual_index = visual_index_for_selection(&entries, view.sidebar_selected);
45
46 let items = entries
47 .iter()
48 .map(|entry| match entry {
49 SidebarEntry::Separator => {
50 ListItem::new(Line::from(Span::styled(
52 "─".repeat(inner_width),
53 Style::default().fg(theme.text_muted),
54 )))
55 }
56 SidebarEntry::Header { title, expanded } => ListItem::new(Line::from(vec![
57 Span::styled(
58 if *expanded { "▾ " } else { "▸ " },
59 Style::default().fg(theme.text_muted),
60 ),
61 Span::styled(*title, Style::default().fg(theme.accent).bold()),
62 ])),
63 SidebarEntry::AllMail => render_all_mail_item(inner_width, view.all_mail_active, theme),
64 SidebarEntry::Subscriptions { count } => {
65 render_subscriptions_item(inner_width, *count, view.subscriptions_active, theme)
66 }
67 SidebarEntry::Label(label) => {
68 render_label_item(label, inner_width, view.active_label, theme)
69 }
70 SidebarEntry::SavedSearch(search) => ListItem::new(format!(" {}", search.name)),
71 })
72 .collect::<Vec<_>>();
73
74 let list = List::new(items)
75 .block(
76 Block::bordered()
77 .title(" Sidebar ")
78 .border_type(BorderType::Rounded)
79 .border_style(border_style),
80 )
81 .highlight_style(theme.highlight_style());
82
83 if is_focused {
84 let mut state = ListState::default().with_selected(selected_visual_index);
85 frame.render_stateful_widget(list, area, &mut state);
86 } else {
87 frame.render_widget(list, area);
88 }
89}
90
91fn build_sidebar_entries<'a>(
92 labels: &'a [Label],
93 saved_searches: &'a [SavedSearch],
94 subscription_count: usize,
95 system_expanded: bool,
96 user_expanded: bool,
97 saved_searches_expanded: bool,
98) -> Vec<SidebarEntry<'a>> {
99 let visible_labels: Vec<&Label> = labels
100 .iter()
101 .filter(|label| !should_hide_label(&label.name))
102 .collect();
103
104 let mut system_labels: Vec<&Label> = visible_labels
105 .iter()
106 .filter(|label| label.kind == LabelKind::System)
107 .filter(|label| {
108 is_primary_system_label(&label.name) || label.total_count > 0 || label.unread_count > 0
109 })
110 .copied()
111 .collect();
112 system_labels.sort_by_key(|label| system_label_order(&label.name));
113
114 let mut user_labels: Vec<&Label> = visible_labels
115 .iter()
116 .filter(|label| label.kind != LabelKind::System)
117 .copied()
118 .collect();
119 user_labels.sort_by(|left, right| left.name.to_lowercase().cmp(&right.name.to_lowercase()));
120
121 let mut entries = vec![SidebarEntry::Header {
122 title: "System",
123 expanded: system_expanded,
124 }];
125 if system_expanded {
126 entries.extend(system_labels.into_iter().map(SidebarEntry::Label));
127 }
128 entries.push(SidebarEntry::AllMail);
129 entries.push(SidebarEntry::Subscriptions {
130 count: subscription_count,
131 });
132
133 if !user_labels.is_empty() {
134 if !entries.is_empty() {
135 entries.push(SidebarEntry::Separator);
136 }
137 entries.push(SidebarEntry::Header {
138 title: "Labels",
139 expanded: user_expanded,
140 });
141 if user_expanded {
142 entries.extend(user_labels.into_iter().map(SidebarEntry::Label));
143 }
144 }
145
146 if !saved_searches.is_empty() {
147 if !entries.is_empty() {
148 entries.push(SidebarEntry::Separator);
149 }
150 entries.push(SidebarEntry::Header {
151 title: "Saved Searches",
152 expanded: saved_searches_expanded,
153 });
154 if saved_searches_expanded {
155 entries.extend(saved_searches.iter().map(SidebarEntry::SavedSearch));
156 }
157 }
158
159 entries
160}
161
162fn visual_index_for_selection(
163 entries: &[SidebarEntry<'_>],
164 sidebar_selected: usize,
165) -> Option<usize> {
166 let mut selectable = 0usize;
167 for (visual_index, entry) in entries.iter().enumerate() {
168 match entry {
169 SidebarEntry::AllMail
170 | SidebarEntry::Subscriptions { .. }
171 | SidebarEntry::Label(_)
172 | SidebarEntry::SavedSearch(_) => {
173 if selectable == sidebar_selected {
174 return Some(visual_index);
175 }
176 selectable += 1;
177 }
178 SidebarEntry::Separator | SidebarEntry::Header { .. } => {}
179 }
180 }
181 None
182}
183
184fn render_all_mail_item<'a>(inner_width: usize, is_active: bool, theme: &Theme) -> ListItem<'a> {
185 render_sidebar_link(inner_width, "All Mail", None, is_active, theme)
186}
187
188fn render_subscriptions_item<'a>(
189 inner_width: usize,
190 count: usize,
191 is_active: bool,
192 theme: &Theme,
193) -> ListItem<'a> {
194 let count_str = (count > 0).then(|| count.to_string());
195 render_sidebar_link(
196 inner_width,
197 "Subscriptions",
198 count_str.as_deref(),
199 is_active,
200 theme,
201 )
202}
203
204fn render_sidebar_link<'a>(
205 inner_width: usize,
206 name: &str,
207 count: Option<&str>,
208 is_active: bool,
209 theme: &Theme,
210) -> ListItem<'a> {
211 let line = format!(" {:<width$}", name, width = inner_width.saturating_sub(2));
212 let line = if let Some(count) = count {
213 let name_part = format!(" {}", name);
214 let padding = inner_width.saturating_sub(name_part.len() + count.len());
215 format!("{}{}{}", name_part, " ".repeat(padding), count)
216 } else {
217 line
218 };
219 let style = if is_active {
220 Style::default()
221 .bg(theme.selection_bg)
222 .fg(theme.accent)
223 .bold()
224 } else {
225 Style::default()
226 };
227 ListItem::new(line).style(style)
228}
229
230fn render_label_item<'a>(
231 label: &Label,
232 inner_width: usize,
233 active_label: Option<&mxr_core::LabelId>,
234 theme: &Theme,
235) -> ListItem<'a> {
236 let is_active = active_label
237 .map(|current| current == &label.id)
238 .unwrap_or(false);
239 let display_name = humanize_label(&label.name);
240
241 let count_str = if label.unread_count > 0 {
242 format!("{}/{}", label.unread_count, label.total_count)
243 } else if label.total_count > 0 {
244 label.total_count.to_string()
245 } else {
246 String::new()
247 };
248
249 let name_part = format!(" {}", display_name);
251 let line = if count_str.is_empty() {
252 name_part
253 } else {
254 let padding = inner_width.saturating_sub(name_part.len() + count_str.len());
255 format!("{}{}{}", name_part, " ".repeat(padding), count_str)
256 };
257
258 let style = if is_active {
259 Style::default()
261 .bg(theme.selection_bg)
262 .fg(theme.accent)
263 .bold()
264 } else if label.unread_count > 0 {
265 theme.unread_style()
266 } else {
267 Style::default()
268 };
269
270 ListItem::new(line).style(style)
271}
272
273pub fn humanize_label(name: &str) -> &str {
274 match name {
275 "INBOX" => "Inbox",
276 "SENT" => "Sent",
277 "DRAFT" => "Drafts",
278 "ARCHIVE" => "Archive",
279 "ALL" => "All Mail",
280 "TRASH" => "Trash",
281 "SPAM" => "Spam",
282 "STARRED" => "Starred",
283 "IMPORTANT" => "Important",
284 "UNREAD" => "Unread",
285 "CHAT" => "Chat",
286 _ => name,
287 }
288}
289
290pub fn should_hide_label(name: &str) -> bool {
291 matches!(
292 name,
293 "CATEGORY_FORUMS"
294 | "CATEGORY_UPDATES"
295 | "CATEGORY_PERSONAL"
296 | "CATEGORY_PROMOTIONS"
297 | "CATEGORY_SOCIAL"
298 | "ALL"
299 | "RED_STAR"
300 | "YELLOW_STAR"
301 | "ORANGE_STAR"
302 | "GREEN_STAR"
303 | "BLUE_STAR"
304 | "PURPLE_STAR"
305 | "RED_BANG"
306 | "YELLOW_BANG"
307 | "BLUE_INFO"
308 | "ORANGE_GUILLEMET"
309 | "GREEN_CHECK"
310 | "PURPLE_QUESTION"
311 )
312}
313
314pub fn is_primary_system_label(name: &str) -> bool {
315 matches!(
316 name,
317 "INBOX" | "STARRED" | "SENT" | "DRAFT" | "ARCHIVE" | "SPAM" | "TRASH"
318 )
319}
320
321pub fn system_label_order(name: &str) -> usize {
322 match name {
323 "INBOX" => 0,
324 "STARRED" => 1,
325 "SENT" => 2,
326 "DRAFT" => 3,
327 "ARCHIVE" => 4,
328 "SPAM" => 5,
329 "TRASH" => 6,
330 _ => 100,
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337 use mxr_core::id::{AccountId, LabelId, SavedSearchId};
338 use mxr_core::types::{SearchMode, SortOrder};
339
340 fn label(name: &str, kind: LabelKind) -> Label {
341 Label {
342 id: LabelId::new(),
343 account_id: AccountId::new(),
344 name: name.into(),
345 kind,
346 color: None,
347 provider_id: name.into(),
348 unread_count: 0,
349 total_count: 1,
350 }
351 }
352
353 #[test]
354 fn sidebar_entries_insert_labels_header_before_user_labels() {
355 let labels = vec![
356 label("INBOX", LabelKind::System),
357 label("Work", LabelKind::User),
358 ];
359 let entries = build_sidebar_entries(&labels, &[], 3, true, true, true);
360 assert!(matches!(
361 entries[0],
362 SidebarEntry::Header {
363 title: "System",
364 ..
365 }
366 ));
367 assert!(matches!(entries[1], SidebarEntry::Label(label) if label.name == "INBOX"));
368 assert!(matches!(entries[2], SidebarEntry::AllMail));
369 assert!(matches!(
370 entries[3],
371 SidebarEntry::Subscriptions { count: 3 }
372 ));
373 assert!(matches!(entries[4], SidebarEntry::Separator));
374 assert!(matches!(
375 entries[5],
376 SidebarEntry::Header {
377 title: "Labels",
378 ..
379 }
380 ));
381 assert!(matches!(entries[6], SidebarEntry::Label(label) if label.name == "Work"));
382 }
383
384 #[test]
385 fn sidebar_selection_skips_headers_and_spacers() {
386 let labels = vec![
387 label("INBOX", LabelKind::System),
388 label("Work", LabelKind::User),
389 ];
390 let searches = vec![SavedSearch {
391 id: SavedSearchId::new(),
392 account_id: None,
393 name: "Unread".into(),
394 query: "is:unread".into(),
395 search_mode: SearchMode::Lexical,
396 sort: SortOrder::DateDesc,
397 icon: None,
398 position: 0,
399 created_at: chrono::Utc::now(),
400 }];
401 let entries = build_sidebar_entries(&labels, &searches, 2, true, true, true);
402 assert_eq!(visual_index_for_selection(&entries, 0), Some(1));
403 assert_eq!(visual_index_for_selection(&entries, 1), Some(2));
404 assert_eq!(visual_index_for_selection(&entries, 2), Some(3));
405 assert_eq!(visual_index_for_selection(&entries, 3), Some(6));
406 assert_eq!(visual_index_for_selection(&entries, 4), Some(9));
407 }
408}