Skip to main content

kimun_notes/app_screen/
panel_set.rs

1//! `PanelOrder` — the pure focus/order/visibility state machine for the
2//! editor screen's persistent **Panels**. Keyed only on `PanelKind`, so it
3//! carries no vault or heavy component state and is testable in isolation.
4//! `PanelSet` (below) composes it with the concrete panels.
5
6use std::sync::Arc;
7
8use crate::server_client::RagClient;
9use ratatui::Frame;
10use ratatui::crossterm::event::{MouseButton, MouseEventKind};
11use ratatui::layout::{Constraint, Direction, Layout, Position, Rect};
12
13use crate::components::Component;
14use crate::components::activity_rail::{ActivityRail, RAIL_WIDTH, RailCaps};
15use crate::components::ask_thread::ThreadPanel;
16use crate::components::attachment_view::AttachmentView;
17use crate::components::drawer::{DrawerHost, DrawerView};
18use crate::components::event_state::EventState;
19use crate::components::events::{AppTx, InputEvent};
20use crate::components::panel::{PanelKind, panel_block};
21use crate::components::query_panel::QueryPanel;
22use crate::components::sidebar::SidebarComponent;
23use crate::components::text_editor::TextEditorComponent;
24use crate::settings::themes::Theme;
25
26/// Default drawer width in columns. Resizable at runtime by dragging the
27/// drawer↔editor divider.
28const DEFAULT_DRAWER_WIDTH: u16 = 34;
29/// Narrowest the drawer can be dragged.
30const MIN_DRAWER_WIDTH: u16 = 20;
31/// Columns that must always remain for the editor when dragging the divider.
32const MIN_EDITOR_WIDTH: u16 = 20;
33
34struct Slot {
35    kind: PanelKind,
36    visible: bool,
37}
38
39/// Ordered panels + which one is focused. The layout is fixed left→right:
40/// rail → drawer → editor. The rail and the editor are always visible; the
41/// drawer toggles. Focus tracks a panel by kind and cycles over the visible
42/// panels, wrapping at both ends.
43pub struct PanelOrder {
44    slots: Vec<Slot>,
45    focus: usize,
46}
47
48impl PanelOrder {
49    /// Default layout: rail → drawer (visible) → editor (focused).
50    pub fn new() -> Self {
51        let slots = vec![
52            Slot {
53                kind: PanelKind::Rail,
54                visible: true,
55            },
56            Slot {
57                kind: PanelKind::Drawer,
58                visible: true,
59            },
60            Slot {
61                kind: PanelKind::Editor,
62                visible: true,
63            },
64        ];
65        let focus = slots
66            .iter()
67            .position(|s| s.kind == PanelKind::Editor)
68            .expect("editor slot present");
69        Self { slots, focus }
70    }
71
72    /// The currently focused panel.
73    pub fn focused(&self) -> PanelKind {
74        self.slots[self.focus].kind
75    }
76
77    /// The visible panel one step left of focus, wrapping at the left end.
78    pub fn prev_kind(&self) -> Option<PanelKind> {
79        self.step(|i, n| (i + n - 1) % n)
80    }
81
82    /// The visible panel one step right of focus, wrapping at the right end.
83    pub fn next_kind(&self) -> Option<PanelKind> {
84        self.step(|i, n| (i + 1) % n)
85    }
86
87    /// Step through the *visible* panels from the focused one.
88    fn step(&self, advance: impl Fn(usize, usize) -> usize) -> Option<PanelKind> {
89        let visible = self.visible_in_order();
90        let n = visible.len();
91        if n < 2 {
92            return None;
93        }
94        let i = visible.iter().position(|&k| k == self.focused())?;
95        Some(visible[advance(i, n)])
96    }
97
98    /// Move focus to `kind`. No-op if the kind is not present.
99    pub fn focus(&mut self, kind: PanelKind) {
100        if let Some(i) = self.slots.iter().position(|s| s.kind == kind) {
101            self.focus = i;
102        }
103    }
104
105    /// Whether `kind` is currently visible.
106    pub fn is_visible(&self, kind: PanelKind) -> bool {
107        self.slots
108            .iter()
109            .find(|s| s.kind == kind)
110            .is_some_and(|s| s.visible)
111    }
112
113    /// Reveal `kind`.
114    pub fn show(&mut self, kind: PanelKind) {
115        if let Some(s) = self.slots.iter_mut().find(|s| s.kind == kind) {
116            s.visible = true;
117        }
118    }
119
120    /// Hide `kind`. The rail and the editor are always visible, so hiding
121    /// them is a no-op.
122    pub fn hide(&mut self, kind: PanelKind) {
123        if kind == PanelKind::Editor || kind == PanelKind::Rail {
124            return;
125        }
126        if let Some(s) = self.slots.iter_mut().find(|s| s.kind == kind) {
127            s.visible = false;
128        }
129        // If focus was on the panel we just hid, move it to the nearest
130        // visible panel (the editor is always visible, so a target exists).
131        if !self.slots[self.focus].visible {
132            self.focus = self.nearest_visible(self.focus);
133        }
134    }
135
136    /// The visible panels in their current left→right order. Drives the
137    /// render layout: each panel contributes one column in this sequence.
138    pub fn visible_in_order(&self) -> Vec<PanelKind> {
139        self.slots
140            .iter()
141            .filter(|s| s.visible)
142            .map(|s| s.kind)
143            .collect()
144    }
145
146    /// Index of the nearest visible slot, searching outward from `from`.
147    /// Falls back to `from` if somehow nothing is visible (cannot happen —
148    /// the editor is always visible).
149    fn nearest_visible(&self, from: usize) -> usize {
150        let n = self.slots.len();
151        (1..n)
152            .flat_map(|d| [from.checked_sub(d), Some(from + d).filter(|&i| i < n)])
153            .flatten()
154            .find(|&i| self.slots[i].visible)
155            .unwrap_or(from)
156    }
157}
158
159impl Default for PanelOrder {
160    fn default() -> Self {
161        Self::new()
162    }
163}
164
165/// Column width a panel occupies when laid out. The rail is fixed, the
166/// drawer is runtime-resizable, and the editor fills the remainder.
167fn panel_column(kind: PanelKind, drawer_width: u16) -> Constraint {
168    match kind {
169        PanelKind::Rail => Constraint::Length(RAIL_WIDTH),
170        PanelKind::Drawer => Constraint::Length(drawer_width),
171        PanelKind::Editor => Constraint::Min(0),
172    }
173}
174
175/// Lay `visible` out left→right as one column per panel. Shared by `render`
176/// and mouse hit-testing so clicks are always tested against the same rects
177/// the panels were drawn into.
178fn layout_columns(visible: &[PanelKind], area: Rect, drawer_width: u16) -> Vec<(PanelKind, Rect)> {
179    let constraints: Vec<Constraint> = visible
180        .iter()
181        .map(|k| panel_column(*k, drawer_width))
182        .collect();
183    let columns = Layout::default()
184        .direction(Direction::Horizontal)
185        .constraints(constraints)
186        .split(area);
187    visible
188        .iter()
189        .copied()
190        .zip(columns.iter().copied())
191        .collect()
192}
193
194/// The panel whose column contains the given screen cell, if any.
195fn kind_at(columns: &[(PanelKind, Rect)], column: u16, row: u16) -> Option<PanelKind> {
196    columns
197        .iter()
198        .find(|(_, rect)| rect.contains(Position::new(column, row)))
199        .map(|(kind, _)| *kind)
200}
201
202/// Which content the editor *area* currently shows — a pure selector, not a
203/// container. The heavy stateful panels are
204/// permanent residents of `PanelSet`: the note editor is `PanelSet::editor`
205/// and the Ask workspace's conversation thread is `PanelSet::ask`, each living
206/// for the whole screen lifetime regardless of which content is selected — so
207/// their state (a live nvim backend, undo history, the conversation) survives
208/// switching the area away and back. Only the read-only attachment view is
209/// owned by its arm here, since it carries no state worth preserving. Exactly
210/// one variant is active at a time.
211enum EditorAreaContent {
212    Note,
213    // Boxed: `AttachmentView` can hold a full text-file preview plus
214    // icons/key bindings — boxing keeps the enum from ballooning to the size
215    // of that arm.
216    Attachment(Box<AttachmentView>),
217    /// Selects the resident `PanelSet::ask` for display (unit — the thread
218    /// panel itself lives on `PanelSet`, not in this variant).
219    Ask,
220}
221
222/// The editor screen's persistent **Panels** — the activity rail, the single
223/// drawer, and the editor — plus the `PanelOrder` that decides visibility and
224/// which one is focused. Routes input and render to the focused / visible
225/// panels; the host (`EditorScreen`) reaches a specific drawer view through
226/// the typed accessors for view-specific calls.
227pub struct PanelSet {
228    order: PanelOrder,
229    rail: ActivityRail,
230    drawer: DrawerHost,
231    /// The note editor. Retained for the whole screen lifetime — including
232    /// while an attachment or the Ask workspace is shown — so its backend
233    /// (e.g. a live nvim process) and undo history survive the round trip.
234    editor: TextEditorComponent,
235    /// The Ask workspace's conversation thread + composer. Like `editor`, a
236    /// permanent resident: retained for the whole screen lifetime so the
237    /// conversation survives switching the editor area to another view and
238    /// back, the same way the note editor does. Owns its own `RagClient`. Shown only
239    /// when `content` is `EditorAreaContent::Ask`.
240    ask: ThreadPanel,
241    /// Which content the editor *area* currently shows: the note editor
242    /// (resident `editor`), a read-only attachment view, or the Ask workspace
243    /// (resident `ask`). See `EditorAreaContent`.
244    content: EditorAreaContent,
245    /// Current drawer width in columns (divider-draggable).
246    drawer_width: u16,
247    /// Whether a divider drag is in progress.
248    dragging_divider: bool,
249    /// The column each visible panel was drawn into on the last render —
250    /// the single source of truth for mouse hit-testing. Empty until the
251    /// first render.
252    column_rects: Vec<(PanelKind, Rect)>,
253}
254
255impl PanelSet {
256    pub fn from_panels(
257        drawer: DrawerHost,
258        editor: TextEditorComponent,
259        icons: crate::settings::icons::Icons,
260        key_bindings: crate::keys::KeyBindings,
261        rail_caps: RailCaps,
262    ) -> Self {
263        let mut ask = ThreadPanel::new();
264        ask.set_icons(icons.clone());
265        Self {
266            order: PanelOrder::new(),
267            rail: ActivityRail::new(key_bindings, icons, rail_caps),
268            drawer,
269            editor,
270            ask,
271            content: EditorAreaContent::Note,
272            drawer_width: DEFAULT_DRAWER_WIDTH,
273            dragging_divider: false,
274            column_rects: Vec::new(),
275        }
276    }
277
278    /// Rebuild the activity rail with fresh feature visibility — the runtime
279    /// path for SEM/ASK appearing or disappearing (RAG status change). Keeps
280    /// the rail cursor on the drawer's active view so navigation continues
281    /// from there.
282    pub fn rebuild_rail(
283        &mut self,
284        key_bindings: crate::keys::KeyBindings,
285        icons: crate::settings::icons::Icons,
286        rail_caps: RailCaps,
287    ) {
288        let active = self.drawer.active_view();
289        self.ask.set_icons(icons.clone());
290        self.rail = ActivityRail::new(key_bindings, icons, rail_caps);
291        self.rail.set_cursor(active);
292    }
293
294    /// Whether the activity rail currently shows `view` (feature gate on).
295    #[cfg(test)]
296    pub fn rail_shows(&self, view: DrawerView) -> bool {
297        self.rail.shows(view)
298    }
299
300    /// The single injection point for the live RAG client: hands it to the
301    /// resident Ask panel, which derives its composer-enabled state from the
302    /// client's presence (a present client enables submission,
303    /// `None` disables it without evicting the thread). Because the panel is
304    /// resident, one call keeps it correct whether or not Ask is on screen.
305    pub fn set_ask_client(&mut self, client: Option<Arc<RagClient>>) {
306        self.ask.set_client(client);
307    }
308
309    // ── Order / focus / visibility (delegate to PanelOrder) ─────────────────
310
311    pub fn focused(&self) -> PanelKind {
312        self.order.focused()
313    }
314
315    /// Status-bar label for the focused panel; the drawer resolves to its
316    /// active view's label.
317    pub fn focused_label(&self) -> &'static str {
318        match self.order.focused() {
319            PanelKind::Drawer => self.drawer.active_view().label(),
320            // The editor area showing the Ask workspace reads as ASK, not
321            // EDITOR — mirrors the panel frame's "Ask" title.
322            PanelKind::Editor if self.is_showing_ask() => DrawerView::Ask.label(),
323            kind => kind.label(),
324        }
325    }
326
327    pub fn prev_kind(&self) -> Option<PanelKind> {
328        self.order.prev_kind()
329    }
330
331    pub fn next_kind(&self) -> Option<PanelKind> {
332        self.order.next_kind()
333    }
334
335    pub fn is_visible(&self, kind: PanelKind) -> bool {
336        self.order.is_visible(kind)
337    }
338
339    pub fn show(&mut self, kind: PanelKind) {
340        self.order.show(kind);
341    }
342
343    pub fn hide(&mut self, kind: PanelKind) {
344        self.order.hide(kind);
345    }
346
347    /// Move focus to `kind`. Any transition away from the editor closes its
348    /// autocomplete popup so it doesn't linger while another panel owns input.
349    pub fn focus(&mut self, kind: PanelKind) {
350        if kind != PanelKind::Editor {
351            self.editor.close_autocomplete();
352        }
353        self.order.focus(kind);
354    }
355
356    // ── Drawer view control ─────────────────────────────────────────────────
357
358    pub fn active_drawer_view(&self) -> DrawerView {
359        self.drawer.active_view()
360    }
361
362    /// Whether the drawer's active view is a text-input context (status-bar
363    /// ⌨/≣ indicator).
364    pub fn drawer_is_text_input(&self) -> bool {
365        self.drawer.is_text_input()
366    }
367
368    /// Grow/shrink the drawer by `delta` columns (leader window commands),
369    /// clamped to the same bounds the divider drag enforces.
370    pub fn adjust_drawer_width(&mut self, delta: i16) {
371        // Before the first render there are no rects to clamp against —
372        // skip rather than allow an unbounded grow.
373        let Some(right) = self.column_rects.iter().map(|(_, r)| r.right()).max() else {
374            return;
375        };
376        let new = self.drawer_width.saturating_add_signed(delta);
377        let max_width = right.saturating_sub(RAIL_WIDTH + MIN_EDITOR_WIDTH);
378        self.drawer_width = new.clamp(MIN_DRAWER_WIDTH, max_width.max(MIN_DRAWER_WIDTH));
379    }
380
381    /// Switch the drawer's active `view` and rail cursor *without* changing
382    /// its visibility — for paths that must not force-reveal a drawer the user
383    /// explicitly hid (e.g. leaving the Ask workspace when a note opens).
384    pub fn switch_drawer_view(&mut self, view: DrawerView) {
385        self.drawer.set_view(view);
386        self.rail.set_cursor(view);
387    }
388
389    /// Switch the drawer to `view` and reveal it. Keeps the rail cursor in
390    /// step so keyboard navigation continues from the active item.
391    pub fn open_drawer_view(&mut self, view: DrawerView) {
392        self.switch_drawer_view(view);
393        self.order.show(PanelKind::Drawer);
394    }
395
396    // ── Typed accessors for panel-specific calls ───────────────────────────
397
398    pub fn sidebar(&self) -> &SidebarComponent {
399        self.drawer.sidebar()
400    }
401    pub fn sidebar_mut(&mut self) -> &mut SidebarComponent {
402        self.drawer.sidebar_mut()
403    }
404    /// The note editor, or `None` while an attachment or the Ask workspace is
405    /// shown in its place. Callers reaching for the editor cross a mode
406    /// boundary and must handle that case.
407    pub fn editor(&self) -> Option<&TextEditorComponent> {
408        matches!(self.content, EditorAreaContent::Note).then_some(&self.editor)
409    }
410    pub fn editor_mut(&mut self) -> Option<&mut TextEditorComponent> {
411        if !matches!(self.content, EditorAreaContent::Note) {
412            return None;
413        }
414        Some(&mut self.editor)
415    }
416
417    /// Show `view` in the editor area, replacing any prior attachment/Ask
418    /// content and hiding the note editor. Closes the editor's autocomplete
419    /// so it can't linger under the attachment, and focuses the editor panel.
420    pub fn show_attachment(&mut self, view: AttachmentView) {
421        self.editor.close_autocomplete();
422        self.content = EditorAreaContent::Attachment(Box::new(view));
423        self.order.focus(PanelKind::Editor);
424    }
425
426    /// Return the editor area to the note editor, discarding any attachment.
427    /// No-op when already showing the editor, and — unlike `hide_ask` — a
428    /// no-op when showing the Ask workspace too: this method's contract is
429    /// specifically about attachments, so it leaves Ask content alone (use
430    /// `hide_ask` for that).
431    pub fn clear_attachment(&mut self) {
432        if matches!(self.content, EditorAreaContent::Attachment(_)) {
433            self.content = EditorAreaContent::Note;
434        }
435    }
436
437    /// Whether the editor area is currently showing an attachment.
438    pub fn is_showing_attachment(&self) -> bool {
439        matches!(self.content, EditorAreaContent::Attachment(_))
440    }
441
442    /// The vault path of the attachment on show, if any — used to open it with
443    /// the OS default program.
444    pub fn attachment_path(&self) -> Option<&kimun_core::nfs::VaultPath> {
445        match &self.content {
446            EditorAreaContent::Attachment(view) => Some(view.path()),
447            _ => None,
448        }
449    }
450
451    /// Select the resident Ask workspace as the editor-area content, hiding
452    /// the note editor. Mirrors `show_attachment`: closes the editor's
453    /// autocomplete and focuses the editor panel. The thread persists by
454    /// residency — this only flips the selector.
455    pub fn show_ask(&mut self) {
456        self.editor.close_autocomplete();
457        self.content = EditorAreaContent::Ask;
458        self.order.focus(PanelKind::Editor);
459    }
460
461    /// Return the editor area to the note editor when it's showing Ask. The
462    /// resident thread panel is untouched — its conversation survives. No-op
463    /// for any other content (mirrors `clear_attachment`'s scoping).
464    pub fn hide_ask(&mut self) {
465        if matches!(self.content, EditorAreaContent::Ask) {
466            self.content = EditorAreaContent::Note;
467        }
468    }
469
470    /// The resident Ask panel (always present; shown only when `content` is
471    /// `Ask`).
472    pub fn ask(&self) -> &ThreadPanel {
473        &self.ask
474    }
475    /// The resident Ask panel, mutably.
476    pub fn ask_mut(&mut self) -> &mut ThreadPanel {
477        &mut self.ask
478    }
479
480    /// Whether the editor area is currently showing the Ask workspace.
481    pub fn is_showing_ask(&self) -> bool {
482        matches!(self.content, EditorAreaContent::Ask)
483    }
484
485    /// The active editor-area content as a `Component` — the attachment view
486    /// or Ask panel when one is shown, otherwise the note editor. Lets
487    /// input/hint routing dispatch once instead of branching on
488    /// `self.content` at each site. (Render stays separate: it wraps the
489    /// editor in its own dirty-title block.)
490    fn editor_area(&self) -> &dyn Component {
491        match &self.content {
492            EditorAreaContent::Attachment(view) => view.as_ref(),
493            EditorAreaContent::Ask => &self.ask,
494            EditorAreaContent::Note => &self.editor,
495        }
496    }
497    pub fn query(&self) -> &QueryPanel {
498        self.drawer.query()
499    }
500    pub fn query_mut(&mut self) -> &mut QueryPanel {
501        self.drawer.query_mut()
502    }
503    pub fn semantic_mut(&mut self) -> &mut crate::components::semantic_search::SemanticPanel {
504        self.drawer.semantic_mut()
505    }
506    pub fn ask_sources_mut(&mut self) -> &mut crate::components::ask_sources::SourcesPanel {
507        self.drawer.ask_sources_mut()
508    }
509    pub fn tags_mut(&mut self) -> &mut crate::components::drawer_views::TagsPanel {
510        self.drawer.tags_mut()
511    }
512    pub fn links_mut(&mut self) -> &mut crate::components::drawer_views::LinksPanel {
513        self.drawer.links_mut()
514    }
515    pub fn outline_mut(&mut self) -> &mut crate::components::drawer_views::OutlinePanel {
516        self.drawer.outline_mut()
517    }
518    pub fn drawer_set_config_info(&mut self, info: crate::components::drawer::ConfigInfo) {
519        self.drawer.set_config_info(info);
520    }
521
522    // ── Routing ────────────────────────────────────────────────────────────
523
524    /// Footer hints for the focused panel.
525    pub fn focused_hints(&self) -> Vec<(String, String)> {
526        match self.order.focused() {
527            PanelKind::Rail => self.rail.hint_shortcuts(),
528            PanelKind::Drawer => self.drawer.hint_shortcuts(),
529            PanelKind::Editor => self.editor_area().hint_shortcuts(),
530        }
531    }
532
533    /// Route an input event to the focused panel.
534    pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
535        match self.order.focused() {
536            PanelKind::Rail => self.rail.handle_input(event, tx),
537            PanelKind::Drawer => self.drawer.handle_input(event, tx),
538            PanelKind::Editor => self.editor_area_handle_input(event, tx),
539        }
540    }
541
542    /// Deliver an input event to the editor-area content. The Ask arm calls
543    /// the resident panel's inherent `ThreadPanel::handle_input` (it derives
544    /// submission from its own client); the others use the trait. Each arm
545    /// borrows a distinct field, disjoint from `content`.
546    fn editor_area_handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
547        match &mut self.content {
548            EditorAreaContent::Ask => self.ask.handle_input(event, tx),
549            EditorAreaContent::Attachment(view) => view.handle_input(event, tx),
550            EditorAreaContent::Note => self.editor.handle_input(event, tx),
551        }
552    }
553
554    /// Whether a press on this cell points *at* somewhere in the note buffer.
555    ///
556    /// Three conditions, and only the first is about geometry:
557    ///
558    /// - **Inside the editor component itself.** Asked of the component, not of
559    ///   `Self::column_rects`, because the column is wider than the buffer: it
560    ///   also holds the frame `render` draws around the component (and, to its
561    ///   left, the divider that [`Self::handle_mouse`] claims before any panel
562    ///   sees it). A press on either is out of the component's bounds, so it
563    ///   returns `NotConsumed` and the cursor never moves.
564    /// - **The editor area is showing the note.** It may instead hold an
565    ///   attachment preview or the Ask workspace, neither of which has a cursor
566    ///   or a link under the pointer — an attachment would launch an external
567    ///   program and Ask would swallow the press.
568    /// - **The mouse drives the cursor.** False under the **nvim** backend,
569    ///   where a press never moves the cursor at all, so anything reading the
570    ///   cursor afterwards reads wherever the *keyboard* left it.
571    ///
572    /// Every one of them is the same question asked of a different layer: did
573    /// this press *place the cursor*? Anything that answers a press by reading
574    /// the cursor — following a link, most obviously — has to ask all three, or
575    /// it acts on a position the press never set. The column alone was the
576    /// original gate, and each of the others was a defect before it was a
577    /// condition. Callers that only want the column should not reuse this.
578    pub fn is_note_buffer_cell(&self, column: u16, row: u16) -> bool {
579        matches!(self.content, EditorAreaContent::Note)
580            && self.editor.mouse_drives_cursor()
581            && self.editor.covers(column, row)
582    }
583
584    /// The divider hit zone: the drawer's right border column (the cell
585    /// between drawer content and editor).
586    fn on_divider(&self, column: u16, row: u16) -> bool {
587        self.column_rects
588            .iter()
589            .find(|(kind, _)| *kind == PanelKind::Drawer)
590            .is_some_and(|(_, rect)| {
591                rect.height > 0
592                    && column == rect.right().saturating_sub(1)
593                    && row >= rect.y
594                    && row < rect.bottom()
595            })
596    }
597
598    /// Apply a divider drag: the drawer's new width follows the cursor,
599    /// clamped so both the drawer and the editor keep a usable minimum.
600    fn drag_divider_to(&mut self, column: u16) {
601        let Some((_, drawer_rect)) = self
602            .column_rects
603            .iter()
604            .find(|(kind, _)| *kind == PanelKind::Drawer)
605        else {
606            return;
607        };
608        let total_right = self
609            .column_rects
610            .iter()
611            .map(|(_, r)| r.right())
612            .max()
613            .unwrap_or(drawer_rect.right());
614        let max_width = total_right
615            .saturating_sub(drawer_rect.x)
616            .saturating_sub(MIN_EDITOR_WIDTH);
617        let new_width = column.saturating_sub(drawer_rect.x).saturating_add(1);
618        self.drawer_width = new_width.clamp(MIN_DRAWER_WIDTH, max_width.max(MIN_DRAWER_WIDTH));
619    }
620
621    /// Route a mouse event by hit-testing the panel columns from the last
622    /// render. Dragging the drawer↔editor divider resizes the drawer. A
623    /// button-down click focuses the panel under the cursor — the one
624    /// consistent click-to-focus rule for every panel — and the event is
625    /// then forwarded to that panel for its internal behavior (cursor
626    /// placement, list selection, scrolling). Events outside every column
627    /// (or before the first render) are not consumed.
628    pub fn handle_mouse(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
629        let InputEvent::Mouse(mouse) = event else {
630            return EventState::NotConsumed;
631        };
632
633        // Divider drag lifecycle.
634        match mouse.kind {
635            MouseEventKind::Down(MouseButton::Left) if self.on_divider(mouse.column, mouse.row) => {
636                self.dragging_divider = true;
637                return EventState::Consumed;
638            }
639            MouseEventKind::Drag(MouseButton::Left) if self.dragging_divider => {
640                self.drag_divider_to(mouse.column);
641                return EventState::Consumed;
642            }
643            MouseEventKind::Up(MouseButton::Left) if self.dragging_divider => {
644                self.dragging_divider = false;
645                return EventState::Consumed;
646            }
647            _ => {}
648        }
649
650        let Some(kind) = kind_at(&self.column_rects, mouse.column, mouse.row) else {
651            return EventState::NotConsumed;
652        };
653        if matches!(mouse.kind, MouseEventKind::Down(_)) {
654            self.focus(kind);
655        }
656        match kind {
657            PanelKind::Rail => {
658                self.rail.handle_input(event, tx);
659            }
660            PanelKind::Drawer => {
661                self.drawer.handle_mouse(event, tx);
662            }
663            PanelKind::Editor => {
664                self.editor_area_handle_input(event, tx);
665            }
666        }
667        EventState::Consumed
668    }
669
670    /// Lay the visible panels out left→right and render each. `show_focus`
671    /// is false while an overlay is open, so no panel draws its focused
672    /// highlight under the overlay.
673    pub fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme, show_focus: bool) {
674        let visible = self.order.visible_in_order();
675        if visible.is_empty() {
676            return;
677        }
678        let columns = layout_columns(&visible, area, self.drawer_width);
679        self.column_rects = columns.clone();
680
681        let focused = self.order.focused();
682        let drawer_view = self
683            .is_visible(PanelKind::Drawer)
684            .then(|| self.drawer.active_view());
685        for (kind, rect) in &columns {
686            let is_focused = show_focus && *kind == focused;
687            let rect = *rect;
688            match kind {
689                PanelKind::Rail => self.rail.render(f, rect, theme, is_focused, drawer_view),
690                PanelKind::Drawer => self.drawer.render(f, rect, theme, is_focused),
691                PanelKind::Editor => {
692                    // The editor area's frame is drawn here (not by the
693                    // component) so the dirty marker and focus border live with
694                    // the layout. The frame title reflects which content the
695                    // area is showing — the note editor, an attachment, or Ask.
696                    match &mut self.content {
697                        EditorAreaContent::Attachment(view) => {
698                            let block = panel_block("Attachment", theme, is_focused);
699                            let inner = block.inner(rect);
700                            f.render_widget(block, rect);
701                            view.render(f, inner, theme, is_focused);
702                        }
703                        EditorAreaContent::Ask => {
704                            let block = panel_block("Ask", theme, is_focused);
705                            let inner = block.inner(rect);
706                            f.render_widget(block, rect);
707                            self.ask.render(f, inner, theme, is_focused);
708                        }
709                        EditorAreaContent::Note => {
710                            let title = if self.editor.is_dirty() {
711                                "Editor [+]"
712                            } else {
713                                "Editor"
714                            };
715                            let block = panel_block(title, theme, is_focused);
716                            let inner = block.inner(rect);
717                            f.render_widget(block, rect);
718                            self.editor.render(f, inner, theme, is_focused);
719                        }
720                    }
721                }
722            }
723        }
724    }
725}
726
727#[cfg(test)]
728mod tests {
729    use super::*;
730    use crate::settings::AppSettings;
731    use crate::test_support::{mouse_down_at, temp_vault};
732    use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
733    use tokio::sync::mpsc::unbounded_channel;
734
735    async fn make_panel_set() -> PanelSet {
736        let vault = temp_vault("panelset").await;
737        vault.validate_and_init().await.unwrap();
738        let settings = AppSettings::default();
739        let sidebar = SidebarComponent::new(
740            settings.key_bindings.clone(),
741            vault.clone(),
742            settings.icons(),
743            &settings,
744        );
745        let editor = TextEditorComponent::new(settings.key_bindings.clone(), &settings);
746        let query = QueryPanel::new(
747            vault.clone(),
748            settings.key_bindings.clone(),
749            settings.icons(),
750        );
751        let tags = crate::components::drawer_views::TagsPanel::new(
752            vault.clone(),
753            settings.icons(),
754            settings.yank_combos(),
755        );
756        let links = crate::components::drawer_views::LinksPanel::new(
757            vault.clone(),
758            settings.icons(),
759            settings.yank_combos(),
760        );
761        let semantic = crate::components::semantic_search::SemanticPanel::new(
762            vault.clone(),
763            std::sync::Arc::new(std::sync::RwLock::new(settings.clone())),
764            settings.icons(),
765            settings.yank_combos(),
766        );
767        let outline = crate::components::drawer_views::OutlinePanel::new(
768            vault.clone(),
769            settings.icons(),
770            settings.yank_combos(),
771        );
772        let drawer = DrawerHost::new(
773            vault,
774            &settings.key_bindings,
775            sidebar,
776            query,
777            semantic,
778            tags,
779            links,
780            outline,
781        );
782        PanelSet::from_panels(
783            drawer,
784            editor,
785            settings.icons(),
786            settings.key_bindings,
787            RailCaps {
788                semantic: true,
789                ask: true,
790            },
791        )
792    }
793
794    /// Lay the visible panels out over a fixed area by actually rendering them.
795    ///
796    /// Assigning `column_rects` directly would be shorter and wrong: the hit
797    /// tests read the editor component's own rect too, and only `render`
798    /// assigns that. A layout that sets one and not the other describes a frame
799    /// that never existed.
800    fn lay_out(panels: &mut PanelSet) {
801        let area = Rect::new(0, 0, 120, 40);
802        let theme = crate::settings::themes::Theme::default();
803        let mut term =
804            ratatui::Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
805                .unwrap();
806        term.draw(|f| panels.render(f, area, &theme, true)).unwrap();
807    }
808
809    /// A cell in the middle of the editor column, as laid out.
810    fn editor_cell(panels: &PanelSet) -> (u16, u16) {
811        let (_, rect) = panels
812            .column_rects
813            .iter()
814            .find(|(kind, _)| *kind == PanelKind::Editor)
815            .expect("the editor is always visible");
816        (rect.x + rect.width / 2, rect.y + rect.height / 2)
817    }
818
819    /// The gate is not merely geometric. It read as "the editor column" once,
820    /// and a double-click in the Ask workspace was swallowed while one in an
821    /// attachment preview spawned an external viewer — the same cell, three
822    /// different things underneath it.
823    #[tokio::test]
824    async fn the_note_buffer_gate_tracks_what_the_editor_area_shows() {
825        let mut panels = make_panel_set().await;
826        lay_out(&mut panels);
827        let (col, row) = editor_cell(&panels);
828        assert!(
829            panels.is_note_buffer_cell(col, row),
830            "the note buffer is what the gate is for"
831        );
832
833        panels.show_ask();
834        assert!(
835            !panels.is_note_buffer_cell(col, row),
836            "the Ask workspace has no cursor to point at"
837        );
838
839        panels.hide_ask();
840        assert!(panels.is_note_buffer_cell(col, row), "and back again");
841    }
842
843    /// A divider press is claimed before any panel sees it and never changes
844    /// focus, so it must not count as pointing at the buffer either.
845    #[tokio::test]
846    async fn the_note_buffer_gate_rejects_the_divider_and_other_columns() {
847        let mut panels = make_panel_set().await;
848        lay_out(&mut panels);
849        let (_, drawer) = panels
850            .column_rects
851            .iter()
852            .find(|(kind, _)| *kind == PanelKind::Drawer)
853            .expect("the drawer is visible by default");
854        let (divider_col, row) = (drawer.right().saturating_sub(1), drawer.y + 1);
855        assert!(panels.on_divider(divider_col, row), "precondition");
856        assert!(!panels.is_note_buffer_cell(divider_col, row));
857        assert!(
858            !panels.is_note_buffer_cell(drawer.x + 1, row),
859            "and the drawer is not the editor"
860        );
861    }
862
863    /// The editor *column* is not the editor *buffer*: `PanelSet::render` draws
864    /// a bordered frame around the component and hands it only the interior.
865    /// A press on the frame is out of the component's bounds, so it returns
866    /// `NotConsumed` and the cursor never moves — counting those cells let two
867    /// clicks on the `─ Editor` title bar follow whichever link the cursor was
868    /// parked on, a note the user never pointed at.
869    #[tokio::test]
870    async fn the_note_buffer_gate_rejects_the_editor_frame() {
871        let mut panels = make_panel_set().await;
872        lay_out(&mut panels);
873        let (_, rect) = panels
874            .column_rects
875            .iter()
876            .find(|(kind, _)| *kind == PanelKind::Editor)
877            .expect("the editor is always visible");
878        let rect = *rect;
879        let (mid_col, mid_row) = (rect.x + rect.width / 2, rect.y + rect.height / 2);
880        assert!(
881            panels.is_note_buffer_cell(mid_col, mid_row),
882            "precondition: the interior still counts"
883        );
884        assert!(
885            !panels.is_note_buffer_cell(mid_col, rect.y),
886            "the title row is frame"
887        );
888        assert!(
889            !panels.is_note_buffer_cell(mid_col, rect.bottom() - 1),
890            "so is the bottom border"
891        );
892        assert!(
893            !panels.is_note_buffer_cell(rect.x, mid_row),
894            "so is the left border"
895        );
896        assert!(
897            !panels.is_note_buffer_cell(rect.right() - 1, mid_row),
898            "so is the right border"
899        );
900    }
901
902    fn scroll_at(col: u16, row: u16) -> InputEvent {
903        InputEvent::Mouse(MouseEvent {
904            kind: MouseEventKind::ScrollDown,
905            column: col,
906            row,
907            modifiers: KeyModifiers::NONE,
908        })
909    }
910
911    fn drag_at(col: u16, row: u16) -> InputEvent {
912        InputEvent::Mouse(MouseEvent {
913            kind: MouseEventKind::Drag(MouseButton::Left),
914            column: col,
915            row,
916            modifiers: KeyModifiers::NONE,
917        })
918    }
919
920    fn up_at(col: u16, row: u16) -> InputEvent {
921        InputEvent::Mouse(MouseEvent {
922            kind: MouseEventKind::Up(MouseButton::Left),
923            column: col,
924            row,
925            modifiers: KeyModifiers::NONE,
926        })
927    }
928
929    /// A button-down click focuses the panel whose column it lands in — for
930    /// every panel, in any prior focus state.
931    #[tokio::test]
932    async fn click_focuses_panel_under_cursor() {
933        let mut panels = make_panel_set().await;
934        lay_out(&mut panels);
935        let (tx, _rx) = unbounded_channel();
936
937        assert_eq!(panels.focused(), PanelKind::Editor);
938        // Rail (0..7) | Drawer (7..41) | Editor (41..120).
939        assert_eq!(
940            panels.handle_mouse(&mouse_down_at(3, 5), &tx),
941            EventState::Consumed
942        );
943        assert_eq!(panels.focused(), PanelKind::Rail);
944        assert_eq!(
945            panels.handle_mouse(&mouse_down_at(20, 5), &tx),
946            EventState::Consumed
947        );
948        assert_eq!(panels.focused(), PanelKind::Drawer);
949        // Regression: a click on the editor must focus it even while another
950        // panel is focused.
951        assert_eq!(
952            panels.handle_mouse(&mouse_down_at(60, 5), &tx),
953            EventState::Consumed
954        );
955        assert_eq!(panels.focused(), PanelKind::Editor);
956    }
957
958    /// Before the first render no rects exist, and clicks outside every
959    /// column must not move focus.
960    #[tokio::test]
961    async fn click_outside_panels_changes_nothing() {
962        let mut panels = make_panel_set().await;
963        let (tx, _rx) = unbounded_channel();
964
965        // No render yet → no rects → nothing to hit.
966        assert_eq!(
967            panels.handle_mouse(&mouse_down_at(10, 10), &tx),
968            EventState::NotConsumed
969        );
970        assert_eq!(panels.focused(), PanelKind::Editor);
971
972        lay_out(&mut panels);
973        // Cells outside the laid-out area miss.
974        assert_eq!(
975            panels.handle_mouse(&mouse_down_at(10, 50), &tx),
976            EventState::NotConsumed
977        );
978        assert_eq!(panels.focused(), PanelKind::Editor);
979    }
980
981    /// Scrolling routes to the panel under the cursor without stealing focus.
982    #[tokio::test]
983    async fn scroll_does_not_change_focus() {
984        let mut panels = make_panel_set().await;
985        lay_out(&mut panels);
986        let (tx, _rx) = unbounded_channel();
987
988        assert_eq!(
989            panels.handle_mouse(&scroll_at(20, 5), &tx),
990            EventState::Consumed
991        );
992        assert_eq!(panels.focused(), PanelKind::Editor);
993    }
994
995    /// Dragging the drawer↔editor divider resizes the drawer, clamped to the
996    /// minimum widths on both sides.
997    #[tokio::test]
998    async fn divider_drag_resizes_drawer() {
999        let mut panels = make_panel_set().await;
1000        lay_out(&mut panels);
1001        let (tx, _rx) = unbounded_channel();
1002
1003        // Drawer occupies 7..41; the divider is its right border column (40).
1004        assert_eq!(
1005            panels.handle_mouse(&mouse_down_at(40, 5), &tx),
1006            EventState::Consumed
1007        );
1008        assert_eq!(
1009            panels.handle_mouse(&drag_at(60, 5), &tx),
1010            EventState::Consumed
1011        );
1012        assert_eq!(panels.drawer_width, 54); // 60 - 7 + 1
1013        // Dragging far left clamps to the minimum drawer width.
1014        assert_eq!(
1015            panels.handle_mouse(&drag_at(0, 5), &tx),
1016            EventState::Consumed
1017        );
1018        assert_eq!(panels.drawer_width, MIN_DRAWER_WIDTH);
1019        // Dragging far right clamps so the editor keeps its minimum.
1020        assert_eq!(
1021            panels.handle_mouse(&drag_at(200, 5), &tx),
1022            EventState::Consumed
1023        );
1024        assert_eq!(panels.drawer_width, 120 - 7 - MIN_EDITOR_WIDTH);
1025        // Release ends the drag: further drags are plain panel events.
1026        assert_eq!(
1027            panels.handle_mouse(&up_at(80, 5), &tx),
1028            EventState::Consumed
1029        );
1030        let width_before = panels.drawer_width;
1031        panels.handle_mouse(&drag_at(50, 5), &tx);
1032        assert_eq!(panels.drawer_width, width_before);
1033    }
1034
1035    #[test]
1036    fn default_focus_is_editor() {
1037        let order = PanelOrder::new();
1038        assert_eq!(order.focused(), PanelKind::Editor);
1039    }
1040
1041    #[test]
1042    fn focus_cycle_wraps_over_visible_panels() {
1043        let mut order = PanelOrder::new();
1044        // Focus = editor (right end); next wraps to the rail.
1045        assert_eq!(order.next_kind(), Some(PanelKind::Rail));
1046        assert_eq!(order.prev_kind(), Some(PanelKind::Drawer));
1047
1048        order.focus(PanelKind::Rail);
1049        assert_eq!(order.prev_kind(), Some(PanelKind::Editor)); // wrap left
1050        assert_eq!(order.next_kind(), Some(PanelKind::Drawer));
1051    }
1052
1053    #[test]
1054    fn focus_cycle_skips_hidden_drawer() {
1055        let mut order = PanelOrder::new();
1056        order.hide(PanelKind::Drawer);
1057        assert_eq!(order.next_kind(), Some(PanelKind::Rail));
1058        order.focus(PanelKind::Rail);
1059        assert_eq!(order.next_kind(), Some(PanelKind::Editor));
1060    }
1061
1062    #[test]
1063    fn show_hide_toggles_visibility_except_rail_and_editor() {
1064        let mut order = PanelOrder::new();
1065        assert!(order.is_visible(PanelKind::Drawer));
1066
1067        order.hide(PanelKind::Drawer);
1068        assert!(!order.is_visible(PanelKind::Drawer));
1069        order.show(PanelKind::Drawer);
1070        assert!(order.is_visible(PanelKind::Drawer));
1071
1072        // The rail and the editor cannot be hidden.
1073        order.hide(PanelKind::Editor);
1074        assert!(order.is_visible(PanelKind::Editor));
1075        order.hide(PanelKind::Rail);
1076        assert!(order.is_visible(PanelKind::Rail));
1077    }
1078
1079    #[test]
1080    fn hiding_focused_panel_moves_focus_to_visible() {
1081        let mut order = PanelOrder::new();
1082        order.focus(PanelKind::Drawer);
1083        order.hide(PanelKind::Drawer);
1084        // Focus cannot stay on a hidden panel.
1085        assert!(order.is_visible(order.focused()));
1086    }
1087
1088    #[test]
1089    fn layout_columns_splits_area_in_panel_order() {
1090        let area = Rect::new(0, 0, 120, 40);
1091        let visible = [PanelKind::Rail, PanelKind::Drawer, PanelKind::Editor];
1092        let columns = layout_columns(&visible, area, DEFAULT_DRAWER_WIDTH);
1093
1094        assert_eq!(columns.len(), 3);
1095        // Rail fixed, drawer at its width, editor takes the rest.
1096        assert_eq!(columns[0].0, PanelKind::Rail);
1097        assert_eq!(columns[0].1.width, RAIL_WIDTH);
1098        assert_eq!(columns[1].0, PanelKind::Drawer);
1099        assert_eq!(columns[1].1.width, DEFAULT_DRAWER_WIDTH);
1100        assert_eq!(columns[2].0, PanelKind::Editor);
1101        assert_eq!(columns[2].1.width, 120 - RAIL_WIDTH - DEFAULT_DRAWER_WIDTH);
1102        // Columns tile the area left→right.
1103        assert_eq!(columns[0].1.x, 0);
1104        assert_eq!(columns[1].1.x, RAIL_WIDTH);
1105        assert_eq!(columns[2].1.x, RAIL_WIDTH + DEFAULT_DRAWER_WIDTH);
1106    }
1107
1108    #[test]
1109    fn hidden_drawer_gives_width_to_editor() {
1110        let area = Rect::new(0, 0, 120, 40);
1111        let visible = [PanelKind::Rail, PanelKind::Editor];
1112        let columns = layout_columns(&visible, area, DEFAULT_DRAWER_WIDTH);
1113
1114        assert_eq!(columns.len(), 2);
1115        assert_eq!(columns[1].0, PanelKind::Editor);
1116        assert_eq!(columns[1].1.width, 120 - RAIL_WIDTH);
1117    }
1118
1119    #[test]
1120    fn kind_at_hit_tests_panel_columns() {
1121        let area = Rect::new(0, 0, 120, 40);
1122        let visible = [PanelKind::Rail, PanelKind::Drawer, PanelKind::Editor];
1123        let columns = layout_columns(&visible, area, DEFAULT_DRAWER_WIDTH);
1124
1125        assert_eq!(kind_at(&columns, 0, 0), Some(PanelKind::Rail));
1126        assert_eq!(kind_at(&columns, 6, 10), Some(PanelKind::Rail));
1127        assert_eq!(kind_at(&columns, 7, 10), Some(PanelKind::Drawer));
1128        assert_eq!(kind_at(&columns, 40, 39), Some(PanelKind::Drawer));
1129        assert_eq!(kind_at(&columns, 41, 0), Some(PanelKind::Editor));
1130        assert_eq!(kind_at(&columns, 119, 39), Some(PanelKind::Editor));
1131        // Outside the laid-out area.
1132        assert_eq!(kind_at(&columns, 120, 10), None);
1133        assert_eq!(kind_at(&columns, 10, 40), None);
1134        // No columns yet (before the first render).
1135        assert_eq!(kind_at(&[], 10, 10), None);
1136    }
1137
1138    #[test]
1139    fn open_drawer_view_reveals_and_switches() {
1140        let mut order = PanelOrder::new();
1141        order.hide(PanelKind::Drawer);
1142        assert!(!order.is_visible(PanelKind::Drawer));
1143        order.show(PanelKind::Drawer);
1144        assert!(order.is_visible(PanelKind::Drawer));
1145    }
1146
1147    fn test_attachment_view() -> AttachmentView {
1148        let details = kimun_core::AttachmentDetails {
1149            path: kimun_core::nfs::VaultPath::new("a.png"),
1150            size: 0,
1151            modified_secs: 0,
1152            extension: Some("png".to_string()),
1153            content: kimun_core::AttachmentContent::Binary,
1154        };
1155        AttachmentView::new(
1156            details,
1157            crate::settings::icons::Icons::new(false),
1158            crate::keys::KeyBindings::empty(),
1159        )
1160    }
1161
1162    #[tokio::test]
1163    async fn ask_content_hides_the_editor_and_leaving_restores_it() {
1164        let mut ps = make_panel_set().await;
1165        assert!(ps.editor().is_some());
1166        ps.show_ask();
1167        assert!(ps.editor().is_none());
1168        assert!(ps.is_showing_ask());
1169        // Leaving Ask returns the note editor; the resident thread survives.
1170        ps.hide_ask();
1171        assert!(!ps.is_showing_ask());
1172        assert!(ps.editor().is_some());
1173    }
1174
1175    /// `set_ask_client` is the single injection point: it drives the resident
1176    /// Ask panel's client, which is its composer-enabled signal. No client ⇒
1177    /// disabled (never a forever-`Thinking` turn); a present client ⇒ enabled.
1178    #[tokio::test]
1179    async fn set_ask_client_drives_the_resident_panels_client() {
1180        let mut ps = make_panel_set().await;
1181        ps.set_ask_client(None);
1182        assert!(!ps.ask().has_client());
1183        // Handing a client back enables it. (A bare client over a throwaway
1184        // vault id is fine — it's never called here.)
1185        let client = std::sync::Arc::new(crate::server_client::RagClient::new(
1186            "http://localhost:0".to_string(),
1187            None,
1188            "vault".to_string(),
1189        ));
1190        ps.set_ask_client(Some(client));
1191        assert!(ps.ask().has_client());
1192    }
1193
1194    #[tokio::test]
1195    async fn showing_an_attachment_replaces_ask() {
1196        let mut ps = make_panel_set().await;
1197        ps.show_ask();
1198        ps.show_attachment(test_attachment_view());
1199        assert!(!ps.is_showing_ask());
1200        assert!(ps.is_showing_attachment());
1201    }
1202
1203    #[test]
1204    fn visible_in_order_skips_hidden() {
1205        let mut order = PanelOrder::new();
1206        assert_eq!(
1207            order.visible_in_order(),
1208            vec![PanelKind::Rail, PanelKind::Drawer, PanelKind::Editor]
1209        );
1210        order.hide(PanelKind::Drawer);
1211        assert_eq!(
1212            order.visible_in_order(),
1213            vec![PanelKind::Rail, PanelKind::Editor]
1214        );
1215    }
1216}