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