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