Skip to main content

kimun_notes/components/
activity_rail.rs

1//! The **Activity Rail** — the fixed-width icon strip on the far left of the
2//! editor screen. Each cell names a drawer view; the active cell shows a
3//! green edge bar and green glyph. CFG is pinned to the bottom.
4
5use 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
19/// Total column width the rail occupies, borders included.
20pub const RAIL_WIDTH: u16 = 7;
21
22/// The full rail catalog in presentation order. CFG is last and pinned to the
23/// bottom of the strip by a spacer. SEM only appears when the server is
24/// reachable for search (the rail is rebuilt on any RAG-status change).
25const 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
36/// The rail glyph for a drawer view, resolved through the icon set so the
37/// nerd-font / ASCII fallback policy applies to the rail like everywhere else.
38fn 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        // No dedicated icon field yet; `~` reads as "similar" and is ASCII-safe.
43        DrawerView::Semantic => "~",
44        // No dedicated icon field yet; `?` reads as "ask" and is ASCII-safe.
45        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
53/// Rows each rail cell occupies (glyph line + label line + gap).
54const CELL_ROWS: u16 = 3;
55
56/// Which feature-gated rail items are currently visible — a server-status
57/// snapshot passed into `ActivityRail::new`/`PanelSet::rebuild_rail`. Named
58/// fields instead of two positional bools so a caller can't silently swap
59/// SEM and ASK.
60#[derive(Debug, Clone, Copy, Default)]
61pub struct RailCaps {
62    /// SEM appears when the server is reachable for search (an embedder is
63    /// configured) — `RagStatus::search_available`, mirroring how `ask` tracks
64    /// `RagStatus::llm_available`.
65    pub semantic: bool,
66    /// ASK appears when the server can answer questions (an LLM configured).
67    pub ask: bool,
68}
69
70pub struct ActivityRail {
71    /// The visible items, in presentation order: [`ITEMS`] minus the views
72    /// whose feature is off (SEM without a search-reachable server).
73    items: Vec<(&'static str, DrawerView)>,
74    /// The item the keyboard cursor sits on (the item `Enter` opens).
75    cursor: usize,
76    /// The row each item was drawn at on the last render, for click
77    /// hit-testing.
78    item_rows: Vec<(DrawerView, Rect)>,
79    /// Icon set resolving the rail glyphs (nerd-font / ASCII).
80    icons: crate::settings::icons::Icons,
81    /// Bindings resolving the focus-cycle hint combos.
82    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            // ASK appears only when the server can answer questions (an LLM is
95            // configured); the rail is rebuilt when that changes.
96            .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    /// The drawer view under the keyboard cursor.
108    pub fn cursor_view(&self) -> DrawerView {
109        self.items[self.cursor].1
110    }
111
112    /// Whether `view` is currently on the rail (its feature gate is on).
113    #[cfg(test)]
114    pub fn shows(&self, view: DrawerView) -> bool {
115        self.items.iter().any(|(_, v)| *v == view)
116    }
117
118    /// Move the keyboard cursor onto `view` (e.g. after a click or a leader
119    /// path switched the drawer), so rail navigation continues from there.
120    /// A view the rail doesn't show (hidden SEM) leaves the cursor in place.
121    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    /// The item at the given screen cell, from the last render.
128    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        // Click on a rail item → switch the drawer to it; clicking the
154        // already-active item toggles the drawer (handled by the editor screen).
155        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    /// `active` is the drawer view currently shown (None when the drawer is
187    /// hidden); it gets the green edge bar + glyph.
188    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        // CFG (last item) is pinned to the bottom; the rest stack from the top.
208        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            // Labels are all three letters wide, so centering yields one
233            // column of padding on each side of the 5-wide inner strip.
234            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            // CFG is drawn last; on cramped rails its cell can overlap a top
243            // item — insert at the FRONT so hit-testing favors the
244            // most-recently drawn (topmost) cell.
245            rows.insert(0, (view, cell));
246            crate::components::clickable::register(cell);
247        };
248
249        let mut y = inner.y;
250        for (i, (label, view)) in top_items.iter().enumerate() {
251            draw(i, label, *view, y, f, &mut self.item_rows);
252            y += CELL_ROWS;
253        }
254        // Bottom-pinned CFG.
255        let (label, view) = bottom_item[0];
256        let cfg_y = inner.bottom().saturating_sub(2).max(y);
257        draw(
258            self.items.len() - 1,
259            label,
260            view,
261            cfg_y,
262            f,
263            &mut self.item_rows,
264        );
265
266        // The active item's marker is the rail's own left border: recolor the
267        // border segment beside the active cell green (and thicken it), so
268        // the highlight reads as part of the panel chrome rather than an
269        // extra in-cell bar.
270        if let Some((_, cell)) = self
271            .item_rows
272            .iter()
273            .find(|(view, _)| active == Some(*view))
274        {
275            let buf = f.buffer_mut();
276            for dy in 0..cell.height {
277                let pos = ratatui::layout::Position::new(rect.x, cell.y + dy);
278                if let Some(border_cell) = buf.cell_mut(pos) {
279                    border_cell.set_symbol("┃");
280                    border_cell.set_fg(theme.focus_border.to_ratatui());
281                }
282            }
283        }
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
291    use tokio::sync::mpsc::unbounded_channel;
292
293    fn key(code: KeyCode) -> InputEvent {
294        InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE))
295    }
296
297    fn rail_with(semantic_visible: bool, ask_visible: bool) -> ActivityRail {
298        let settings = crate::settings::AppSettings::default();
299        ActivityRail::new(
300            settings.key_bindings,
301            crate::settings::icons::Icons::new(false),
302            RailCaps {
303                semantic: semantic_visible,
304                ask: ask_visible,
305            },
306        )
307    }
308
309    fn rail_with_semantic(semantic_visible: bool) -> ActivityRail {
310        rail_with(semantic_visible, false)
311    }
312
313    fn test_rail() -> ActivityRail {
314        rail_with(true, true)
315    }
316
317    /// The drawer views the rail currently shows, in order.
318    fn rail_views(rail: &ActivityRail) -> Vec<DrawerView> {
319        rail.items.iter().map(|(_, v)| *v).collect()
320    }
321
322    #[test]
323    fn rail_hides_ask_without_llm() {
324        let rail = rail_with(true, false);
325        assert!(rail_views(&rail).contains(&DrawerView::Semantic));
326        assert!(!rail_views(&rail).contains(&DrawerView::Ask));
327    }
328
329    #[test]
330    fn rail_shows_ask_with_llm() {
331        let rail = rail_with(true, true);
332        assert!(rail_views(&rail).contains(&DrawerView::Ask));
333    }
334
335    #[test]
336    fn cursor_moves_and_clamps() {
337        let mut rail = test_rail();
338        let (tx, _rx) = unbounded_channel();
339        assert_eq!(rail.cursor_view(), DrawerView::Files);
340
341        rail.handle_input(&key(KeyCode::Up), &tx);
342        assert_eq!(rail.cursor_view(), DrawerView::Files); // clamped at top
343
344        rail.handle_input(&key(KeyCode::Down), &tx);
345        assert_eq!(rail.cursor_view(), DrawerView::Find);
346        for _ in 0..10 {
347            rail.handle_input(&key(KeyCode::Down), &tx);
348        }
349        assert_eq!(rail.cursor_view(), DrawerView::Config); // clamped at bottom
350    }
351
352    #[test]
353    fn enter_emits_open_drawer_view() {
354        let mut rail = test_rail();
355        let (tx, mut rx) = unbounded_channel();
356        rail.handle_input(&key(KeyCode::Down), &tx);
357        rail.handle_input(&key(KeyCode::Enter), &tx);
358        match rx.try_recv() {
359            Ok(AppEvent::OpenDrawerView(view)) => assert_eq!(view, DrawerView::Find),
360            other => panic!("expected OpenDrawerView, got {other:?}"),
361        }
362    }
363
364    #[test]
365    fn set_cursor_tracks_view() {
366        let mut rail = test_rail();
367        rail.set_cursor(DrawerView::Outline);
368        assert_eq!(rail.cursor_view(), DrawerView::Outline);
369    }
370
371    #[test]
372    fn hints_include_focus_cycle() {
373        let rail = test_rail();
374        let labels: Vec<String> = rail
375            .hint_shortcuts()
376            .into_iter()
377            .map(|(_, label)| label)
378            .collect();
379        assert!(labels.contains(&"\u{2190} focus left".to_string()));
380        assert!(labels.contains(&"focus right \u{2192}".to_string()));
381    }
382
383    #[test]
384    fn semantic_hidden_when_no_server_configured() {
385        let mut rail = rail_with_semantic(false);
386        let (tx, _rx) = unbounded_channel();
387
388        // SEM is not on the rail: FND steps straight to TAG.
389        rail.handle_input(&key(KeyCode::Down), &tx);
390        assert_eq!(rail.cursor_view(), DrawerView::Find);
391        rail.handle_input(&key(KeyCode::Down), &tx);
392        assert_eq!(rail.cursor_view(), DrawerView::Tags);
393
394        // Bottom clamp still lands on the pinned CFG.
395        for _ in 0..10 {
396            rail.handle_input(&key(KeyCode::Down), &tx);
397        }
398        assert_eq!(rail.cursor_view(), DrawerView::Config);
399
400        // Pointing the cursor at the hidden view is a no-op.
401        rail.set_cursor(DrawerView::Tags);
402        rail.set_cursor(DrawerView::Semantic);
403        assert_eq!(rail.cursor_view(), DrawerView::Tags);
404    }
405
406    #[test]
407    fn rail_labels_are_three_chars() {
408        // The render centers labels in the 5-wide inner strip; exactly three
409        // characters guarantees one column of padding on each side.
410        for (label, _) in ITEMS {
411            assert_eq!(label.len(), 3, "rail label {label:?} must be 3 chars");
412        }
413    }
414}