Skip to main content

kimun_notes/components/
drawer.rs

1//! The **Drawer** — the single panel between the activity rail and the
2//! editor. It renders whichever rail view is active: the file browser
3//! (FILES), the Query panel (FIND), or a placeholder for the views that land
4//! in later phases (TAGS, LINKS, OUTLINE, CFG).
5
6use ratatui::Frame;
7use ratatui::layout::Rect;
8
9use crate::components::Component;
10use crate::components::config_panel::ConfigPanel;
11use crate::components::drawer_views::{LinksPanel, OutlinePanel, TagsPanel};
12use crate::components::event_state::EventState;
13use crate::components::events::{AppTx, InputEvent};
14use crate::components::query_panel::QueryPanel;
15use crate::components::semantic_search::SemanticPanel;
16use crate::components::sidebar::SidebarComponent;
17use crate::settings::themes::Theme;
18
19/// The views the activity rail can put in the drawer. Closed set, mirrors
20/// the rail items top to bottom.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum DrawerView {
23    Files,
24    Find,
25    Semantic,
26    Tags,
27    Links,
28    Outline,
29    Config,
30}
31
32impl DrawerView {
33    /// Status-bar label when the drawer shows this view.
34    pub fn label(&self) -> &'static str {
35        match self {
36            DrawerView::Files => "FILES",
37            DrawerView::Find => "FIND",
38            DrawerView::Semantic => "SEMANTIC",
39            DrawerView::Tags => "TAGS",
40            DrawerView::Links => "LINKS",
41            DrawerView::Outline => "OUTLINE",
42            DrawerView::Config => "CFG",
43        }
44    }
45}
46
47pub use crate::components::config_panel::ConfigInfo;
48
49/// Hosts the drawer views. FILES and FIND are the ported existing panels
50/// (file browser and Query panel); TAGS, LINKS, and OUTLINE are the
51/// phase-03 panels; CFG is a placeholder until the settings drawer lands.
52pub struct DrawerHost {
53    active: DrawerView,
54    sidebar: SidebarComponent,
55    query: QueryPanel,
56    semantic: SemanticPanel,
57    tags: TagsPanel,
58    links: LinksPanel,
59    outline: OutlinePanel,
60    config: ConfigPanel,
61}
62
63impl DrawerHost {
64    pub fn new(
65        sidebar: SidebarComponent,
66        query: QueryPanel,
67        semantic: SemanticPanel,
68        tags: TagsPanel,
69        links: LinksPanel,
70        outline: OutlinePanel,
71    ) -> Self {
72        Self {
73            active: DrawerView::Files,
74            sidebar,
75            query,
76            semantic,
77            tags,
78            links,
79            outline,
80            config: ConfigPanel::default(),
81        }
82    }
83
84    /// Refresh what the CFG view shows (called when the view opens).
85    pub fn set_config_info(&mut self, info: ConfigInfo) {
86        self.config.set_info(info);
87    }
88
89    pub fn active_view(&self) -> DrawerView {
90        self.active
91    }
92
93    /// Whether the active view is a text-input context (drives the status
94    /// bar's ⌨/≣ indicator). The surface owns this knowledge: FIND hosts a
95    /// query input; the list views are filter-as-you-type lists, which read
96    /// as lists (spec mockup shows them with ≣).
97    pub fn is_text_input(&self) -> bool {
98        matches!(self.active, DrawerView::Find | DrawerView::Semantic)
99    }
100
101    pub fn set_view(&mut self, view: DrawerView) {
102        self.active = view;
103    }
104
105    // ── Typed accessors for view-specific calls from the host screen ───────
106
107    pub fn sidebar(&self) -> &SidebarComponent {
108        &self.sidebar
109    }
110    pub fn sidebar_mut(&mut self) -> &mut SidebarComponent {
111        &mut self.sidebar
112    }
113    pub fn query(&self) -> &QueryPanel {
114        &self.query
115    }
116    pub fn query_mut(&mut self) -> &mut QueryPanel {
117        &mut self.query
118    }
119    pub fn semantic_mut(&mut self) -> &mut SemanticPanel {
120        &mut self.semantic
121    }
122    pub fn tags_mut(&mut self) -> &mut TagsPanel {
123        &mut self.tags
124    }
125    pub fn links_mut(&mut self) -> &mut LinksPanel {
126        &mut self.links
127    }
128    pub fn outline_mut(&mut self) -> &mut OutlinePanel {
129        &mut self.outline
130    }
131
132    pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
133        match self.active {
134            DrawerView::Files => self.sidebar.hint_shortcuts(),
135            DrawerView::Find => self.query.hint_shortcuts(),
136            DrawerView::Semantic => self.semantic.hint_shortcuts(),
137            DrawerView::Tags => self.tags.hint_shortcuts(),
138            DrawerView::Links => self.links.hint_shortcuts(),
139            DrawerView::Outline => self.outline.hint_shortcuts(),
140            DrawerView::Config => self.config.hint_shortcuts(),
141        }
142    }
143
144    pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
145        match self.active {
146            DrawerView::Files => self.sidebar.handle_input(event, tx),
147            DrawerView::Find => {
148                // The Query panel speaks `handle_key`; non-key events are not
149                // delivered to it.
150                if let InputEvent::Key(key) = event {
151                    self.query.handle_key(key, tx)
152                } else {
153                    EventState::NotConsumed
154                }
155            }
156            DrawerView::Semantic => self.semantic.handle_input(event, tx),
157            DrawerView::Tags => self.tags.handle_input(event, tx),
158            DrawerView::Links => self.links.handle_input(event, tx),
159            DrawerView::Outline => self.outline.handle_input(event, tx),
160            DrawerView::Config => self.config.handle_input(event, tx),
161        }
162    }
163
164    pub fn handle_mouse(&mut self, event: &InputEvent, tx: &AppTx) {
165        let InputEvent::Mouse(mouse) = event else {
166            return;
167        };
168        match self.active {
169            // The Query panel has a dedicated mouse entry point; every other
170            // view takes mouse events through its regular input path.
171            DrawerView::Find => {
172                self.query.handle_mouse(mouse, tx);
173            }
174            _ => {
175                self.handle_input(event, tx);
176            }
177        }
178    }
179
180    pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
181        match self.active {
182            DrawerView::Files => self.sidebar.render(f, rect, theme, focused),
183            DrawerView::Find => self.query.render(f, rect, theme, focused),
184            DrawerView::Semantic => self.semantic.render(f, rect, theme, focused),
185            DrawerView::Tags => self.tags.render(f, rect, theme, focused),
186            DrawerView::Links => self.links.render(f, rect, theme, focused),
187            DrawerView::Outline => self.outline.render(f, rect, theme, focused),
188            DrawerView::Config => self.config.render(f, rect, theme, focused),
189        }
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::components::events::AppEvent;
197    use crate::settings::AppSettings;
198    use crate::test_support::temp_vault;
199    use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
200    use tokio::sync::mpsc::unbounded_channel;
201
202    async fn make_host() -> DrawerHost {
203        let vault = temp_vault("drawerhost").await;
204        vault.validate_and_init().await.unwrap();
205        let settings = AppSettings::default();
206        let sidebar = crate::components::sidebar::SidebarComponent::new(
207            settings.key_bindings.clone(),
208            vault.clone(),
209            settings.icons(),
210            &settings,
211        );
212        let query = crate::components::query_panel::QueryPanel::new(
213            vault.clone(),
214            settings.key_bindings.clone(),
215            settings.icons(),
216        );
217        let semantic = crate::components::semantic_search::SemanticPanel::new(
218            vault.clone(),
219            std::sync::Arc::new(std::sync::RwLock::new(settings.clone())),
220            settings.icons(),
221        );
222        let tags = crate::components::drawer_views::TagsPanel::new(vault.clone(), settings.icons());
223        let links =
224            crate::components::drawer_views::LinksPanel::new(vault.clone(), settings.icons());
225        let outline = crate::components::drawer_views::OutlinePanel::new(vault, settings.icons());
226        DrawerHost::new(sidebar, query, semantic, tags, links, outline)
227    }
228
229    fn key(code: KeyCode) -> InputEvent {
230        InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE))
231    }
232
233    /// FIND's key-only rule: the Query panel speaks `handle_key`, so
234    /// non-key events must pass through unconsumed rather than reach it.
235    #[tokio::test]
236    async fn find_view_delivers_keys_only() {
237        let mut host = make_host().await;
238        let (tx, _rx) = unbounded_channel();
239        host.set_view(DrawerView::Find);
240        assert_eq!(
241            host.handle_input(&InputEvent::Paste("hi".into()), &tx),
242            EventState::NotConsumed,
243            "a paste is not a key; FIND must not consume it"
244        );
245    }
246
247    /// The CFG arm delegates to the ConfigPanel component (adr/0023: no
248    /// inline view logic in the host) — its launcher keys work through the
249    /// host's dispatch.
250    #[tokio::test]
251    async fn config_view_routes_to_the_config_panel() {
252        let mut host = make_host().await;
253        let (tx, mut rx) = unbounded_channel();
254        host.set_view(DrawerView::Config);
255        assert_eq!(
256            host.handle_input(&key(KeyCode::Char('t')), &tx),
257            EventState::Consumed
258        );
259        assert!(matches!(
260            rx.try_recv(),
261            Ok(AppEvent::ExecuteLeaderAction(
262                crate::keys::leader::LeaderAction::VaultTheme
263            ))
264        ));
265        assert_eq!(
266            host.handle_input(&key(KeyCode::Char('x')), &tx),
267            EventState::NotConsumed
268        );
269    }
270
271    /// Hints follow the active view — the routing seam every view shares.
272    #[tokio::test]
273    async fn hints_follow_the_active_view() {
274        let mut host = make_host().await;
275        host.set_view(DrawerView::Config);
276        let cfg_hints = host.hint_shortcuts();
277        assert!(cfg_hints.iter().any(|(_, h)| h == "Theme picker"));
278        host.set_view(DrawerView::Files);
279        assert_ne!(host.hint_shortcuts(), cfg_hints);
280    }
281
282    /// The status bar's ⌨/≣ indicator: only the query-input views read as
283    /// text input.
284    #[tokio::test]
285    async fn text_input_views_are_find_and_semantic() {
286        let mut host = make_host().await;
287        for (view, expect) in [
288            (DrawerView::Files, false),
289            (DrawerView::Find, true),
290            (DrawerView::Semantic, true),
291            (DrawerView::Tags, false),
292            (DrawerView::Links, false),
293            (DrawerView::Outline, false),
294            (DrawerView::Config, false),
295        ] {
296            host.set_view(view);
297            assert_eq!(host.is_text_input(), expect, "{view:?}");
298        }
299    }
300}