1use ratatui::Frame;
6use ratatui::crossterm::event::KeyCode;
7use ratatui::layout::Rect;
8use ratatui::style::{Modifier, Style};
9use ratatui::text::{Line, Span};
10use ratatui::widgets::Paragraph;
11
12use crate::components::drawer::DrawerView;
13use crate::components::event_state::EventState;
14use crate::components::events::{AppEvent, AppTx, InputEvent};
15use crate::components::panel::panel_block;
16use crate::settings::themes::Theme;
17
18pub const RAIL_WIDTH: u16 = 7;
20
21const ITEMS: [(&str, DrawerView); 6] = [
24 ("FIL", DrawerView::Files),
25 ("FND", DrawerView::Find),
26 ("TAG", DrawerView::Tags),
27 ("LNK", DrawerView::Links),
28 ("OUT", DrawerView::Outline),
29 ("CFG", DrawerView::Config),
30];
31
32fn glyph_for(icons: &crate::settings::icons::Icons, view: DrawerView) -> &'static str {
35 match view {
36 DrawerView::Files => icons.rail_files,
37 DrawerView::Find => icons.rail_find,
38 DrawerView::Tags => icons.rail_tags,
39 DrawerView::Links => icons.rail_links,
40 DrawerView::Outline => icons.rail_outline,
41 DrawerView::Config => icons.rail_config,
42 }
43}
44
45const CELL_ROWS: u16 = 3;
47
48pub struct ActivityRail {
49 cursor: usize,
51 item_rows: Vec<(DrawerView, Rect)>,
54 icons: crate::settings::icons::Icons,
56}
57
58impl ActivityRail {
59 pub fn new(icons: crate::settings::icons::Icons) -> Self {
60 Self {
61 cursor: 0,
62 item_rows: Vec::new(),
63 icons,
64 }
65 }
66
67 pub fn cursor_view(&self) -> DrawerView {
69 ITEMS[self.cursor].1
70 }
71
72 pub fn set_cursor(&mut self, view: DrawerView) {
75 if let Some(i) = ITEMS.iter().position(|(_, v)| *v == view) {
76 self.cursor = i;
77 }
78 }
79
80 pub fn view_at(&self, column: u16, row: u16) -> Option<DrawerView> {
82 self.item_rows
83 .iter()
84 .find(|(_, rect)| rect.contains(ratatui::layout::Position::new(column, row)))
85 .map(|(view, _)| *view)
86 }
87
88 pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
89 vec![
90 ("↑/↓".into(), "Move".into()),
91 ("Enter".into(), "Open/close".into()),
92 ]
93 }
94
95 pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
96 if let InputEvent::Mouse(mouse) = event {
99 use ratatui::crossterm::event::{MouseButton, MouseEventKind};
100 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
101 && let Some(view) = self.view_at(mouse.column, mouse.row)
102 {
103 self.set_cursor(view);
104 tx.send(AppEvent::OpenDrawerView(view)).ok();
105 return EventState::Consumed;
106 }
107 return EventState::NotConsumed;
108 }
109 let InputEvent::Key(key) = event else {
110 return EventState::NotConsumed;
111 };
112 match key.code {
113 KeyCode::Up | KeyCode::Char('k') => {
114 self.cursor = self.cursor.saturating_sub(1);
115 EventState::Consumed
116 }
117 KeyCode::Down | KeyCode::Char('j') => {
118 self.cursor = (self.cursor + 1).min(ITEMS.len() - 1);
119 EventState::Consumed
120 }
121 KeyCode::Enter => {
122 tx.send(AppEvent::OpenDrawerView(self.cursor_view())).ok();
123 EventState::Consumed
124 }
125 _ => EventState::NotConsumed,
126 }
127 }
128
129 pub fn render(
132 &mut self,
133 f: &mut Frame,
134 rect: Rect,
135 theme: &Theme,
136 focused: bool,
137 active: Option<DrawerView>,
138 ) {
139 let block = panel_block("", theme, focused);
140 let inner = block.inner(rect);
141 f.render_widget(block, rect);
142 self.item_rows.clear();
143
144 let accent = Style::default().fg(theme.focus_border.to_ratatui());
145 let dim = Style::default().fg(theme.gray.to_ratatui());
146 let cursor_style = Style::default()
147 .fg(theme.fg_bright.to_ratatui())
148 .add_modifier(Modifier::BOLD);
149
150 let (top_items, bottom_item) = ITEMS.split_at(ITEMS.len() - 1);
152
153 let icons = self.icons.clone();
154 let draw = |idx: usize,
155 label: &str,
156 view: DrawerView,
157 y: u16,
158 f: &mut Frame,
159 rows: &mut Vec<(DrawerView, Rect)>| {
160 if y + 1 >= inner.bottom() {
161 return;
162 }
163 let glyph = glyph_for(&icons, view);
164 let is_active = active == Some(view);
165 let is_cursor = focused && idx == self.cursor;
166 let glyph_style = if is_active {
167 accent
168 } else if is_cursor {
169 cursor_style
170 } else {
171 dim
172 };
173 let label_style = if is_cursor { cursor_style } else { dim };
174 let cell = Rect::new(inner.x, y, inner.width, 2);
175 f.render_widget(
178 Paragraph::new(vec![
179 Line::from(Span::styled(glyph, glyph_style)),
180 Line::from(Span::styled(label, label_style)),
181 ])
182 .alignment(ratatui::layout::Alignment::Center),
183 cell,
184 );
185 rows.insert(0, (view, cell));
189 };
190
191 let mut y = inner.y;
192 for (i, (label, view)) in top_items.iter().enumerate() {
193 draw(i, label, *view, y, f, &mut self.item_rows);
194 y += CELL_ROWS;
195 }
196 let (label, view) = bottom_item[0];
198 let cfg_y = inner.bottom().saturating_sub(2).max(y);
199 draw(ITEMS.len() - 1, label, view, cfg_y, f, &mut self.item_rows);
200
201 if let Some((_, cell)) = self
206 .item_rows
207 .iter()
208 .find(|(view, _)| active == Some(*view))
209 {
210 let buf = f.buffer_mut();
211 for dy in 0..cell.height {
212 let pos = ratatui::layout::Position::new(rect.x, cell.y + dy);
213 if let Some(border_cell) = buf.cell_mut(pos) {
214 border_cell.set_symbol("┃");
215 border_cell.set_fg(theme.focus_border.to_ratatui());
216 }
217 }
218 }
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use super::*;
225 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
226 use tokio::sync::mpsc::unbounded_channel;
227
228 fn key(code: KeyCode) -> InputEvent {
229 InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE))
230 }
231
232 fn test_rail() -> ActivityRail {
233 ActivityRail::new(crate::settings::icons::Icons::new(false))
234 }
235
236 #[test]
237 fn cursor_moves_and_clamps() {
238 let mut rail = test_rail();
239 let (tx, _rx) = unbounded_channel();
240 assert_eq!(rail.cursor_view(), DrawerView::Files);
241
242 rail.handle_input(&key(KeyCode::Up), &tx);
243 assert_eq!(rail.cursor_view(), DrawerView::Files); rail.handle_input(&key(KeyCode::Down), &tx);
246 assert_eq!(rail.cursor_view(), DrawerView::Find);
247 for _ in 0..10 {
248 rail.handle_input(&key(KeyCode::Down), &tx);
249 }
250 assert_eq!(rail.cursor_view(), DrawerView::Config); }
252
253 #[test]
254 fn enter_emits_open_drawer_view() {
255 let mut rail = test_rail();
256 let (tx, mut rx) = unbounded_channel();
257 rail.handle_input(&key(KeyCode::Down), &tx);
258 rail.handle_input(&key(KeyCode::Enter), &tx);
259 match rx.try_recv() {
260 Ok(AppEvent::OpenDrawerView(view)) => assert_eq!(view, DrawerView::Find),
261 other => panic!("expected OpenDrawerView, got {other:?}"),
262 }
263 }
264
265 #[test]
266 fn set_cursor_tracks_view() {
267 let mut rail = test_rail();
268 rail.set_cursor(DrawerView::Outline);
269 assert_eq!(rail.cursor_view(), DrawerView::Outline);
270 }
271
272 #[test]
273 fn rail_labels_are_three_chars() {
274 for (label, _) in ITEMS {
277 assert_eq!(label.len(), 3, "rail label {label:?} must be 3 chars");
278 }
279 }
280}