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 a RAG server is
24/// configured (the rail is rebuilt with the screen on any config change).
25const 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
35/// The rail glyph for a drawer view, resolved through the icon set so the
36/// nerd-font / ASCII fallback policy applies to the rail like everywhere else.
37fn 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        // No dedicated icon field yet; `~` reads as "similar" and is ASCII-safe.
42        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
50/// Rows each rail cell occupies (glyph line + label line + gap).
51const CELL_ROWS: u16 = 3;
52
53pub struct ActivityRail {
54    /// The visible items, in presentation order: [`ITEMS`] minus the views
55    /// whose feature is off (SEM without a configured RAG server).
56    items: Vec<(&'static str, DrawerView)>,
57    /// The item the keyboard cursor sits on (the item `Enter` opens).
58    cursor: usize,
59    /// The row each item was drawn at on the last render, for click
60    /// hit-testing.
61    item_rows: Vec<(DrawerView, Rect)>,
62    /// Icon set resolving the rail glyphs (nerd-font / ASCII).
63    icons: crate::settings::icons::Icons,
64    /// Bindings resolving the focus-cycle hint combos.
65    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    /// The drawer view under the keyboard cursor.
88    pub fn cursor_view(&self) -> DrawerView {
89        self.items[self.cursor].1
90    }
91
92    /// Move the keyboard cursor onto `view` (e.g. after a click or a leader
93    /// path switched the drawer), so rail navigation continues from there.
94    /// A view the rail doesn't show (hidden SEM) leaves the cursor in place.
95    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    /// The item at the given screen cell, from the last render.
102    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        // Click on a rail item → switch the drawer to it (spec §3); the
128        // toggle-on-active-click refinement lands with Phase 03.
129        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    /// `active` is the drawer view currently shown (None when the drawer is
161    /// hidden); it gets the green edge bar + glyph.
162    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        // CFG (last item) is pinned to the bottom; the rest stack from the top.
182        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            // Labels are all three letters wide, so centering yields one
207            // column of padding on each side of the 5-wide inner strip.
208            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            // CFG is drawn last; on cramped rails its cell can overlap a top
217            // item — insert at the FRONT so hit-testing favors the
218            // most-recently drawn (topmost) cell.
219            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        // Bottom-pinned CFG.
228        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        // The active item's marker is the rail's own left border: recolor the
240        // border segment beside the active cell green (and thicken it), so
241        // the highlight reads as part of the panel chrome rather than an
242        // extra in-cell bar.
243        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); // clamped at top
291
292        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); // clamped at bottom
298    }
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        // SEM is not on the rail: FND steps straight to TAG.
337        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        // Bottom clamp still lands on the pinned CFG.
343        for _ in 0..10 {
344            rail.handle_input(&key(KeyCode::Down), &tx);
345        }
346        assert_eq!(rail.cursor_view(), DrawerView::Config);
347
348        // Pointing the cursor at the hidden view is a no-op.
349        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        // The render centers labels in the 5-wide inner strip; exactly three
357        // characters guarantees one column of padding on each side.
358        for (label, _) in ITEMS {
359            assert_eq!(label.len(), 3, "rail label {label:?} must be 3 chars");
360        }
361    }
362}