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 (adr/0030).
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        );
244        let tags = crate::components::drawer_views::TagsPanel::new(vault.clone(), settings.icons());
245        let links =
246            crate::components::drawer_views::LinksPanel::new(vault.clone(), settings.icons());
247        let outline =
248            crate::components::drawer_views::OutlinePanel::new(vault.clone(), settings.icons());
249        DrawerHost::new(
250            vault,
251            &settings.key_bindings,
252            sidebar,
253            query,
254            semantic,
255            tags,
256            links,
257            outline,
258        )
259    }
260
261    fn key(code: KeyCode) -> InputEvent {
262        InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE))
263    }
264
265    /// FIND's key-only rule: the Query panel speaks `handle_key`, so
266    /// non-key events must pass through unconsumed rather than reach it.
267    #[tokio::test]
268    async fn find_view_delivers_keys_only() {
269        let mut host = make_host().await;
270        let (tx, _rx) = unbounded_channel();
271        host.set_view(DrawerView::Find);
272        assert_eq!(
273            host.handle_input(&InputEvent::Paste("hi".into()), &tx),
274            EventState::NotConsumed,
275            "a paste is not a key; FIND must not consume it"
276        );
277    }
278
279    /// The CFG arm delegates to the ConfigPanel component (adr/0023: no
280    /// inline view logic in the host) — its launcher keys work through the
281    /// host's dispatch.
282    #[tokio::test]
283    async fn config_view_routes_to_the_config_panel() {
284        let mut host = make_host().await;
285        let (tx, mut rx) = unbounded_channel();
286        host.set_view(DrawerView::Config);
287        assert_eq!(
288            host.handle_input(&key(KeyCode::Char('t')), &tx),
289            EventState::Consumed
290        );
291        assert!(matches!(
292            rx.try_recv(),
293            Ok(AppEvent::ExecuteLeaderAction(
294                crate::keys::leader::LeaderAction::VaultTheme
295            ))
296        ));
297        assert_eq!(
298            host.handle_input(&key(KeyCode::Char('x')), &tx),
299            EventState::NotConsumed
300        );
301    }
302
303    /// Hints follow the active view — the routing seam every view shares.
304    #[tokio::test]
305    async fn hints_follow_the_active_view() {
306        let mut host = make_host().await;
307        host.set_view(DrawerView::Config);
308        let cfg_hints = host.hint_shortcuts();
309        assert!(cfg_hints.iter().any(|(_, h)| h == "Theme picker"));
310        host.set_view(DrawerView::Files);
311        assert_ne!(host.hint_shortcuts(), cfg_hints);
312    }
313
314    /// The status bar's ⌨/≣ indicator: only the query-input views read as
315    /// text input.
316    #[tokio::test]
317    async fn text_input_views_are_find_and_semantic() {
318        let mut host = make_host().await;
319        for (view, expect) in [
320            (DrawerView::Files, false),
321            (DrawerView::Find, true),
322            (DrawerView::Semantic, true),
323            (DrawerView::Ask, false),
324            (DrawerView::Tags, false),
325            (DrawerView::Links, false),
326            (DrawerView::Outline, false),
327            (DrawerView::Config, false),
328        ] {
329            host.set_view(view);
330            assert_eq!(host.is_text_input(), expect, "{view:?}");
331        }
332    }
333}