envelope_cli/tui/views/
sidebar.rs

1//! Sidebar view
2//!
3//! Shows account list and view switcher
4
5use ratatui::{
6    layout::Rect,
7    style::{Color, Modifier, Style},
8    text::{Line, Span},
9    widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
10    Frame,
11};
12
13use crate::services::AccountService;
14use crate::tui::app::{ActiveView, App, FocusedPanel};
15use crate::tui::layout::SidebarLayout;
16
17/// Render the sidebar
18pub fn render(frame: &mut Frame, app: &mut App, area: Rect) {
19    let layout = SidebarLayout::new(area);
20
21    // Render header
22    render_header(frame, layout.header);
23
24    // Render account list
25    render_accounts(frame, app, layout.accounts);
26
27    // Render view switcher
28    render_view_switcher(frame, app, layout.view_switcher);
29}
30
31/// Render sidebar header
32fn render_header(frame: &mut Frame, area: Rect) {
33    let block = Block::default()
34        .title(" Envelope ")
35        .title_style(
36            Style::default()
37                .fg(Color::Cyan)
38                .add_modifier(Modifier::BOLD),
39        )
40        .borders(Borders::ALL)
41        .border_style(Style::default().fg(Color::White));
42
43    let version = Paragraph::new(format!("v{}", env!("CARGO_PKG_VERSION")))
44        .block(block)
45        .style(Style::default().fg(Color::Yellow));
46
47    frame.render_widget(version, area);
48}
49
50/// Render account list
51fn render_accounts(frame: &mut Frame, app: &mut App, area: Rect) {
52    let is_focused = app.focused_panel == FocusedPanel::Sidebar;
53
54    let border_color = if is_focused {
55        Color::Cyan
56    } else {
57        Color::White
58    };
59
60    let block = Block::default()
61        .title(" Accounts ")
62        .borders(Borders::ALL)
63        .border_style(Style::default().fg(border_color));
64
65    // Get accounts with balances
66    let account_service = AccountService::new(app.storage);
67    let accounts = account_service
68        .list_with_balances(app.show_archived)
69        .unwrap_or_default();
70
71    if accounts.is_empty() {
72        let text = Paragraph::new("No accounts")
73            .block(block)
74            .style(Style::default().fg(Color::Yellow));
75        frame.render_widget(text, area);
76        return;
77    }
78
79    // Build list items
80    let items: Vec<ListItem> = accounts
81        .iter()
82        .map(|summary| {
83            let balance_str = format!("{}", summary.balance);
84            let balance_color = if summary.balance.is_negative() {
85                Color::Red
86            } else {
87                Color::Green
88            };
89
90            let line = Line::from(vec![
91                Span::styled(
92                    format!("{:<15}", truncate_string(&summary.account.name, 15)),
93                    Style::default().fg(Color::White),
94                ),
95                Span::styled(
96                    format!("{:>12}", balance_str),
97                    Style::default().fg(balance_color),
98                ),
99            ]);
100
101            ListItem::new(line)
102        })
103        .collect();
104
105    let list = List::new(items)
106        .block(block)
107        .highlight_style(
108            Style::default()
109                .bg(Color::DarkGray)
110                .add_modifier(Modifier::BOLD),
111        )
112        .highlight_symbol("> ");
113
114    let mut state = ListState::default();
115    state.select(Some(app.selected_account_index));
116
117    frame.render_stateful_widget(list, area, &mut state);
118}
119
120/// Render view switcher
121fn render_view_switcher(frame: &mut Frame, app: &mut App, area: Rect) {
122    let block = Block::default()
123        .title(" Views ")
124        .borders(Borders::ALL)
125        .border_style(Style::default().fg(Color::White));
126
127    let views = [
128        ("1", "Accounts", ActiveView::Accounts),
129        ("2", "Budget", ActiveView::Budget),
130        ("3", "Reports", ActiveView::Reports),
131    ];
132
133    let items: Vec<ListItem> = views
134        .iter()
135        .map(|(key, name, view)| {
136            let style = if app.active_view == *view {
137                Style::default()
138                    .fg(Color::Cyan)
139                    .add_modifier(Modifier::BOLD)
140            } else {
141                Style::default().fg(Color::White)
142            };
143
144            let indicator = if app.active_view == *view { "▶" } else { " " };
145
146            let line = Line::from(vec![
147                Span::styled(format!("{} ", indicator), style),
148                Span::styled(format!("[{}] ", key), Style::default().fg(Color::Yellow)),
149                Span::styled(*name, style),
150            ]);
151
152            ListItem::new(line)
153        })
154        .collect();
155
156    let list = List::new(items).block(block);
157
158    frame.render_widget(list, area);
159}
160
161/// Truncate a string to a maximum length
162fn truncate_string(s: &str, max_len: usize) -> String {
163    if s.len() <= max_len {
164        s.to_string()
165    } else {
166        format!("{}…", &s[..max_len - 1])
167    }
168}