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