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::keys::KeyBindings;
17use crate::settings::themes::Theme;
18
19pub const RAIL_WIDTH: u16 = 7;
21
22const ITEMS: [(&str, DrawerView); 6] = [
25 ("FIL", DrawerView::Files),
26 ("FND", DrawerView::Find),
27 ("TAG", DrawerView::Tags),
28 ("LNK", DrawerView::Links),
29 ("OUT", DrawerView::Outline),
30 ("CFG", DrawerView::Config),
31];
32
33fn glyph_for(icons: &crate::settings::icons::Icons, view: DrawerView) -> &'static str {
36 match view {
37 DrawerView::Files => icons.rail_files,
38 DrawerView::Find => icons.rail_find,
39 DrawerView::Tags => icons.rail_tags,
40 DrawerView::Links => icons.rail_links,
41 DrawerView::Outline => icons.rail_outline,
42 DrawerView::Config => icons.rail_config,
43 }
44}
45
46const CELL_ROWS: u16 = 3;
48
49pub struct ActivityRail {
50 cursor: usize,
52 item_rows: Vec<(DrawerView, Rect)>,
55 icons: crate::settings::icons::Icons,
57 key_bindings: KeyBindings,
59}
60
61impl ActivityRail {
62 pub fn new(key_bindings: KeyBindings, icons: crate::settings::icons::Icons) -> Self {
63 Self {
64 cursor: 0,
65 item_rows: Vec::new(),
66 icons,
67 key_bindings,
68 }
69 }
70
71 pub fn cursor_view(&self) -> DrawerView {
73 ITEMS[self.cursor].1
74 }
75
76 pub fn set_cursor(&mut self, view: DrawerView) {
79 if let Some(i) = ITEMS.iter().position(|(_, v)| *v == view) {
80 self.cursor = i;
81 }
82 }
83
84 pub fn view_at(&self, column: u16, row: u16) -> Option<DrawerView> {
86 self.item_rows
87 .iter()
88 .find(|(_, rect)| rect.contains(ratatui::layout::Position::new(column, row)))
89 .map(|(view, _)| *view)
90 }
91
92 pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
93 use crate::keys::action_shortcuts::ActionShortcuts;
94
95 let mut hints = vec![
96 ("↑/↓".into(), "Move".into()),
97 ("Enter".into(), "Open/close".into()),
98 ];
99 hints.extend(crate::components::hints::hints_for(
100 &self.key_bindings,
101 &[
102 (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
103 (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
104 ],
105 ));
106 hints
107 }
108
109 pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
110 if let InputEvent::Mouse(mouse) = event {
113 use ratatui::crossterm::event::{MouseButton, MouseEventKind};
114 if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
115 && let Some(view) = self.view_at(mouse.column, mouse.row)
116 {
117 self.set_cursor(view);
118 tx.send(AppEvent::OpenDrawerView(view)).ok();
119 return EventState::Consumed;
120 }
121 return EventState::NotConsumed;
122 }
123 let InputEvent::Key(key) = event else {
124 return EventState::NotConsumed;
125 };
126 match key.code {
127 KeyCode::Up | KeyCode::Char('k') => {
128 self.cursor = self.cursor.saturating_sub(1);
129 EventState::Consumed
130 }
131 KeyCode::Down | KeyCode::Char('j') => {
132 self.cursor = (self.cursor + 1).min(ITEMS.len() - 1);
133 EventState::Consumed
134 }
135 KeyCode::Enter => {
136 tx.send(AppEvent::OpenDrawerView(self.cursor_view())).ok();
137 EventState::Consumed
138 }
139 _ => EventState::NotConsumed,
140 }
141 }
142
143 pub fn render(
146 &mut self,
147 f: &mut Frame,
148 rect: Rect,
149 theme: &Theme,
150 focused: bool,
151 active: Option<DrawerView>,
152 ) {
153 let block = panel_block("", theme, focused);
154 let inner = block.inner(rect);
155 f.render_widget(block, rect);
156 self.item_rows.clear();
157
158 let accent = Style::default().fg(theme.focus_border.to_ratatui());
159 let dim = Style::default().fg(theme.gray.to_ratatui());
160 let cursor_style = Style::default()
161 .fg(theme.fg_bright.to_ratatui())
162 .add_modifier(Modifier::BOLD);
163
164 let (top_items, bottom_item) = ITEMS.split_at(ITEMS.len() - 1);
166
167 let icons = self.icons.clone();
168 let draw = |idx: usize,
169 label: &str,
170 view: DrawerView,
171 y: u16,
172 f: &mut Frame,
173 rows: &mut Vec<(DrawerView, Rect)>| {
174 if y + 1 >= inner.bottom() {
175 return;
176 }
177 let glyph = glyph_for(&icons, view);
178 let is_active = active == Some(view);
179 let is_cursor = focused && idx == self.cursor;
180 let glyph_style = if is_active {
181 accent
182 } else if is_cursor {
183 cursor_style
184 } else {
185 dim
186 };
187 let label_style = if is_cursor { cursor_style } else { dim };
188 let cell = Rect::new(inner.x, y, inner.width, 2);
189 f.render_widget(
192 Paragraph::new(vec![
193 Line::from(Span::styled(glyph, glyph_style)),
194 Line::from(Span::styled(label, label_style)),
195 ])
196 .alignment(ratatui::layout::Alignment::Center),
197 cell,
198 );
199 rows.insert(0, (view, cell));
203 };
204
205 let mut y = inner.y;
206 for (i, (label, view)) in top_items.iter().enumerate() {
207 draw(i, label, *view, y, f, &mut self.item_rows);
208 y += CELL_ROWS;
209 }
210 let (label, view) = bottom_item[0];
212 let cfg_y = inner.bottom().saturating_sub(2).max(y);
213 draw(ITEMS.len() - 1, label, view, cfg_y, f, &mut self.item_rows);
214
215 if let Some((_, cell)) = self
220 .item_rows
221 .iter()
222 .find(|(view, _)| active == Some(*view))
223 {
224 let buf = f.buffer_mut();
225 for dy in 0..cell.height {
226 let pos = ratatui::layout::Position::new(rect.x, cell.y + dy);
227 if let Some(border_cell) = buf.cell_mut(pos) {
228 border_cell.set_symbol("┃");
229 border_cell.set_fg(theme.focus_border.to_ratatui());
230 }
231 }
232 }
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
240 use tokio::sync::mpsc::unbounded_channel;
241
242 fn key(code: KeyCode) -> InputEvent {
243 InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE))
244 }
245
246 fn test_rail() -> ActivityRail {
247 let settings = crate::settings::AppSettings::default();
248 ActivityRail::new(
249 settings.key_bindings,
250 crate::settings::icons::Icons::new(false),
251 )
252 }
253
254 #[test]
255 fn cursor_moves_and_clamps() {
256 let mut rail = test_rail();
257 let (tx, _rx) = unbounded_channel();
258 assert_eq!(rail.cursor_view(), DrawerView::Files);
259
260 rail.handle_input(&key(KeyCode::Up), &tx);
261 assert_eq!(rail.cursor_view(), DrawerView::Files); rail.handle_input(&key(KeyCode::Down), &tx);
264 assert_eq!(rail.cursor_view(), DrawerView::Find);
265 for _ in 0..10 {
266 rail.handle_input(&key(KeyCode::Down), &tx);
267 }
268 assert_eq!(rail.cursor_view(), DrawerView::Config); }
270
271 #[test]
272 fn enter_emits_open_drawer_view() {
273 let mut rail = test_rail();
274 let (tx, mut rx) = unbounded_channel();
275 rail.handle_input(&key(KeyCode::Down), &tx);
276 rail.handle_input(&key(KeyCode::Enter), &tx);
277 match rx.try_recv() {
278 Ok(AppEvent::OpenDrawerView(view)) => assert_eq!(view, DrawerView::Find),
279 other => panic!("expected OpenDrawerView, got {other:?}"),
280 }
281 }
282
283 #[test]
284 fn set_cursor_tracks_view() {
285 let mut rail = test_rail();
286 rail.set_cursor(DrawerView::Outline);
287 assert_eq!(rail.cursor_view(), DrawerView::Outline);
288 }
289
290 #[test]
291 fn hints_include_focus_cycle() {
292 let rail = test_rail();
293 let labels: Vec<String> = rail
294 .hint_shortcuts()
295 .into_iter()
296 .map(|(_, label)| label)
297 .collect();
298 assert!(labels.contains(&"\u{2190} focus left".to_string()));
299 assert!(labels.contains(&"focus right \u{2192}".to_string()));
300 }
301
302 #[test]
303 fn rail_labels_are_three_chars() {
304 for (label, _) in ITEMS {
307 assert_eq!(label.len(), 3, "rail label {label:?} must be 3 chars");
308 }
309 }
310}