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 (spec §3); the
154        // toggle-on-active-click refinement lands with Phase 03.
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        };
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        // Bottom-pinned CFG.
254        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        // The active item's marker is the rail's own left border: recolor the
266        // border segment beside the active cell green (and thicken it), so
267        // the highlight reads as part of the panel chrome rather than an
268        // extra in-cell bar.
269        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    /// The drawer views the rail currently shows, in order.
317    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); // clamped at top
342
343        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); // clamped at bottom
349    }
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        // SEM is not on the rail: FND steps straight to TAG.
388        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        // Bottom clamp still lands on the pinned CFG.
394        for _ in 0..10 {
395            rail.handle_input(&key(KeyCode::Down), &tx);
396        }
397        assert_eq!(rail.cursor_view(), DrawerView::Config);
398
399        // Pointing the cursor at the hidden view is a no-op.
400        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        // The render centers labels in the 5-wide inner strip; exactly three
408        // characters guarantees one column of padding on each side.
409        for (label, _) in ITEMS {
410            assert_eq!(label.len(), 3, "rail label {label:?} must be 3 chars");
411        }
412    }
413}