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![
122 SidebarEntry::Header {
123 title: "System",
124 expanded: system_expanded,
125 },
126 SidebarEntry::AllMail,
127 SidebarEntry::Subscriptions {
128 count: subscription_count,
129 },
130 ];
131 if system_expanded {
132 entries.extend(system_labels.into_iter().map(SidebarEntry::Label));
133 }
134
135 if !user_labels.is_empty() {
136 if !entries.is_empty() {
137 entries.push(SidebarEntry::Separator);
138 }
139 entries.push(SidebarEntry::Header {
140 title: "Labels",
141 expanded: user_expanded,
142 });
143 if user_expanded {
144 entries.extend(user_labels.into_iter().map(SidebarEntry::Label));
145 }
146 }
147
148 if !saved_searches.is_empty() {
149 if !entries.is_empty() {
150 entries.push(SidebarEntry::Separator);
151 }
152 entries.push(SidebarEntry::Header {
153 title: "Saved Searches",
154 expanded: saved_searches_expanded,
155 });
156 if saved_searches_expanded {
157 entries.extend(saved_searches.iter().map(SidebarEntry::SavedSearch));
158 }
159 }
160
161 entries
162}
163
164fn visual_index_for_selection(
165 entries: &[SidebarEntry<'_>],
166 sidebar_selected: usize,
167) -> Option<usize> {
168 let mut selectable = 0usize;
169 for (visual_index, entry) in entries.iter().enumerate() {
170 match entry {
171 SidebarEntry::AllMail
172 | SidebarEntry::Subscriptions { .. }
173 | SidebarEntry::Label(_)
174 | SidebarEntry::SavedSearch(_) => {
175 if selectable == sidebar_selected {
176 return Some(visual_index);
177 }
178 selectable += 1;
179 }
180 SidebarEntry::Separator | SidebarEntry::Header { .. } => {}
181 }
182 }
183 None
184}
185
186fn render_all_mail_item<'a>(inner_width: usize, is_active: bool, theme: &Theme) -> ListItem<'a> {
187 render_sidebar_link(inner_width, "All Mail", None, is_active, theme)
188}
189
190fn render_subscriptions_item<'a>(
191 inner_width: usize,
192 count: usize,
193 is_active: bool,
194 theme: &Theme,
195) -> ListItem<'a> {
196 let count_str = (count > 0).then(|| count.to_string());
197 render_sidebar_link(
198 inner_width,
199 "Subscriptions",
200 count_str.as_deref(),
201 is_active,
202 theme,
203 )
204}
205
206fn render_sidebar_link<'a>(
207 inner_width: usize,
208 name: &str,
209 count: Option<&str>,
210 is_active: bool,
211 theme: &Theme,
212) -> ListItem<'a> {
213 let line = format!(" {:<width$}", name, width = inner_width.saturating_sub(2));
214 let line = if let Some(count) = count {
215 let name_part = format!(" {}", name);
216 let padding = inner_width.saturating_sub(name_part.len() + count.len());
217 format!("{}{}{}", name_part, " ".repeat(padding), count)
218 } else {
219 line
220 };
221 let style = if is_active {
222 Style::default()
223 .bg(theme.selection_bg)
224 .fg(theme.accent)
225 .bold()
226 } else {
227 Style::default()
228 };
229 ListItem::new(line).style(style)
230}
231
232fn render_label_item<'a>(
233 label: &Label,
234 inner_width: usize,
235 active_label: Option<&mxr_core::LabelId>,
236 theme: &Theme,
237) -> ListItem<'a> {
238 let is_active = active_label
239 .map(|current| current == &label.id)
240 .unwrap_or(false);
241 let display_name = humanize_label(&label.name);
242
243 let count_str = if label.unread_count > 0 {
244 format!("{}/{}", label.unread_count, label.total_count)
245 } else if label.total_count > 0 {
246 label.total_count.to_string()
247 } else {
248 String::new()
249 };
250
251 let name_part = format!(" {}", display_name);
253 let line = if count_str.is_empty() {
254 name_part
255 } else {
256 let padding = inner_width.saturating_sub(name_part.len() + count_str.len());
257 format!("{}{}{}", name_part, " ".repeat(padding), count_str)
258 };
259
260 let style = if is_active {
261 Style::default()
263 .bg(theme.selection_bg)
264 .fg(theme.accent)
265 .bold()
266 } else if label.unread_count > 0 {
267 theme.unread_style()
268 } else {
269 Style::default()
270 };
271
272 ListItem::new(line).style(style)
273}
274
275pub fn humanize_label(name: &str) -> &str {
276 match name {
277 "INBOX" => "Inbox",
278 "SENT" => "Sent",
279 "DRAFT" => "Drafts",
280 "ARCHIVE" => "Archive",
281 "ALL" => "All Mail",
282 "TRASH" => "Trash",
283 "SPAM" => "Spam",
284 "STARRED" => "Starred",
285 "IMPORTANT" => "Important",
286 "UNREAD" => "Unread",
287 "CHAT" => "Chat",
288 _ => name,
289 }
290}
291
292pub fn should_hide_label(name: &str) -> bool {
293 matches!(
294 name,
295 "CATEGORY_FORUMS"
296 | "CATEGORY_UPDATES"
297 | "CATEGORY_PERSONAL"
298 | "CATEGORY_PROMOTIONS"
299 | "CATEGORY_SOCIAL"
300 | "ALL"
301 | "RED_STAR"
302 | "YELLOW_STAR"
303 | "ORANGE_STAR"
304 | "GREEN_STAR"
305 | "BLUE_STAR"
306 | "PURPLE_STAR"
307 | "RED_BANG"
308 | "YELLOW_BANG"
309 | "BLUE_INFO"
310 | "ORANGE_GUILLEMET"
311 | "GREEN_CHECK"
312 | "PURPLE_QUESTION"
313 )
314}
315
316pub fn is_primary_system_label(name: &str) -> bool {
317 matches!(
318 name,
319 "INBOX" | "STARRED" | "SENT" | "DRAFT" | "ARCHIVE" | "SPAM" | "TRASH"
320 )
321}
322
323pub fn system_label_order(name: &str) -> usize {
324 match name {
325 "INBOX" => 0,
326 "STARRED" => 1,
327 "SENT" => 2,
328 "DRAFT" => 3,
329 "ARCHIVE" => 4,
330 "SPAM" => 5,
331 "TRASH" => 6,
332 _ => 100,
333 }
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339 use mxr_core::id::{AccountId, LabelId, SavedSearchId};
340 use mxr_core::types::{SearchMode, SortOrder};
341
342 fn label(name: &str, kind: LabelKind) -> Label {
343 Label {
344 id: LabelId::new(),
345 account_id: AccountId::new(),
346 name: name.into(),
347 kind,
348 color: None,
349 provider_id: name.into(),
350 unread_count: 0,
351 total_count: 1,
352 }
353 }
354
355 #[test]
356 fn sidebar_entries_insert_labels_header_before_user_labels() {
357 let labels = vec![
358 label("INBOX", LabelKind::System),
359 label("Work", LabelKind::User),
360 ];
361 let entries = build_sidebar_entries(&labels, &[], 3, true, true, true);
362 assert!(matches!(
363 entries[0],
364 SidebarEntry::Header {
365 title: "System",
366 ..
367 }
368 ));
369 assert!(matches!(entries[1], SidebarEntry::AllMail));
370 assert!(matches!(
371 entries[2],
372 SidebarEntry::Subscriptions { count: 3 }
373 ));
374 assert!(matches!(entries[3], SidebarEntry::Label(label) if label.name == "INBOX"));
375 assert!(matches!(entries[4], SidebarEntry::Separator));
376 assert!(matches!(
377 entries[5],
378 SidebarEntry::Header {
379 title: "Labels",
380 ..
381 }
382 ));
383 assert!(matches!(entries[6], SidebarEntry::Label(label) if label.name == "Work"));
384 }
385
386 #[test]
387 fn sidebar_selection_skips_headers_and_spacers() {
388 let labels = vec![
389 label("INBOX", LabelKind::System),
390 label("Work", LabelKind::User),
391 ];
392 let searches = vec![SavedSearch {
393 id: SavedSearchId::new(),
394 account_id: None,
395 name: "Unread".into(),
396 query: "is:unread".into(),
397 search_mode: SearchMode::Lexical,
398 sort: SortOrder::DateDesc,
399 icon: None,
400 position: 0,
401 created_at: chrono::Utc::now(),
402 }];
403 let entries = build_sidebar_entries(&labels, &searches, 2, true, true, true);
404 assert_eq!(visual_index_for_selection(&entries, 0), Some(1));
405 assert_eq!(visual_index_for_selection(&entries, 1), Some(2));
406 assert_eq!(visual_index_for_selection(&entries, 2), Some(3));
407 assert_eq!(visual_index_for_selection(&entries, 3), Some(6));
408 assert_eq!(visual_index_for_selection(&entries, 4), Some(9));
409 }
410}