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![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    // Right-align count: name on left, count on right
250    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        // Full-width highlight bar for active label
260        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}