Skip to main content

mxr_tui/ui/
sidebar.rs

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                // Visual separator line instead of empty spacer
51                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    // Right-align count: name on left, count on right
252    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        // Full-width highlight bar for active label
262        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::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            sort: SortOrder::DateDesc,
398            icon: None,
399            position: 0,
400            created_at: chrono::Utc::now(),
401        }];
402        let entries = build_sidebar_entries(&labels, &searches, 2, true, true, true);
403        assert_eq!(visual_index_for_selection(&entries, 0), Some(1));
404        assert_eq!(visual_index_for_selection(&entries, 1), Some(2));
405        assert_eq!(visual_index_for_selection(&entries, 2), Some(3));
406        assert_eq!(visual_index_for_selection(&entries, 3), Some(6));
407        assert_eq!(visual_index_for_selection(&entries, 4), Some(9));
408    }
409}