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 rail items in presentation order. CFG is last and pinned to the
23/// bottom of the strip by a spacer.
24const ITEMS: [(&str, DrawerView); 6] = [
25    ("FIL", DrawerView::Files),
26    ("FND", DrawerView::Find),
27    ("TAG", DrawerView::Tags),
28    ("LNK", DrawerView::Links),
29    ("OUT", DrawerView::Outline),
30    ("CFG", DrawerView::Config),
31];
32
33/// The rail glyph for a drawer view, resolved through the icon set so the
34/// nerd-font / ASCII fallback policy applies to the rail like everywhere else.
35fn glyph_for(icons: &crate::settings::icons::Icons, view: DrawerView) -> &'static str {
36    match view {
37        DrawerView::Files => icons.rail_files,
38        DrawerView::Find => icons.rail_find,
39        DrawerView::Tags => icons.rail_tags,
40        DrawerView::Links => icons.rail_links,
41        DrawerView::Outline => icons.rail_outline,
42        DrawerView::Config => icons.rail_config,
43    }
44}
45
46/// Rows each rail cell occupies (glyph line + label line + gap).
47const CELL_ROWS: u16 = 3;
48
49pub struct ActivityRail {
50    /// The item the keyboard cursor sits on (the item `Enter` opens).
51    cursor: usize,
52    /// The row each item was drawn at on the last render, for click
53    /// hit-testing.
54    item_rows: Vec<(DrawerView, Rect)>,
55    /// Icon set resolving the rail glyphs (nerd-font / ASCII).
56    icons: crate::settings::icons::Icons,
57    /// Bindings resolving the focus-cycle hint combos.
58    key_bindings: KeyBindings,
59}
60
61impl ActivityRail {
62    pub fn new(key_bindings: KeyBindings, icons: crate::settings::icons::Icons) -> Self {
63        Self {
64            cursor: 0,
65            item_rows: Vec::new(),
66            icons,
67            key_bindings,
68        }
69    }
70
71    /// The drawer view under the keyboard cursor.
72    pub fn cursor_view(&self) -> DrawerView {
73        ITEMS[self.cursor].1
74    }
75
76    /// Move the keyboard cursor onto `view` (e.g. after a click or a leader
77    /// path switched the drawer), so rail navigation continues from there.
78    pub fn set_cursor(&mut self, view: DrawerView) {
79        if let Some(i) = ITEMS.iter().position(|(_, v)| *v == view) {
80            self.cursor = i;
81        }
82    }
83
84    /// The item at the given screen cell, from the last render.
85    pub fn view_at(&self, column: u16, row: u16) -> Option<DrawerView> {
86        self.item_rows
87            .iter()
88            .find(|(_, rect)| rect.contains(ratatui::layout::Position::new(column, row)))
89            .map(|(view, _)| *view)
90    }
91
92    pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
93        use crate::keys::action_shortcuts::ActionShortcuts;
94
95        let mut hints = vec![
96            ("↑/↓".into(), "Move".into()),
97            ("Enter".into(), "Open/close".into()),
98        ];
99        hints.extend(crate::components::hints::hints_for(
100            &self.key_bindings,
101            &[
102                (ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
103                (ActionShortcuts::FocusEditor, "focus right \u{2192}"),
104            ],
105        ));
106        hints
107    }
108
109    pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
110        // Click on a rail item → switch the drawer to it (spec §3); the
111        // toggle-on-active-click refinement lands with Phase 03.
112        if let InputEvent::Mouse(mouse) = event {
113            use ratatui::crossterm::event::{MouseButton, MouseEventKind};
114            if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
115                && let Some(view) = self.view_at(mouse.column, mouse.row)
116            {
117                self.set_cursor(view);
118                tx.send(AppEvent::OpenDrawerView(view)).ok();
119                return EventState::Consumed;
120            }
121            return EventState::NotConsumed;
122        }
123        let InputEvent::Key(key) = event else {
124            return EventState::NotConsumed;
125        };
126        match key.code {
127            KeyCode::Up | KeyCode::Char('k') => {
128                self.cursor = self.cursor.saturating_sub(1);
129                EventState::Consumed
130            }
131            KeyCode::Down | KeyCode::Char('j') => {
132                self.cursor = (self.cursor + 1).min(ITEMS.len() - 1);
133                EventState::Consumed
134            }
135            KeyCode::Enter => {
136                tx.send(AppEvent::OpenDrawerView(self.cursor_view())).ok();
137                EventState::Consumed
138            }
139            _ => EventState::NotConsumed,
140        }
141    }
142
143    /// `active` is the drawer view currently shown (None when the drawer is
144    /// hidden); it gets the green edge bar + glyph.
145    pub fn render(
146        &mut self,
147        f: &mut Frame,
148        rect: Rect,
149        theme: &Theme,
150        focused: bool,
151        active: Option<DrawerView>,
152    ) {
153        let block = panel_block("", theme, focused);
154        let inner = block.inner(rect);
155        f.render_widget(block, rect);
156        self.item_rows.clear();
157
158        let accent = Style::default().fg(theme.focus_border.to_ratatui());
159        let dim = Style::default().fg(theme.gray.to_ratatui());
160        let cursor_style = Style::default()
161            .fg(theme.fg_bright.to_ratatui())
162            .add_modifier(Modifier::BOLD);
163
164        // CFG (last item) is pinned to the bottom; the rest stack from the top.
165        let (top_items, bottom_item) = ITEMS.split_at(ITEMS.len() - 1);
166
167        let icons = self.icons.clone();
168        let draw = |idx: usize,
169                    label: &str,
170                    view: DrawerView,
171                    y: u16,
172                    f: &mut Frame,
173                    rows: &mut Vec<(DrawerView, Rect)>| {
174            if y + 1 >= inner.bottom() {
175                return;
176            }
177            let glyph = glyph_for(&icons, view);
178            let is_active = active == Some(view);
179            let is_cursor = focused && idx == self.cursor;
180            let glyph_style = if is_active {
181                accent
182            } else if is_cursor {
183                cursor_style
184            } else {
185                dim
186            };
187            let label_style = if is_cursor { cursor_style } else { dim };
188            let cell = Rect::new(inner.x, y, inner.width, 2);
189            // Labels are all three letters wide, so centering yields one
190            // column of padding on each side of the 5-wide inner strip.
191            f.render_widget(
192                Paragraph::new(vec![
193                    Line::from(Span::styled(glyph, glyph_style)),
194                    Line::from(Span::styled(label, label_style)),
195                ])
196                .alignment(ratatui::layout::Alignment::Center),
197                cell,
198            );
199            // CFG is drawn last; on cramped rails its cell can overlap a top
200            // item — insert at the FRONT so hit-testing favors the
201            // most-recently drawn (topmost) cell.
202            rows.insert(0, (view, cell));
203        };
204
205        let mut y = inner.y;
206        for (i, (label, view)) in top_items.iter().enumerate() {
207            draw(i, label, *view, y, f, &mut self.item_rows);
208            y += CELL_ROWS;
209        }
210        // Bottom-pinned CFG.
211        let (label, view) = bottom_item[0];
212        let cfg_y = inner.bottom().saturating_sub(2).max(y);
213        draw(ITEMS.len() - 1, label, view, cfg_y, f, &mut self.item_rows);
214
215        // The active item's marker is the rail's own left border: recolor the
216        // border segment beside the active cell green (and thicken it), so
217        // the highlight reads as part of the panel chrome rather than an
218        // extra in-cell bar.
219        if let Some((_, cell)) = self
220            .item_rows
221            .iter()
222            .find(|(view, _)| active == Some(*view))
223        {
224            let buf = f.buffer_mut();
225            for dy in 0..cell.height {
226                let pos = ratatui::layout::Position::new(rect.x, cell.y + dy);
227                if let Some(border_cell) = buf.cell_mut(pos) {
228                    border_cell.set_symbol("┃");
229                    border_cell.set_fg(theme.focus_border.to_ratatui());
230                }
231            }
232        }
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
240    use tokio::sync::mpsc::unbounded_channel;
241
242    fn key(code: KeyCode) -> InputEvent {
243        InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE))
244    }
245
246    fn test_rail() -> ActivityRail {
247        let settings = crate::settings::AppSettings::default();
248        ActivityRail::new(
249            settings.key_bindings,
250            crate::settings::icons::Icons::new(false),
251        )
252    }
253
254    #[test]
255    fn cursor_moves_and_clamps() {
256        let mut rail = test_rail();
257        let (tx, _rx) = unbounded_channel();
258        assert_eq!(rail.cursor_view(), DrawerView::Files);
259
260        rail.handle_input(&key(KeyCode::Up), &tx);
261        assert_eq!(rail.cursor_view(), DrawerView::Files); // clamped at top
262
263        rail.handle_input(&key(KeyCode::Down), &tx);
264        assert_eq!(rail.cursor_view(), DrawerView::Find);
265        for _ in 0..10 {
266            rail.handle_input(&key(KeyCode::Down), &tx);
267        }
268        assert_eq!(rail.cursor_view(), DrawerView::Config); // clamped at bottom
269    }
270
271    #[test]
272    fn enter_emits_open_drawer_view() {
273        let mut rail = test_rail();
274        let (tx, mut rx) = unbounded_channel();
275        rail.handle_input(&key(KeyCode::Down), &tx);
276        rail.handle_input(&key(KeyCode::Enter), &tx);
277        match rx.try_recv() {
278            Ok(AppEvent::OpenDrawerView(view)) => assert_eq!(view, DrawerView::Find),
279            other => panic!("expected OpenDrawerView, got {other:?}"),
280        }
281    }
282
283    #[test]
284    fn set_cursor_tracks_view() {
285        let mut rail = test_rail();
286        rail.set_cursor(DrawerView::Outline);
287        assert_eq!(rail.cursor_view(), DrawerView::Outline);
288    }
289
290    #[test]
291    fn hints_include_focus_cycle() {
292        let rail = test_rail();
293        let labels: Vec<String> = rail
294            .hint_shortcuts()
295            .into_iter()
296            .map(|(_, label)| label)
297            .collect();
298        assert!(labels.contains(&"\u{2190} focus left".to_string()));
299        assert!(labels.contains(&"focus right \u{2192}".to_string()));
300    }
301
302    #[test]
303    fn rail_labels_are_three_chars() {
304        // The render centers labels in the 5-wide inner strip; exactly three
305        // characters guarantees one column of padding on each side.
306        for (label, _) in ITEMS {
307            assert_eq!(label.len(), 3, "rail label {label:?} must be 3 chars");
308        }
309    }
310}