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