envelope_cli/tui/views/
sidebar.rs1use 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
17pub fn render(frame: &mut Frame, app: &mut App, area: Rect) {
19 let layout = SidebarLayout::new(area);
20
21 render_header(frame, layout.header);
23
24 render_accounts(frame, app, layout.accounts);
26
27 render_view_switcher(frame, app, layout.view_switcher);
29}
30
31fn 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
50fn 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 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 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
120fn 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
161fn 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}