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    /// Bring the OUTLINE in step with the editor buffer for `note` — empty
519    /// while no note editor is shown. Parses only when the buffer revision
520    /// moved; see [`OutlinePanel::sync`].
521    ///
522    /// [`OutlinePanel::sync`]: crate::components::drawer_views::OutlinePanel::sync
523    pub fn sync_outline(&mut self, note: &kimun_core::nfs::VaultPath, tx: &AppTx) {
524        let editor = matches!(self.content, EditorAreaContent::Note).then_some(&self.editor);
525        let revision = editor.map(|e| e.content_revision());
526        self.drawer.outline_mut().sync(
527            note,
528            revision,
529            || editor.map(|e| e.get_text()).unwrap_or_default(),
530            tx,
531        );
532    }
533    pub fn drawer_set_config_info(&mut self, info: crate::components::drawer::ConfigInfo) {
534        self.drawer.set_config_info(info);
535    }
536
537    // ── Routing ────────────────────────────────────────────────────────────
538
539    /// Footer hints for the focused panel.
540    pub fn focused_hints(&self) -> Vec<(String, String)> {
541        match self.order.focused() {
542            PanelKind::Rail => self.rail.hint_shortcuts(),
543            PanelKind::Drawer => self.drawer.hint_shortcuts(),
544            PanelKind::Editor => self.editor_area().hint_shortcuts(),
545        }
546    }
547
548    /// Route an input event to the focused panel.
549    pub fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
550        match self.order.focused() {
551            PanelKind::Rail => self.rail.handle_input(event, tx),
552            PanelKind::Drawer => self.drawer.handle_input(event, tx),
553            PanelKind::Editor => self.editor_area_handle_input(event, tx),
554        }
555    }
556
557    /// Deliver an input event to the editor-area content. The Ask arm calls
558    /// the resident panel's inherent `ThreadPanel::handle_input` (it derives
559    /// submission from its own client); the others use the trait. Each arm
560    /// borrows a distinct field, disjoint from `content`.
561    fn editor_area_handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
562        match &mut self.content {
563            EditorAreaContent::Ask => self.ask.handle_input(event, tx),
564            EditorAreaContent::Attachment(view) => view.handle_input(event, tx),
565            EditorAreaContent::Note => self.editor.handle_input(event, tx),
566        }
567    }
568
569    /// Whether a press on this cell points *at* somewhere in the note buffer.
570    ///
571    /// Three conditions, and only the first is about geometry:
572    ///
573    /// - **Inside the editor component itself.** Asked of the component, not of
574    ///   `Self::column_rects`, because the column is wider than the buffer: it
575    ///   also holds the frame `render` draws around the component (and, to its
576    ///   left, the divider that [`Self::handle_mouse`] claims before any panel
577    ///   sees it). A press on either is out of the component's bounds, so it
578    ///   returns `NotConsumed` and the cursor never moves.
579    /// - **The editor area is showing the note.** It may instead hold an
580    ///   attachment preview or the Ask workspace, neither of which has a cursor
581    ///   or a link under the pointer — an attachment would launch an external
582    ///   program and Ask would swallow the press.
583    /// - **The mouse drives the cursor.** False under the **nvim** backend,
584    ///   where a press never moves the cursor at all, so anything reading the
585    ///   cursor afterwards reads wherever the *keyboard* left it.
586    ///
587    /// Every one of them is the same question asked of a different layer: did
588    /// this press *place the cursor*? Anything that answers a press by reading
589    /// the cursor — following a link, most obviously — has to ask all three, or
590    /// it acts on a position the press never set. The column alone was the
591    /// original gate, and each of the others was a defect before it was a
592    /// condition. Callers that only want the column should not reuse this.
593    pub fn is_note_buffer_cell(&self, column: u16, row: u16) -> bool {
594        matches!(self.content, EditorAreaContent::Note)
595            && self.editor.mouse_drives_cursor()
596            && self.editor.covers(column, row)
597    }
598
599    /// Whether a mouse event at this cell belongs to the editor column — the
600    /// region an **editor claim** may own. False mid divider-drag, so a drag
601    /// that sweeps across the editor keeps resizing the drawer.
602    pub fn pointer_on_editor(&self, column: u16, row: u16) -> bool {
603        !self.dragging_divider
604            && kind_at(&self.column_rects, column, row) == Some(PanelKind::Editor)
605    }
606
607    /// The divider hit zone: the drawer's right border column (the cell
608    /// between drawer content and editor).
609    fn on_divider(&self, column: u16, row: u16) -> bool {
610        self.column_rects
611            .iter()
612            .find(|(kind, _)| *kind == PanelKind::Drawer)
613            .is_some_and(|(_, rect)| {
614                rect.height > 0
615                    && column == rect.right().saturating_sub(1)
616                    && row >= rect.y
617                    && row < rect.bottom()
618            })
619    }
620
621    /// Apply a divider drag: the drawer's new width follows the cursor,
622    /// clamped so both the drawer and the editor keep a usable minimum.
623    fn drag_divider_to(&mut self, column: u16) {
624        let Some((_, drawer_rect)) = self
625            .column_rects
626            .iter()
627            .find(|(kind, _)| *kind == PanelKind::Drawer)
628        else {
629            return;
630        };
631        let total_right = self
632            .column_rects
633            .iter()
634            .map(|(_, r)| r.right())
635            .max()
636            .unwrap_or(drawer_rect.right());
637        let max_width = total_right
638            .saturating_sub(drawer_rect.x)
639            .saturating_sub(MIN_EDITOR_WIDTH);
640        let new_width = column.saturating_sub(drawer_rect.x).saturating_add(1);
641        self.drawer_width = new_width.clamp(MIN_DRAWER_WIDTH, max_width.max(MIN_DRAWER_WIDTH));
642    }
643
644    /// Route a mouse event by hit-testing the panel columns from the last
645    /// render. Dragging the drawer↔editor divider resizes the drawer. A
646    /// button-down click focuses the panel under the cursor — the one
647    /// consistent click-to-focus rule for every panel — and the event is
648    /// then forwarded to that panel for its internal behavior (cursor
649    /// placement, list selection, scrolling). Events outside every column
650    /// (or before the first render) are not consumed.
651    pub fn handle_mouse(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
652        let InputEvent::Mouse(mouse) = event else {
653            return EventState::NotConsumed;
654        };
655
656        // Divider drag lifecycle.
657        match mouse.kind {
658            MouseEventKind::Down(MouseButton::Left) if self.on_divider(mouse.column, mouse.row) => {
659                self.dragging_divider = true;
660                return EventState::Consumed;
661            }
662            MouseEventKind::Drag(MouseButton::Left) if self.dragging_divider => {
663                self.drag_divider_to(mouse.column);
664                return EventState::Consumed;
665            }
666            MouseEventKind::Up(MouseButton::Left) if self.dragging_divider => {
667                self.dragging_divider = false;
668                return EventState::Consumed;
669            }
670            _ => {}
671        }
672
673        let Some(kind) = kind_at(&self.column_rects, mouse.column, mouse.row) else {
674            return EventState::NotConsumed;
675        };
676        if matches!(mouse.kind, MouseEventKind::Down(_)) {
677            self.focus(kind);
678        }
679        match kind {
680            PanelKind::Rail => {
681                self.rail.handle_input(event, tx);
682            }
683            PanelKind::Drawer => {
684                self.drawer.handle_mouse(event, tx);
685            }
686            PanelKind::Editor => {
687                self.editor_area_handle_input(event, tx);
688            }
689        }
690        EventState::Consumed
691    }
692
693    /// Lay the visible panels out left→right and render each. `show_focus`
694    /// is false while an overlay is open, so no panel draws its focused
695    /// highlight under the overlay.
696    pub fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme, show_focus: bool) {
697        let visible = self.order.visible_in_order();
698        if visible.is_empty() {
699            return;
700        }
701        let columns = layout_columns(&visible, area, self.drawer_width);
702        self.column_rects = columns.clone();
703
704        let focused = self.order.focused();
705        let drawer_view = self
706            .is_visible(PanelKind::Drawer)
707            .then(|| self.drawer.active_view());
708        for (kind, rect) in &columns {
709            let is_focused = show_focus && *kind == focused;
710            let rect = *rect;
711            match kind {
712                PanelKind::Rail => self.rail.render(f, rect, theme, is_focused, drawer_view),
713                PanelKind::Drawer => self.drawer.render(f, rect, theme, is_focused),
714                PanelKind::Editor => {
715                    // The editor area's frame is drawn here (not by the
716                    // component) so the dirty marker and focus border live with
717                    // the layout. The frame title reflects which content the
718                    // area is showing — the note editor, an attachment, or Ask.
719                    match &mut self.content {
720                        EditorAreaContent::Attachment(view) => {
721                            let block = panel_block("Attachment", theme, is_focused);
722                            let inner = block.inner(rect);
723                            f.render_widget(block, rect);
724                            view.render(f, inner, theme, is_focused);
725                        }
726                        EditorAreaContent::Ask => {
727                            let block = panel_block("Ask", theme, is_focused);
728                            let inner = block.inner(rect);
729                            f.render_widget(block, rect);
730                            self.ask.render(f, inner, theme, is_focused);
731                        }
732                        EditorAreaContent::Note => {
733                            let title = if self.editor.is_dirty() {
734                                "Editor [+]"
735                            } else {
736                                "Editor"
737                            };
738                            let block = panel_block(title, theme, is_focused);
739                            let inner = block.inner(rect);
740                            f.render_widget(block, rect);
741                            self.editor.render(f, inner, theme, is_focused);
742                        }
743                    }
744                }
745            }
746        }
747    }
748}
749
750#[cfg(test)]
751mod tests {
752    use super::*;
753    use crate::settings::AppSettings;
754    use crate::test_support::{mouse_down_at, temp_vault};
755    use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
756    use tokio::sync::mpsc::unbounded_channel;
757
758    async fn make_panel_set() -> PanelSet {
759        let vault = temp_vault("panelset").await;
760        vault.validate_and_init().await.unwrap();
761        let settings = AppSettings::default();
762        let sidebar = SidebarComponent::new(
763            settings.key_bindings.clone(),
764            vault.clone(),
765            settings.icons(),
766            &settings,
767        );
768        let editor = TextEditorComponent::new(settings.key_bindings.clone(), &settings);
769        let query = QueryPanel::new(
770            vault.clone(),
771            settings.key_bindings.clone(),
772            settings.icons(),
773        );
774        let tags = crate::components::drawer_views::TagsPanel::new(
775            vault.clone(),
776            settings.icons(),
777            settings.yank_combos(),
778        );
779        let links = crate::components::drawer_views::LinksPanel::new(
780            vault.clone(),
781            settings.icons(),
782            settings.yank_combos(),
783        );
784        let semantic = crate::components::semantic_search::SemanticPanel::new(
785            vault.clone(),
786            std::sync::Arc::new(std::sync::RwLock::new(settings.clone())),
787            settings.icons(),
788            settings.yank_combos(),
789        );
790        let outline = crate::components::drawer_views::OutlinePanel::new(
791            settings.icons(),
792            settings.yank_combos(),
793        );
794        let drawer = DrawerHost::new(
795            vault,
796            &settings.key_bindings,
797            sidebar,
798            query,
799            semantic,
800            tags,
801            links,
802            outline,
803        );
804        PanelSet::from_panels(
805            drawer,
806            editor,
807            settings.icons(),
808            settings.key_bindings,
809            RailCaps {
810                semantic: true,
811                ask: true,
812            },
813        )
814    }
815
816    /// Lay the visible panels out over a fixed area by actually rendering them.
817    ///
818    /// Assigning `column_rects` directly would be shorter and wrong: the hit
819    /// tests read the editor component's own rect too, and only `render`
820    /// assigns that. A layout that sets one and not the other describes a frame
821    /// that never existed.
822    fn lay_out(panels: &mut PanelSet) {
823        let area = Rect::new(0, 0, 120, 40);
824        let theme = crate::settings::themes::Theme::default();
825        let mut term =
826            ratatui::Terminal::new(ratatui::backend::TestBackend::new(area.width, area.height))
827                .unwrap();
828        term.draw(|f| panels.render(f, area, &theme, true)).unwrap();
829    }
830
831    /// A cell in the middle of the editor column, as laid out.
832    fn editor_cell(panels: &PanelSet) -> (u16, u16) {
833        let (_, rect) = panels
834            .column_rects
835            .iter()
836            .find(|(kind, _)| *kind == PanelKind::Editor)
837            .expect("the editor is always visible");
838        (rect.x + rect.width / 2, rect.y + rect.height / 2)
839    }
840
841    /// The gate is not merely geometric. It read as "the editor column" once,
842    /// and a double-click in the Ask workspace was swallowed while one in an
843    /// attachment preview spawned an external viewer — the same cell, three
844    /// different things underneath it.
845    #[tokio::test]
846    async fn the_note_buffer_gate_tracks_what_the_editor_area_shows() {
847        let mut panels = make_panel_set().await;
848        lay_out(&mut panels);
849        let (col, row) = editor_cell(&panels);
850        assert!(
851            panels.is_note_buffer_cell(col, row),
852            "the note buffer is what the gate is for"
853        );
854
855        panels.show_ask();
856        assert!(
857            !panels.is_note_buffer_cell(col, row),
858            "the Ask workspace has no cursor to point at"
859        );
860
861        panels.hide_ask();
862        assert!(panels.is_note_buffer_cell(col, row), "and back again");
863    }
864
865    /// A divider press is claimed before any panel sees it and never changes
866    /// focus, so it must not count as pointing at the buffer either.
867    #[tokio::test]
868    async fn the_note_buffer_gate_rejects_the_divider_and_other_columns() {
869        let mut panels = make_panel_set().await;
870        lay_out(&mut panels);
871        let (_, drawer) = panels
872            .column_rects
873            .iter()
874            .find(|(kind, _)| *kind == PanelKind::Drawer)
875            .expect("the drawer is visible by default");
876        let (divider_col, row) = (drawer.right().saturating_sub(1), drawer.y + 1);
877        assert!(panels.on_divider(divider_col, row), "precondition");
878        assert!(!panels.is_note_buffer_cell(divider_col, row));
879        assert!(
880            !panels.is_note_buffer_cell(drawer.x + 1, row),
881            "and the drawer is not the editor"
882        );
883    }
884
885    /// The editor *column* is not the editor *buffer*: `PanelSet::render` draws
886    /// a bordered frame around the component and hands it only the interior.
887    /// A press on the frame is out of the component's bounds, so it returns
888    /// `NotConsumed` and the cursor never moves — counting those cells let two
889    /// clicks on the `─ Editor` title bar follow whichever link the cursor was
890    /// parked on, a note the user never pointed at.
891    #[tokio::test]
892    async fn the_note_buffer_gate_rejects_the_editor_frame() {
893        let mut panels = make_panel_set().await;
894        lay_out(&mut panels);
895        let (_, rect) = panels
896            .column_rects
897            .iter()
898            .find(|(kind, _)| *kind == PanelKind::Editor)
899            .expect("the editor is always visible");
900        let rect = *rect;
901        let (mid_col, mid_row) = (rect.x + rect.width / 2, rect.y + rect.height / 2);
902        assert!(
903            panels.is_note_buffer_cell(mid_col, mid_row),
904            "precondition: the interior still counts"
905        );
906        assert!(
907            !panels.is_note_buffer_cell(mid_col, rect.y),
908            "the title row is frame"
909        );
910        assert!(
911            !panels.is_note_buffer_cell(mid_col, rect.bottom() - 1),
912            "so is the bottom border"
913        );
914        assert!(
915            !panels.is_note_buffer_cell(rect.x, mid_row),
916            "so is the left border"
917        );
918        assert!(
919            !panels.is_note_buffer_cell(rect.right() - 1, mid_row),
920            "so is the right border"
921        );
922    }
923
924    fn scroll_at(col: u16, row: u16) -> InputEvent {
925        InputEvent::Mouse(MouseEvent {
926            kind: MouseEventKind::ScrollDown,
927            column: col,
928            row,
929            modifiers: KeyModifiers::NONE,
930        })
931    }
932
933    fn drag_at(col: u16, row: u16) -> InputEvent {
934        InputEvent::Mouse(MouseEvent {
935            kind: MouseEventKind::Drag(MouseButton::Left),
936            column: col,
937            row,
938            modifiers: KeyModifiers::NONE,
939        })
940    }
941
942    fn up_at(col: u16, row: u16) -> InputEvent {
943        InputEvent::Mouse(MouseEvent {
944            kind: MouseEventKind::Up(MouseButton::Left),
945            column: col,
946            row,
947            modifiers: KeyModifiers::NONE,
948        })
949    }
950
951    /// A button-down click focuses the panel whose column it lands in — for
952    /// every panel, in any prior focus state.
953    #[tokio::test]
954    async fn click_focuses_panel_under_cursor() {
955        let mut panels = make_panel_set().await;
956        lay_out(&mut panels);
957        let (tx, _rx) = unbounded_channel();
958
959        assert_eq!(panels.focused(), PanelKind::Editor);
960        // Rail (0..7) | Drawer (7..41) | Editor (41..120).
961        assert_eq!(
962            panels.handle_mouse(&mouse_down_at(3, 5), &tx),
963            EventState::Consumed
964        );
965        assert_eq!(panels.focused(), PanelKind::Rail);
966        assert_eq!(
967            panels.handle_mouse(&mouse_down_at(20, 5), &tx),
968            EventState::Consumed
969        );
970        assert_eq!(panels.focused(), PanelKind::Drawer);
971        // Regression: a click on the editor must focus it even while another
972        // panel is focused.
973        assert_eq!(
974            panels.handle_mouse(&mouse_down_at(60, 5), &tx),
975            EventState::Consumed
976        );
977        assert_eq!(panels.focused(), PanelKind::Editor);
978    }
979
980    /// Before the first render no rects exist, and clicks outside every
981    /// column must not move focus.
982    #[tokio::test]
983    async fn click_outside_panels_changes_nothing() {
984        let mut panels = make_panel_set().await;
985        let (tx, _rx) = unbounded_channel();
986
987        // No render yet → no rects → nothing to hit.
988        assert_eq!(
989            panels.handle_mouse(&mouse_down_at(10, 10), &tx),
990            EventState::NotConsumed
991        );
992        assert_eq!(panels.focused(), PanelKind::Editor);
993
994        lay_out(&mut panels);
995        // Cells outside the laid-out area miss.
996        assert_eq!(
997            panels.handle_mouse(&mouse_down_at(10, 50), &tx),
998            EventState::NotConsumed
999        );
1000        assert_eq!(panels.focused(), PanelKind::Editor);
1001    }
1002
1003    /// Scrolling routes to the panel under the cursor without stealing focus.
1004    #[tokio::test]
1005    async fn scroll_does_not_change_focus() {
1006        let mut panels = make_panel_set().await;
1007        lay_out(&mut panels);
1008        let (tx, _rx) = unbounded_channel();
1009
1010        assert_eq!(
1011            panels.handle_mouse(&scroll_at(20, 5), &tx),
1012            EventState::Consumed
1013        );
1014        assert_eq!(panels.focused(), PanelKind::Editor);
1015    }
1016
1017    /// Dragging the drawer↔editor divider resizes the drawer, clamped to the
1018    /// minimum widths on both sides.
1019    #[tokio::test]
1020    async fn divider_drag_resizes_drawer() {
1021        let mut panels = make_panel_set().await;
1022        lay_out(&mut panels);
1023        let (tx, _rx) = unbounded_channel();
1024
1025        // Drawer occupies 7..41; the divider is its right border column (40).
1026        assert_eq!(
1027            panels.handle_mouse(&mouse_down_at(40, 5), &tx),
1028            EventState::Consumed
1029        );
1030        assert_eq!(
1031            panels.handle_mouse(&drag_at(60, 5), &tx),
1032            EventState::Consumed
1033        );
1034        assert_eq!(panels.drawer_width, 54); // 60 - 7 + 1
1035        // Dragging far left clamps to the minimum drawer width.
1036        assert_eq!(
1037            panels.handle_mouse(&drag_at(0, 5), &tx),
1038            EventState::Consumed
1039        );
1040        assert_eq!(panels.drawer_width, MIN_DRAWER_WIDTH);
1041        // Dragging far right clamps so the editor keeps its minimum.
1042        assert_eq!(
1043            panels.handle_mouse(&drag_at(200, 5), &tx),
1044            EventState::Consumed
1045        );
1046        assert_eq!(panels.drawer_width, 120 - 7 - MIN_EDITOR_WIDTH);
1047        // Release ends the drag: further drags are plain panel events.
1048        assert_eq!(
1049            panels.handle_mouse(&up_at(80, 5), &tx),
1050            EventState::Consumed
1051        );
1052        let width_before = panels.drawer_width;
1053        panels.handle_mouse(&drag_at(50, 5), &tx);
1054        assert_eq!(panels.drawer_width, width_before);
1055    }
1056
1057    #[test]
1058    fn default_focus_is_editor() {
1059        let order = PanelOrder::new();
1060        assert_eq!(order.focused(), PanelKind::Editor);
1061    }
1062
1063    #[test]
1064    fn focus_cycle_wraps_over_visible_panels() {
1065        let mut order = PanelOrder::new();
1066        // Focus = editor (right end); next wraps to the rail.
1067        assert_eq!(order.next_kind(), Some(PanelKind::Rail));
1068        assert_eq!(order.prev_kind(), Some(PanelKind::Drawer));
1069
1070        order.focus(PanelKind::Rail);
1071        assert_eq!(order.prev_kind(), Some(PanelKind::Editor)); // wrap left
1072        assert_eq!(order.next_kind(), Some(PanelKind::Drawer));
1073    }
1074
1075    #[test]
1076    fn focus_cycle_skips_hidden_drawer() {
1077        let mut order = PanelOrder::new();
1078        order.hide(PanelKind::Drawer);
1079        assert_eq!(order.next_kind(), Some(PanelKind::Rail));
1080        order.focus(PanelKind::Rail);
1081        assert_eq!(order.next_kind(), Some(PanelKind::Editor));
1082    }
1083
1084    #[test]
1085    fn show_hide_toggles_visibility_except_rail_and_editor() {
1086        let mut order = PanelOrder::new();
1087        assert!(order.is_visible(PanelKind::Drawer));
1088
1089        order.hide(PanelKind::Drawer);
1090        assert!(!order.is_visible(PanelKind::Drawer));
1091        order.show(PanelKind::Drawer);
1092        assert!(order.is_visible(PanelKind::Drawer));
1093
1094        // The rail and the editor cannot be hidden.
1095        order.hide(PanelKind::Editor);
1096        assert!(order.is_visible(PanelKind::Editor));
1097        order.hide(PanelKind::Rail);
1098        assert!(order.is_visible(PanelKind::Rail));
1099    }
1100
1101    #[test]
1102    fn hiding_focused_panel_moves_focus_to_visible() {
1103        let mut order = PanelOrder::new();
1104        order.focus(PanelKind::Drawer);
1105        order.hide(PanelKind::Drawer);
1106        // Focus cannot stay on a hidden panel.
1107        assert!(order.is_visible(order.focused()));
1108    }
1109
1110    #[test]
1111    fn layout_columns_splits_area_in_panel_order() {
1112        let area = Rect::new(0, 0, 120, 40);
1113        let visible = [PanelKind::Rail, PanelKind::Drawer, PanelKind::Editor];
1114        let columns = layout_columns(&visible, area, DEFAULT_DRAWER_WIDTH);
1115
1116        assert_eq!(columns.len(), 3);
1117        // Rail fixed, drawer at its width, editor takes the rest.
1118        assert_eq!(columns[0].0, PanelKind::Rail);
1119        assert_eq!(columns[0].1.width, RAIL_WIDTH);
1120        assert_eq!(columns[1].0, PanelKind::Drawer);
1121        assert_eq!(columns[1].1.width, DEFAULT_DRAWER_WIDTH);
1122        assert_eq!(columns[2].0, PanelKind::Editor);
1123        assert_eq!(columns[2].1.width, 120 - RAIL_WIDTH - DEFAULT_DRAWER_WIDTH);
1124        // Columns tile the area left→right.
1125        assert_eq!(columns[0].1.x, 0);
1126        assert_eq!(columns[1].1.x, RAIL_WIDTH);
1127        assert_eq!(columns[2].1.x, RAIL_WIDTH + DEFAULT_DRAWER_WIDTH);
1128    }
1129
1130    #[test]
1131    fn hidden_drawer_gives_width_to_editor() {
1132        let area = Rect::new(0, 0, 120, 40);
1133        let visible = [PanelKind::Rail, PanelKind::Editor];
1134        let columns = layout_columns(&visible, area, DEFAULT_DRAWER_WIDTH);
1135
1136        assert_eq!(columns.len(), 2);
1137        assert_eq!(columns[1].0, PanelKind::Editor);
1138        assert_eq!(columns[1].1.width, 120 - RAIL_WIDTH);
1139    }
1140
1141    #[test]
1142    fn kind_at_hit_tests_panel_columns() {
1143        let area = Rect::new(0, 0, 120, 40);
1144        let visible = [PanelKind::Rail, PanelKind::Drawer, PanelKind::Editor];
1145        let columns = layout_columns(&visible, area, DEFAULT_DRAWER_WIDTH);
1146
1147        assert_eq!(kind_at(&columns, 0, 0), Some(PanelKind::Rail));
1148        assert_eq!(kind_at(&columns, 6, 10), Some(PanelKind::Rail));
1149        assert_eq!(kind_at(&columns, 7, 10), Some(PanelKind::Drawer));
1150        assert_eq!(kind_at(&columns, 40, 39), Some(PanelKind::Drawer));
1151        assert_eq!(kind_at(&columns, 41, 0), Some(PanelKind::Editor));
1152        assert_eq!(kind_at(&columns, 119, 39), Some(PanelKind::Editor));
1153        // Outside the laid-out area.
1154        assert_eq!(kind_at(&columns, 120, 10), None);
1155        assert_eq!(kind_at(&columns, 10, 40), None);
1156        // No columns yet (before the first render).
1157        assert_eq!(kind_at(&[], 10, 10), None);
1158    }
1159
1160    #[test]
1161    fn open_drawer_view_reveals_and_switches() {
1162        let mut order = PanelOrder::new();
1163        order.hide(PanelKind::Drawer);
1164        assert!(!order.is_visible(PanelKind::Drawer));
1165        order.show(PanelKind::Drawer);
1166        assert!(order.is_visible(PanelKind::Drawer));
1167    }
1168
1169    fn test_attachment_view() -> AttachmentView {
1170        let details = kimun_core::AttachmentDetails {
1171            path: kimun_core::nfs::VaultPath::new("a.png"),
1172            size: 0,
1173            modified_secs: 0,
1174            extension: Some("png".to_string()),
1175            content: kimun_core::AttachmentContent::Binary,
1176        };
1177        AttachmentView::new(
1178            details,
1179            crate::settings::icons::Icons::new(false),
1180            crate::keys::KeyBindings::empty(),
1181        )
1182    }
1183
1184    #[tokio::test]
1185    async fn ask_content_hides_the_editor_and_leaving_restores_it() {
1186        let mut ps = make_panel_set().await;
1187        assert!(ps.editor().is_some());
1188        ps.show_ask();
1189        assert!(ps.editor().is_none());
1190        assert!(ps.is_showing_ask());
1191        // Leaving Ask returns the note editor; the resident thread survives.
1192        ps.hide_ask();
1193        assert!(!ps.is_showing_ask());
1194        assert!(ps.editor().is_some());
1195    }
1196
1197    /// `set_ask_client` is the single injection point: it drives the resident
1198    /// Ask panel's client, which is its composer-enabled signal. No client ⇒
1199    /// disabled (never a forever-`Thinking` turn); a present client ⇒ enabled.
1200    #[tokio::test]
1201    async fn set_ask_client_drives_the_resident_panels_client() {
1202        let mut ps = make_panel_set().await;
1203        ps.set_ask_client(None);
1204        assert!(!ps.ask().has_client());
1205        // Handing a client back enables it. (A bare client over a throwaway
1206        // vault id is fine — it's never called here.)
1207        let client = std::sync::Arc::new(crate::server_client::RagClient::new(
1208            "http://localhost:0".to_string(),
1209            None,
1210            "vault".to_string(),
1211        ));
1212        ps.set_ask_client(Some(client));
1213        assert!(ps.ask().has_client());
1214    }
1215
1216    #[tokio::test]
1217    async fn showing_an_attachment_replaces_ask() {
1218        let mut ps = make_panel_set().await;
1219        ps.show_ask();
1220        ps.show_attachment(test_attachment_view());
1221        assert!(!ps.is_showing_ask());
1222        assert!(ps.is_showing_attachment());
1223    }
1224
1225    #[test]
1226    fn visible_in_order_skips_hidden() {
1227        let mut order = PanelOrder::new();
1228        assert_eq!(
1229            order.visible_in_order(),
1230            vec![PanelKind::Rail, PanelKind::Drawer, PanelKind::Editor]
1231        );
1232        order.hide(PanelKind::Drawer);
1233        assert_eq!(
1234            order.visible_in_order(),
1235            vec![PanelKind::Rail, PanelKind::Editor]
1236        );
1237    }
1238}