Skip to main content

quadraui/
shell.rs

1//! Shell runner infrastructure: [`ShellApp`] trait + [`ShellConfig`].
2//!
3//! Apps that want an AppShell (activity bar + sidebar + main content)
4//! implement [`ShellApp`] instead of [`AppLogic`](crate::AppLogic).
5//! Per-backend `run_with_shell()` functions handle the full lifecycle:
6//! window creation, event wiring, AppShell chrome rendering, and event
7//! routing — the consumer renders only its own content.
8
9use std::cell::{Cell, Ref, RefCell, RefMut};
10
11use crate::compose::app_shell::{
12    AppShell, AppShellEvent, AppShellLayout, BottomBand, PanelDefinition, ShellPosition,
13};
14use crate::compose::bottom_panel::{BottomPanelConfig, BottomPanelEvent};
15use crate::event::{Point, Rect};
16use crate::primitives::image::ImageSource;
17use crate::types::{Color, Icon, WidgetId};
18use crate::{Backend, Reaction, ResizeEdge, UiEvent};
19
20/// Configuration for creating an AppShell.
21pub struct ShellConfig {
22    pub panels: Vec<PanelDefinition>,
23    pub bottom_items: Vec<PanelDefinition>,
24    pub title: String,
25    pub default_sidebar_width: f32,
26    pub min_sidebar_width: f32,
27    pub max_sidebar_width: f32,
28    /// Activity bar width in line-height multiples (`4.0` by default,
29    /// giving 4 TUI columns: 1 accent + 2 icon cells (double-width nerd
30    /// font glyph) + 1 separator) — same unit as
31    /// `default_sidebar_width`. Set via [`Self::with_activity_bar_width`].
32    /// See [`Self::activity_bar_width_px`] for a fixed-pixel alternative
33    /// that doesn't move with the app's editor font.
34    pub activity_bar_width: f32,
35    /// Fixed-pixel activity bar width override, on pixel backends. `Some(48.0)`
36    /// by default, matching VS Code's activity bar and the `ACTIVITY_ROW_PX`
37    /// row height GTK/macOS already paint each item at — regardless of
38    /// `line_height`. Set via [`Self::with_activity_bar_width_px`]; pass
39    /// `None` to fall back to `activity_bar_width` (a line-height multiple)
40    /// instead. **TUI ignores this field** — a cell grid has no literal
41    /// pixels, so a px override there would become a column count instead
42    /// of a width; `activity_bar_width` always drives TUI.
43    pub activity_bar_width_px: Option<f32>,
44    pub position: ShellPosition,
45    pub has_title_bar: bool,
46    pub title_bar_height_lh: f32,
47    /// Fixed-pixel floor under `title_bar_height_lh * line_height`, on pixel
48    /// backends. `Some(35.0)` by default, matching VS Code's tab/title bar.
49    /// See [`AppShell::with_title_bar_min_px`] for the full rationale and
50    /// why this is a floor rather than [`Self::activity_bar_width_px`]'s
51    /// hard override. Set via [`Self::with_title_bar_min_px`]; pass `None`
52    /// to leave `title_bar_height_lh * line_height` in sole charge. **TUI
53    /// ignores this field**, for the same reason as `activity_bar_width_px`.
54    pub title_bar_min_px: Option<f32>,
55    /// Ask the backend not to also draw its own native titlebar band on
56    /// top of an app-painted one. `false` (the default) leaves
57    /// every backend's previous window-creation behavior unchanged.
58    ///
59    /// This is **not** GTK's `set_decorated(false)` "undecorated" model —
60    /// on macOS the native traffic-light controls (close/minimize/zoom)
61    /// stay live and native even with this set; only the native title
62    /// text and titlebar background go away
63    /// (`NSWindowStyleMask::FullSizeContentView` +
64    /// `setTitlebarAppearsTransparent(true)` +
65    /// `NSWindowTitleVisibility::Hidden` — see
66    /// `macos::run::window_style_mask`).
67    ///
68    /// **macOS is the only backend that acts on this today.** TUI has no
69    /// window concept, and GTK/Win-GUI still ignore the field: their
70    /// windows keep full native chrome whether it is set or not. Don't
71    /// hardcode "macOS reserves 78pt, everyone else reserves nothing" on
72    /// the strength of that — ask
73    /// [`crate::Backend::titlebar_control_inset`] instead, which reports
74    /// how much of the leading edge of
75    /// [`crate::compose::app_shell::AppShellLayout::title_bar_bounds`] to
76    /// leave clear (`Rect::default()`, i.e. "paint the whole band", on
77    /// every backend that ignores this field) and keeps working
78    /// unchanged when GTK/Win-GUI do grow client-side decorations.
79    ///
80    /// Set via [`Self::with_client_side_titlebar`].
81    pub client_side_titlebar: bool,
82    pub has_bottom_panel: bool,
83    pub bottom_panel_height_lh: f32,
84    pub min_bottom_panel_height_lh: f32,
85    pub max_bottom_panel_height_lh: f32,
86    pub has_command_line: bool,
87    pub has_status_bar: bool,
88    /// Fixed-pixel status bar height, on pixel backends, when
89    /// [`Self::has_status_bar`] is set. `Some(22.0)` by default, matching
90    /// VS Code's status bar. `None` falls back to one line-height (the
91    /// same fallback [`AppShell`] has always used). Set via
92    /// [`Self::with_status_bar_height_px`]. **TUI ignores this field** —
93    /// a cell grid has no literal pixels, so a px override there would
94    /// become a row count instead of a height; the one-line-height
95    /// fallback always drives TUI.
96    pub status_bar_height_px: Option<f32>,
97    /// Carve the status bar and command line from the main column's width
98    /// instead of the full window width, letting the activity bar and
99    /// sidebar run the full height below the title bar. Off by default.
100    /// Set via [`Self::with_status_bar_under_main_column`].
101    pub status_bar_under_main_column: bool,
102    /// Optional tabbed bottom panel. `None` (the default) preserves the
103    /// existing no-panel layout. When `Some`, the shell runner creates a
104    /// [`crate::compose::bottom_panel::BottomPanelController`] that renders
105    /// the tab strip and delegates content to each tab's `BackendWidget`.
106    ///
107    /// Setting this field automatically enables an AppShell bottom panel
108    /// region — no need to also call [`Self::with_bottom_panel`] unless you
109    /// want to tune the height via the old API as well.
110    pub bottom_panel: Option<BottomPanelConfig>,
111    /// Independently-gated bottom bands stacked below the editor content
112    /// area, generalizing the single `bottom_panel`/`bottom_panel`-drawer
113    /// model to **N** rungs — e.g. a terminal toolbar, a
114    /// debug toolbar, a quickfix list, a wildmenu, a separated status row,
115    /// each present/sized independently of the others. Empty by default,
116    /// so nothing that predates this field changes behavior. See
117    /// [`BottomBand`] for the stacking order and how this interacts with
118    /// [`Self::bottom_panel`] / [`Self::with_bottom_panel`]. Set via
119    /// [`Self::with_bottom_bands`]; toggle presence/height at runtime via
120    /// `ctx.shell_mut().set_bottom_band_visible(...)` /
121    /// `set_bottom_band_height(...)` ([`AppShell`]'s own methods, reached
122    /// through [`ShellContext::shell_mut`]).
123    pub bottom_bands: Vec<BottomBand>,
124    /// Editor font override: `(family, size_pt)`. `None` (the default)
125    /// leaves each backend's built-in default in place (GTK:
126    /// `"Monospace 11"`; TUI: fixed-cell, no font concept — the value is
127    /// ignored). Set via [`Self::with_editor_font`]; the shell runner
128    /// applies it once via [`crate::Backend::set_editor_font`] during
129    /// [`crate::shell_adapter::ShellAdapter`]'s one-time `setup()`, before
130    /// the app's own `setup()` runs, so painted glyphs and click-column
131    /// math derive from the same font from the very first frame.
132    pub editor_font: Option<(String, f32)>,
133    /// GTK application id, e.g. `"io.github.jdonaghy.VimCode"`. Feeds
134    /// `gtk4::Application::builder().application_id(..)` in the GTK shell
135    /// runner (`quadraui::gtk::run_with_shell`), which is what a live GDK
136    /// backend derives the toplevel's identity from: Wayland
137    /// `xdg_toplevel.set_app_id`, X11 `WM_CLASS`. The window manager uses
138    /// that id to match a live window to an installed `.desktop` file —
139    /// with the default generic id, every downstream app's taskbar/dock/
140    /// alt-tab entry shows a generic icon instead of the app's own.
141    /// Defaults to `"org.quadraui.app"` so existing callers are
142    /// unaffected. Set via [`Self::with_app_id`]. Backends with no
143    /// window-manager concept (TUI) ignore this field.
144    pub app_id: String,
145    /// Themed icon name, e.g. `"io.github.jdonaghy.vimcode"`, matching an
146    /// icon installed under an XDG icon theme directory. Feeds
147    /// `gtk4::Window::set_icon_name` in the GTK shell runner so themed-icon
148    /// lookup works even on window managers that don't resolve the app id
149    /// to a `.desktop` file. `None` (the default) leaves GTK's own
150    /// fallback icon in place. Set via [`Self::with_icon_name`]. Backends
151    /// with no icon-theme concept (TUI) ignore this field.
152    pub icon_name: Option<String>,
153    /// Per-panel Nerd-Font glyph + ASCII fallback overrides, replayed onto
154    /// the built [`AppShell`] via [`AppShell::with_panel_icon`] by
155    /// [`crate::shell_adapter::build_shell_adapter`]. Covers both top
156    /// panels (`panels`) and [`Self::bottom_items`] — same seam,
157    /// [`AppShell::with_panel_icon`] doesn't distinguish the two. Empty by
158    /// default, so nothing that predates this field changes behavior:
159    /// every panel keeps painting its single [`PanelDefinition::icon`]
160    /// string under both settings of `nerd_fonts_enabled`, exactly as
161    /// before.
162    ///
163    /// Set via [`Self::with_panel_icon`]. Before this field existed, a
164    /// `ShellConfig`-based consumer had no way to reach
165    /// [`AppShell::with_panel_icon`] at all — `build_shell_adapter` builds
166    /// the `AppShell` directly from `panels/bottom_items` and never
167    /// exposed a hook for it.
168    pub panel_icons: Vec<(WidgetId, Icon)>,
169    /// App icon (Dock/app-switcher on macOS, big+small taskbar/titlebar
170    /// icon on Win-GUI). `None` (the default) leaves each backend's
171    /// previous behaviour unchanged: macOS shows the generic
172    /// unbundled-executable icon, Win-GUI shows no titlebar/taskbar icon.
173    ///
174    /// This is a **window-manager identity concern**, not a paint-time
175    /// [`crate::primitives::image::Image`] primitive — there is no
176    /// `Backend::draw_*` method for it, mirroring [`Self::app_id`] /
177    /// [`Self::icon_name`] (GTK's equivalent window-manager identity
178    /// fields, also consulted only by each backend's own shell runner,
179    /// never by [`crate::shell_adapter`]). GTK keeps using
180    /// [`Self::icon_name`] (the themed icon) and ignores this field —
181    /// backends with no separate Dock/taskbar icon concept (TUI) ignore
182    /// it too.
183    ///
184    /// Set via [`Self::with_app_icon`].
185    pub app_icon: Option<ImageSource>,
186    /// Activity-bar accent colour, forwarded to
187    /// [`AppShell::with_active_accent`] at construction and available via
188    /// [`AppShell::set_active_accent`] at runtime.  `None` (the default)
189    /// paints no accent strip — see [`crate::ActivityBar::active_accent`].
190    ///
191    /// Set via [`Self::with_active_accent`].
192    pub active_accent: Option<Color>,
193    /// Keyboard-selection background, forwarded to
194    /// [`AppShell::with_selection_bg`].  `None` (the default) lets each
195    /// backend fall back to its own default.
196    ///
197    /// Set via [`Self::with_selection_bg`].
198    pub selection_bg: Option<Color>,
199}
200
201impl ShellConfig {
202    pub fn new(title: impl Into<String>, panels: Vec<PanelDefinition>) -> Self {
203        Self {
204            panels,
205            bottom_items: Vec::new(),
206            title: title.into(),
207            default_sidebar_width: 20.0,
208            min_sidebar_width: 8.0,
209            max_sidebar_width: 50.0,
210            activity_bar_width: 4.0,
211            activity_bar_width_px: Some(48.0),
212            position: ShellPosition::Left,
213            has_title_bar: false,
214            title_bar_height_lh: 1.5,
215            title_bar_min_px: Some(35.0),
216            client_side_titlebar: false,
217            has_bottom_panel: false,
218            bottom_panel_height_lh: 10.0,
219            min_bottom_panel_height_lh: 3.0,
220            max_bottom_panel_height_lh: 30.0,
221            has_command_line: false,
222            has_status_bar: false,
223            status_bar_height_px: Some(22.0),
224            status_bar_under_main_column: false,
225            bottom_panel: None,
226            bottom_bands: Vec::new(),
227            editor_font: None,
228            app_id: "org.quadraui.app".to_string(),
229            icon_name: None,
230            panel_icons: Vec::new(),
231            app_icon: None,
232            active_accent: None,
233            selection_bg: None,
234        }
235    }
236
237    pub fn with_bottom_items(mut self, items: Vec<PanelDefinition>) -> Self {
238        self.bottom_items = items;
239        self
240    }
241
242    pub fn with_position(mut self, position: ShellPosition) -> Self {
243        self.position = position;
244        self
245    }
246
247    /// Override the activity bar width in line-height multiples (`4.0` by
248    /// default). Same unit as `default_sidebar_width`. See
249    /// [`Self::with_activity_bar_width_px`] for a fixed-pixel alternative
250    pub fn with_activity_bar_width(mut self, width_lh: f32) -> Self {
251        self.activity_bar_width = width_lh;
252        self
253    }
254
255    /// Pin the activity bar to a fixed pixel width, overriding
256    /// `activity_bar_width`. Independent of `line_height` — defaults to
257    /// `48.0`, keeping the bar square against `ACTIVITY_ROW_PX` regardless
258    /// of the app's editor font size. To fall back to the line-height
259    /// multiple instead, set the `activity_bar_width_px` field directly to
260    /// `None` (it is `pub`).
261    pub fn with_activity_bar_width_px(mut self, width_px: f32) -> Self {
262        self.activity_bar_width_px = Some(width_px);
263        self
264    }
265
266    pub fn with_title_bar(mut self, height_lh: f32) -> Self {
267        self.has_title_bar = true;
268        self.title_bar_height_lh = height_lh;
269        self
270    }
271
272    /// Pin a fixed-pixel floor under the title bar's line-height-derived
273    /// height — see [`AppShell::with_title_bar_min_px`] for the full
274    /// rationale. Defaults to `35.0`. Set the `title_bar_min_px` field
275    /// directly to `None` to leave `title_bar_height_lh * line_height` in
276    /// sole charge instead.
277    pub fn with_title_bar_min_px(mut self, min_px: f32) -> Self {
278        self.title_bar_min_px = Some(min_px);
279        self
280    }
281
282    /// Opt into a client-side titlebar: ask the backend not to
283    /// also paint its own native titlebar band on top of an app-drawn
284    /// [`Self::with_title_bar`] region. See [`Self::client_side_titlebar`]
285    /// for the per-backend mechanism and why it is not GTK's
286    /// "undecorated" model on macOS.
287    ///
288    /// Typically paired with [`Self::with_title_bar`] — this field alone
289    /// only affects window creation, not layout; the app still reserves
290    /// the band via `with_title_bar`.
291    pub fn with_client_side_titlebar(mut self) -> Self {
292        self.client_side_titlebar = true;
293        self
294    }
295
296    pub fn with_bottom_panel(mut self, height_lh: f32) -> Self {
297        self.has_bottom_panel = true;
298        self.bottom_panel_height_lh = height_lh;
299        self
300    }
301
302    pub fn with_bottom_panel_limits(mut self, min: f32, max: f32) -> Self {
303        self.min_bottom_panel_height_lh = min;
304        self.max_bottom_panel_height_lh = max;
305        self
306    }
307
308    pub fn with_command_line(mut self) -> Self {
309        self.has_command_line = true;
310        self
311    }
312
313    pub fn with_status_bar(mut self) -> Self {
314        self.has_status_bar = true;
315        self
316    }
317
318    /// Pin the status bar to a fixed pixel height. Defaults to `22.0`,
319    /// matching VS Code's status bar. Set the `status_bar_height_px` field
320    /// directly to `None` to fall back to one line-height instead.
321    pub fn with_status_bar_height_px(mut self, height_px: f32) -> Self {
322        self.status_bar_height_px = Some(height_px);
323        self
324    }
325
326    pub fn with_status_bar_under_main_column(mut self) -> Self {
327        self.status_bar_under_main_column = true;
328        self
329    }
330
331    /// Attach a tabbed bottom panel.
332    ///
333    /// This is the preferred way to add a bottom panel when you want tab
334    /// switching, close buttons, and per-tab [`crate::compose::bottom_panel::BackendWidget`]
335    /// content. It automatically enables the AppShell bottom panel region;
336    /// the initial height comes from [`BottomPanelConfig::height_fraction`].
337    ///
338    /// Use [`Self::with_bottom_panel`] + [`Self::with_bottom_panel_limits`]
339    /// instead when you only need a bare unstyled region below main content.
340    pub fn with_bottom_panel_config(mut self, config: BottomPanelConfig) -> Self {
341        self.bottom_panel = Some(config);
342        self
343    }
344
345    /// Register the ordered list of independently-gated bottom bands
346    /// — see [`Self::bottom_bands`] / [`BottomBand`].
347    ///
348    /// Replaces any previously-registered list. Combine freely with
349    /// [`Self::with_bottom_panel`] / [`Self::with_bottom_panel_config`]:
350    /// the legacy drawer always docks directly above these bands.
351    pub fn with_bottom_bands(mut self, bands: Vec<BottomBand>) -> Self {
352        self.bottom_bands = bands;
353        self
354    }
355
356    /// Override the font painted for editor content (family name + size
357    /// in points).
358    ///
359    /// `family` should name a monospace font — primitives that map
360    /// columns to pixels assume uniform glyph width. Applied once via
361    /// [`crate::Backend::set_editor_font`] during the shell runner's
362    /// one-time `setup()` call (see [`Self::editor_font`]); call
363    /// [`crate::Backend::set_editor_font`] directly at runtime (e.g. from
364    /// [`ShellApp::handle`]) to change it again after startup, such as a
365    /// zoom-in/out keybinding.
366    pub fn with_editor_font(mut self, family: impl Into<String>, size_pt: f32) -> Self {
367        self.editor_font = Some((family.into(), size_pt));
368        self
369    }
370
371    /// Override the GTK application id (`"org.quadraui.app"` by default).
372    ///
373    /// Should be a reverse-DNS id — see the
374    /// [GNOME application ID guidelines](https://developer.gnome.org/documentation/tutorials/application-id.html).
375    /// GTK warns and ignores a malformed id rather than rejecting it
376    /// outright, so this method does not validate the string itself.
377    /// See [`Self::app_id`] for why this matters.
378    pub fn with_app_id(mut self, app_id: impl Into<String>) -> Self {
379        self.app_id = app_id.into();
380        self
381    }
382
383    /// Set a themed icon name for the window (unset — GTK's own fallback —
384    /// by default). See [`Self::icon_name`] for why this matters.
385    pub fn with_icon_name(mut self, icon_name: impl Into<String>) -> Self {
386        self.icon_name = Some(icon_name.into());
387        self
388    }
389
390    /// Attach a distinct Nerd-Font glyph + ASCII fallback pair to one
391    /// activity-bar row, mirroring [`AppShell::with_panel_icon`] — the
392    /// consuming-builder method [`crate::shell_adapter::build_shell_adapter`]
393    /// replays every registered pair onto during construction.
394    ///
395    /// `id` may name a top panel (`panels`) or a [`Self::bottom_items`]
396    /// entry; unknown ids are stored but never consulted, so call order
397    /// relative to `ShellConfig::new` / [`Self::with_bottom_items`] doesn't
398    /// matter. Calling this twice for the same id keeps the last pair —
399    /// same last-write-wins semantics as `AppShell::with_panel_icon`, since
400    /// both are ultimately backed by the same `HashMap`.
401    pub fn with_panel_icon(mut self, id: WidgetId, icon: Icon) -> Self {
402        self.panel_icons.push((id, icon));
403        self
404    }
405
406    /// Set the app icon (Dock/app-switcher on macOS, taskbar/titlebar on
407    /// Win-GUI) — see [`Self::app_icon`] for the per-backend contract
408    pub fn with_app_icon(mut self, icon: ImageSource) -> Self {
409        self.app_icon = Some(icon);
410        self
411    }
412
413    /// Set the activity-bar accent colour forwarded to
414    /// [`AppShell::with_active_accent`] at construction. See
415    /// [`Self::active_accent`] for the full contract.
416    pub fn with_active_accent(mut self, color: Color) -> Self {
417        self.active_accent = Some(color);
418        self
419    }
420
421    /// Set the keyboard-selection background forwarded to
422    /// [`AppShell::with_selection_bg`] at construction. See
423    /// [`Self::selection_bg`] for the full contract.
424    pub fn with_selection_bg(mut self, color: Color) -> Self {
425        self.selection_bg = Some(color);
426        self
427    }
428}
429
430/// Context passed to [`ShellApp::handle`] so the consumer can route
431/// events by panel without tracking shell state themselves.
432pub struct ShellContext<'a> {
433    /// Currently active sidebar panel, if any.
434    pub active_panel_id: Option<&'a WidgetId>,
435    /// Whether the sidebar is visible.
436    pub sidebar_visible: bool,
437    /// Layout bounds from the last render.
438    pub layout: &'a AppShellLayout,
439    /// Set via [`Self::request_activity_keyboard_focus`]; consumed by
440    /// [`crate::shell_adapter::ShellAdapter::handle`] after
441    /// `ShellApp::handle` returns. `Cell` because `ShellContext` is passed
442    /// by shared reference — the consumer can request focus without a
443    /// `&mut` hook back into the shell.
444    activity_focus_requested: Cell<bool>,
445    /// A scoped mutable borrow of the [`AppShell`] instance
446    /// [`crate::shell_adapter::ShellAdapter`] actually renders, lent out for
447    /// the duration of one `ShellApp::handle` dispatch.
448    ///
449    /// Before this existed, a `ShellApp` consumer had no way to reach the
450    /// rendered shell at all — `ShellAdapter::shell` is `pub(crate)` — so
451    /// driving shell state programmatically (e.g. a `Ctrl+B` toggle-sidebar
452    /// binding) required keeping a second, shadow `AppShell` that silently
453    /// drifted from the one actually painted (vimcode `Ctrl+B` was dead on
454    /// GTK because of exactly this: the shadow toggled, the rendered
455    /// instance never learned about it).
456    ///
457    /// `RefCell` because `ShellContext` is passed by shared reference (see
458    /// `activity_focus_requested` above) — access via [`Self::shell`] /
459    /// [`Self::shell_mut`]. `AppShell` remains solely owned and constructed
460    /// by `ShellAdapter`; this only lends a scoped borrow for one dispatch,
461    /// so the single-owner invariant that makes drift impossible is
462    /// preserved.
463    shell: RefCell<&'a mut AppShell>,
464}
465
466impl<'a> ShellContext<'a> {
467    /// Construct a [`ShellContext`] for one `ShellApp::handle` dispatch.
468    /// Only called by [`crate::shell_adapter::ShellAdapter`]; downstream
469    /// consumers receive an already-built context, they don't build one.
470    ///
471    /// `cfg`-gated with `ShellAdapter` itself: under a feature set
472    /// with no shell runner there is no caller, so this and
473    /// `take_activity_focus_requested` below go dead-code under
474    /// `-D warnings`. `macos` and `win` are included in the gate to match
475    /// their respective shell runners. `test` is included so tests in
476    /// this module can build a `ShellContext` directly.
477    #[cfg(any(
478        feature = "tui",
479        feature = "gtk",
480        all(feature = "macos", target_os = "macos"),
481        feature = "win",
482        test
483    ))]
484    pub(crate) fn new(
485        active_panel_id: Option<&'a WidgetId>,
486        sidebar_visible: bool,
487        layout: &'a AppShellLayout,
488        shell: &'a mut AppShell,
489    ) -> Self {
490        Self {
491            active_panel_id,
492            sidebar_visible,
493            layout,
494            activity_focus_requested: Cell::new(false),
495            shell: RefCell::new(shell),
496        }
497    }
498
499    /// Borrow the real, rendered [`AppShell`] read-only.
500    ///
501    /// Prefer the narrow accessors above (`in_sidebar`, `sidebar_bounds`,
502    /// ...) for layout queries — this exists for the rest of `AppShell`'s
503    /// API (e.g. `bottom_panel_visible()`, `activity_selected_id()`) that
504    /// `ShellContext` doesn't otherwise mirror.
505    ///
506    /// # Panics
507    /// Panics if [`Self::shell_mut`] is borrowed at the same time (standard
508    /// `RefCell` rules). Each borrow is meant to be short-lived — grab it,
509    /// read, drop.
510    pub fn shell(&self) -> Ref<'_, &'a mut AppShell> {
511        self.shell.borrow()
512    }
513
514    /// Borrow the real, rendered [`AppShell`] mutably.
515    ///
516    /// Call any `AppShell` mutator directly on the instance
517    /// [`crate::shell_adapter::ShellAdapter`] actually renders —
518    /// `ctx.shell_mut().toggle_sidebar()`,
519    /// `ctx.shell_mut().show_bottom_panel()`,
520    /// `ctx.shell_mut().set_sidebar_width(w)`, etc. — to keep shell state
521    /// in sync with the rendered display.
522    ///
523    /// # Panics
524    /// Panics if [`Self::shell`] / [`Self::shell_mut`] is borrowed at the
525    /// same time (standard `RefCell` rules). Each borrow is meant to be
526    /// short-lived — grab it, mutate, drop.
527    pub fn shell_mut(&self) -> RefMut<'_, &'a mut AppShell> {
528        self.shell.borrow_mut()
529    }
530
531    /// Request that the activity bar take keyboard focus, with its cursor
532    /// reset to the top item, once the current `ShellApp::handle` call
533    /// returns.
534    ///
535    /// Call this from your own `handle()` in response to whatever key you
536    /// want to bind as the "focus the activity bar" trigger (`Tab`,
537    /// `Ctrl+W`, a command-palette action, ...) — quadraui does not
538    /// reserve a key for this itself, so different consumers can pick
539    /// different triggers. Once focused, [`crate::shell_adapter::ShellAdapter`]
540    /// owns the navigation keys (`j`/`k`/arrows to move, `Enter`/`Space` to
541    /// activate, `Esc` to cancel) by driving the same
542    /// [`crate::compose::app_shell::AppShell`] methods the raw `AppLogic`
543    /// pattern uses directly — see `examples/common/shell_app.rs`.
544    ///
545    /// A no-op if called more than once per `handle()` call (the flag is
546    /// simply set, not counted).
547    pub fn request_activity_keyboard_focus(&self) {
548        self.activity_focus_requested.set(true);
549    }
550
551    /// Consume the pending focus request, if any. `pub(crate)` — only
552    /// [`crate::shell_adapter::ShellAdapter::handle`] calls this, after
553    /// `ShellApp::handle` returns.
554    ///
555    /// `cfg`-gated with `ShellAdapter` itself; see [`Self::new`].
556    #[cfg(any(
557        feature = "tui",
558        feature = "gtk",
559        all(feature = "macos", target_os = "macos"),
560        feature = "win"
561    ))]
562    pub(crate) fn take_activity_focus_requested(&self) -> bool {
563        self.activity_focus_requested.replace(false)
564    }
565
566    /// Check if a mouse position lands inside the sidebar content area.
567    pub fn in_sidebar(&self, x: f32, y: f32) -> bool {
568        self.layout
569            .sidebar_content_bounds
570            .is_some_and(|r| r.contains(Point::new(x, y)))
571    }
572
573    /// Check if a mouse position lands inside the main content area.
574    pub fn in_main(&self, x: f32, y: f32) -> bool {
575        self.layout.main_content_bounds.contains(Point::new(x, y))
576    }
577
578    /// Check if a mouse position lands inside the bottom panel.
579    pub fn in_bottom_panel(&self, x: f32, y: f32) -> bool {
580        self.layout
581            .bottom_panel_bounds
582            .is_some_and(|r| r.contains(Point::new(x, y)))
583    }
584
585    /// Check if a mouse position lands inside the title bar.
586    pub fn in_title_bar(&self, x: f32, y: f32) -> bool {
587        self.layout
588            .title_bar_bounds
589            .is_some_and(|r| r.contains(Point::new(x, y)))
590    }
591
592    /// Check if a mouse position lands inside the status bar.
593    pub fn in_status_bar(&self, x: f32, y: f32) -> bool {
594        self.layout
595            .status_bar_bounds
596            .is_some_and(|r| r.contains(Point::new(x, y)))
597    }
598
599    /// Check if a mouse position lands inside the command line.
600    pub fn in_command_line(&self, x: f32, y: f32) -> bool {
601        self.layout
602            .command_line_bounds
603            .is_some_and(|r| r.contains(Point::new(x, y)))
604    }
605
606    /// Sidebar content bounds (convenience for coordinate translation).
607    pub fn sidebar_bounds(&self) -> Option<Rect> {
608        self.layout.sidebar_content_bounds
609    }
610
611    /// Main content bounds.
612    pub fn main_bounds(&self) -> Rect {
613        self.layout.main_content_bounds
614    }
615
616    /// Bottom panel bounds.
617    pub fn bottom_panel_bounds(&self) -> Option<Rect> {
618        self.layout.bottom_panel_bounds
619    }
620
621    /// Resolved bounds for one currently-visible [`BottomBand`],
622    /// or `None` if `id` names no registered band or the
623    /// band is not visible in the last computed layout.
624    ///
625    /// Reads [`AppShell::bottom_band_bounds`] rather than a field on
626    /// [`AppShellLayout`] — see that struct's doc for why bottom-band
627    /// bounds live there instead of on the layout snapshot.
628    pub fn bottom_band_bounds(&self, id: &WidgetId) -> Option<Rect> {
629        self.shell()
630            .bottom_band_bounds()
631            .into_iter()
632            .find(|(band_id, _)| band_id == id)
633            .map(|(_, rect)| rect)
634    }
635
636    /// Check if a mouse position lands inside one currently-visible
637    /// [`BottomBand`].
638    pub fn in_bottom_band(&self, id: &WidgetId, x: f32, y: f32) -> bool {
639        self.bottom_band_bounds(id)
640            .is_some_and(|r| r.contains(Point::new(x, y)))
641    }
642
643    /// Title bar bounds.
644    pub fn title_bar_bounds(&self) -> Option<Rect> {
645        self.layout.title_bar_bounds
646    }
647
648    /// The full window/viewport bounds this layout was computed against.
649    pub fn window_bounds(&self) -> Rect {
650        self.layout.window_bounds
651    }
652
653    /// Which edge or corner of the window `(x, y)` is within `margin` of,
654    /// or `None` if the position isn't near any outer border.
655    ///
656    /// Mirrors [`Self::in_title_bar`]'s pattern: the app hit-tests the
657    /// pointer against a stored bounds field every frame rather than the
658    /// backend owning edge detection. Callers should derive `margin` from a
659    /// portable backend unit (e.g. `backend.line_height()`) rather than a
660    /// hardcoded pixel/cell constant — see `quadraui/docs/LESSONS.md`
661    /// "Shared AppLogic code must not hardcode backend-native units".
662    ///
663    /// Call from a `MouseDown` handler (pass the result to
664    /// [`Backend::begin_window_resize`]) and from a `MouseMoved` handler
665    /// (pass `PointerShape::Resize(edge)` / `PointerShape::Default` to
666    /// [`Backend::set_cursor`]).
667    pub fn window_edge(&self, x: f32, y: f32, margin: f32) -> Option<ResizeEdge> {
668        let r = self.layout.window_bounds;
669        // Points outside the window entirely (shouldn't normally happen —
670        // mouse events are clipped to the window — but defend anyway) never
671        // resolve to an edge.
672        if x < r.x || x > r.x + r.width || y < r.y || y > r.y + r.height {
673            return None;
674        }
675        // `near_right`/`near_bottom` use `>=` (not `>`) so the last valid
676        // cell index is included on TUI, mirroring `Rect::contains`'s own
677        // `x < r.x + r.width` upper bound (which treats column
678        // `r.width - 1` as inside). With continuous GTK pixel coordinates
679        // the `=` case is a single point of measure zero and doesn't
680        // change behaviour; on TUI's discrete cell grid it's the
681        // difference between the bottom-right corner being reachable at
682        // all and a permanently-dead corner (margin == 1 cell exactly
683        // covers the last row/column, so a strict `>` would never fire).
684        let margin = margin.max(0.0);
685        let near_left = x < r.x + margin;
686        let near_right = x >= r.x + r.width - margin;
687        let near_top = y < r.y + margin;
688        let near_bottom = y >= r.y + r.height - margin;
689
690        // Corners first, then single edges. An if-chain (rather than an
691        // exhaustive match on all four bools) reads clearer and handles the
692        // degenerate case of a window smaller than `2 * margin` in one
693        // dimension (both `near_top` and `near_bottom` true, no left/right)
694        // by falling through to a deterministic single-edge pick instead of
695        // requiring a meaningless tie-break arm.
696        if near_top && near_left {
697            Some(ResizeEdge::NorthWest)
698        } else if near_top && near_right {
699            Some(ResizeEdge::NorthEast)
700        } else if near_bottom && near_left {
701            Some(ResizeEdge::SouthWest)
702        } else if near_bottom && near_right {
703            Some(ResizeEdge::SouthEast)
704        } else if near_top {
705            Some(ResizeEdge::North)
706        } else if near_bottom {
707            Some(ResizeEdge::South)
708        } else if near_left {
709            Some(ResizeEdge::West)
710        } else if near_right {
711            Some(ResizeEdge::East)
712        } else {
713            None
714        }
715    }
716
717    /// Status bar bounds.
718    pub fn status_bar_bounds(&self) -> Option<Rect> {
719        self.layout.status_bar_bounds
720    }
721
722    /// Command line bounds.
723    pub fn command_line_bounds(&self) -> Option<Rect> {
724        self.layout.command_line_bounds
725    }
726}
727
728/// Application trait for apps that use the AppShell chrome.
729///
730/// The shell handles: activity bar rendering + clicks, sidebar
731/// header + divider, panel switching, and resize drag. The consumer
732/// renders panel content and main-area content into the bounds the
733/// shell provides via [`AppShellLayout`].
734pub trait ShellApp {
735    /// Render content into the shell's content zones. The shell has
736    /// already drawn its chrome (activity bar, sidebar header, divider);
737    /// the consumer draws sidebar panel content + main content here.
738    fn render_content(&self, backend: &mut dyn Backend, layout: &AppShellLayout);
739
740    /// Render one currently-visible [`crate::compose::app_shell::BottomBand`].
741    /// Called once per visible band, after `render_content`,
742    /// in the same bottom-up stacking order the band list was given.
743    ///
744    /// This is a separate hook rather than a field on [`AppShellLayout`]
745    /// deliberately: `AppShellLayout` is an all-`pub`-field struct a real
746    /// downstream consumer builds with an exhaustive struct literal (see
747    /// that struct's doc and `quadraui/tests/downstream_struct_literals.rs`),
748    /// so growing its field list breaks that call site. A new trait method
749    /// with a default (no-op) implementation is additive instead — see
750    /// `docs/PRIMITIVE_RULES.md` rule 8.
751    ///
752    /// Default: no-op — apps that never call
753    /// [`crate::compose::app_shell::AppShell::with_bottom_bands`] /
754    /// [`ShellConfig::bottom_bands`] are unaffected.
755    fn render_bottom_band(&self, _backend: &mut dyn Backend, _id: &WidgetId, _bounds: Rect) {}
756
757    /// Handle events the shell didn't consume. The [`ShellContext`]
758    /// provides the active panel ID and layout bounds so the consumer
759    /// can route per-panel without tracking shell state.
760    fn handle(&mut self, event: UiEvent, backend: &mut dyn Backend, ctx: &ShellContext)
761        -> Reaction;
762
763    /// Called once after the shell is built. Optional.
764    fn setup(&mut self, _backend: &mut dyn Backend) {}
765
766    /// Called every frame before the shell reads `chrome_line_height` and
767    /// computes its layout.  Apps that update backend font metrics (e.g.
768    /// via `set_ui_font` / `set_editor_font` / `set_theme`) should do so
769    /// here so the shell layout uses the current values on the same frame.
770    fn pre_render(&self, _backend: &mut dyn Backend) {}
771
772    /// Notified when a panel switch occurs (activity bar click or
773    /// programmatic). Optional.
774    ///
775    /// The [`ShellContext`] lends the same scoped `&mut AppShell` borrow
776    /// [`Self::handle`] receives — call `ctx.shell_mut()` mutators
777    /// here (e.g. `set_title_bar_visible`, `set_sidebar_width`,
778    /// `show_panel`, `toggle_sidebar`) to push state back into the shell
779    /// on the *same frame* this event fires.
780    ///
781    /// Default implementation is a no-op.
782    fn on_shell_event_ctx(&mut self, _event: &AppShellEvent, _ctx: &ShellContext) {}
783
784    /// Poll for a pending **app-initiated** panel switch — e.g. an action
785    /// handler that jumps straight to a different panel (a "launch
786    /// interactive session" command landing in the Terminal panel) without
787    /// the user clicking the ActivityBar.
788    ///
789    /// [`ShellAdapter`](crate::shell_adapter::ShellAdapter) calls this once
790    /// immediately after every [`Self::handle`] dispatch. Returning
791    /// `Some(panel_id)` applies the switch to the underlying `AppShell`
792    /// (updating the ActivityBar highlight and sidebar panel header, which
793    /// are otherwise owned entirely by `AppShell` and invisible to
794    /// `ShellApp` implementors) and re-notifies via
795    /// [`Self::on_shell_event_ctx`] with [`AppShellEvent::PanelChanged`], the
796    /// same notification a mouse click produces — so app code that already
797    /// syncs its own view state from `on_shell_event_ctx` doesn't need a
798    /// second code path.
799    ///
800    /// Default: never requests a switch. `&mut self` (not `&self`) so
801    /// implementors can `.take()` a stored `Option` field rather than
802    /// needing interior mutability.
803    fn take_requested_panel(&mut self) -> Option<WidgetId> {
804        None
805    }
806
807    /// Notified when the bottom panel tab strip emits an event
808    /// (tab activation, close, maximise toggle, or resize).
809    ///
810    /// Only called when `ShellConfig.bottom_panel` is `Some`. The
811    /// [`crate::compose::bottom_panel::BottomPanelController`] has already
812    /// applied the state change (switched active tab, removed a closed tab,
813    /// toggled maximised) before this method is called. Use it for any
814    /// higher-level bookkeeping — re-rendering is triggered automatically.
815    ///
816    /// Default: no-op.
817    fn on_bottom_panel_event(&mut self, _event: &BottomPanelEvent) {}
818
819    /// Periodic callback invoked by the runner on every frame (≈60Hz on
820    /// the TUI backend, GTK timeout on GTK). Use for timer-driven work —
821    /// polling background tasks, expiring caches, draining channels —
822    /// that must happen without a user input event. Default is no-op.
823    fn tick(&mut self, _backend: &mut dyn Backend) -> Reaction {
824        Reaction::Continue
825    }
826}
827
828#[cfg(test)]
829mod tests {
830    use super::*;
831
832    /// Build a bare (no panels, no chrome) `AppShellLayout` sized `w x h`
833    /// starting at the origin — just enough to exercise `window_bounds`.
834    fn layout_for(w: f32, h: f32) -> AppShellLayout {
835        AppShell::new(Vec::new(), 20.0).layout(Rect::new(0.0, 0.0, w, h), 1.0)
836    }
837
838    fn ctx<'a>(layout: &'a AppShellLayout, shell: &'a mut AppShell) -> ShellContext<'a> {
839        ShellContext::new(None, false, layout, shell)
840    }
841
842    /// A fresh `ShellConfig` has no editor-font override — backends
843    /// keep their own default (GTK: "Monospace 11").
844    #[test]
845    fn shell_config_editor_font_defaults_to_none() {
846        let config = ShellConfig::new("test", Vec::new());
847        assert_eq!(config.editor_font, None);
848    }
849
850    /// `with_editor_font` stores the family/size pair verbatim for
851    /// the shell runner to apply via `Backend::set_editor_font`.
852    #[test]
853    fn shell_config_with_editor_font_sets_family_and_size() {
854        let config = ShellConfig::new("test", Vec::new()).with_editor_font("Fira Code", 14.0);
855        assert_eq!(config.editor_font, Some(("Fira Code".to_string(), 14.0)));
856    }
857
858    /// A fresh `ShellConfig` keeps the generic app id and no icon
859    /// override, so nothing that predates this field changes behavior.
860    #[test]
861    fn shell_config_app_id_defaults_to_generic() {
862        let config = ShellConfig::new("test", Vec::new());
863        assert_eq!(config.app_id, "org.quadraui.app");
864        assert_eq!(config.icon_name, None);
865    }
866
867    /// `with_app_id` stores the caller's id verbatim, replacing the
868    /// generic default the GTK shell runner would otherwise announce to
869    /// the window manager (Wayland `app_id` / X11 `WM_CLASS`).
870    #[test]
871    fn shell_config_with_app_id_overrides_the_default() {
872        let config = ShellConfig::new("test", Vec::new()).with_app_id("io.github.jdonaghy.VimCode");
873        assert_eq!(config.app_id, "io.github.jdonaghy.VimCode");
874    }
875
876    /// A fresh `ShellConfig` keeps `client_side_titlebar` off, so
877    /// every existing consumer's window-creation call keeps getting
878    /// today's native chrome unchanged.
879    #[test]
880    fn shell_config_client_side_titlebar_defaults_to_false() {
881        let config = ShellConfig::new("test", Vec::new());
882        assert!(!config.client_side_titlebar);
883    }
884
885    /// `with_client_side_titlebar` opts in, mirroring the no-arg
886    /// `with_command_line`/`with_status_bar` builders above since there's
887    /// no accompanying data (unlike `with_title_bar`'s height).
888    #[test]
889    fn shell_config_with_client_side_titlebar_opts_in() {
890        let config = ShellConfig::new("test", Vec::new()).with_client_side_titlebar();
891        assert!(config.client_side_titlebar);
892    }
893
894    /// `with_icon_name` stores the themed icon name for the GTK
895    /// shell runner to apply via `gtk4::Window::set_icon_name`.
896    #[test]
897    fn shell_config_with_icon_name_sets_the_icon() {
898        let config =
899            ShellConfig::new("test", Vec::new()).with_icon_name("io.github.jdonaghy.vimcode");
900        assert_eq!(
901            config.icon_name,
902            Some("io.github.jdonaghy.vimcode".to_string())
903        );
904    }
905
906    /// A fresh `ShellConfig` keeps `AppShell`'s own line-height-multiple
907    /// default (4.0, giving 4 TUI columns: 1 accent + 2 icon cells for
908    /// double-width nerd font glyphs + 1 separator) available as a
909    /// fallback, but leads with a px-first default matching VS Code's
910    /// activity bar (`48.0`) on pixel backends.
911    #[test]
912    fn shell_config_activity_bar_width_defaults_match_app_shell() {
913        let config = ShellConfig::new("test", Vec::new());
914        assert_eq!(config.activity_bar_width, 4.0);
915        assert_eq!(config.activity_bar_width_px, Some(48.0));
916    }
917
918    /// `with_activity_bar_width` stores the line-height multiple
919    /// verbatim for `build_shell_adapter` to pass through to `AppShell`.
920    #[test]
921    fn shell_config_with_activity_bar_width_overrides_the_multiple() {
922        let config = ShellConfig::new("test", Vec::new()).with_activity_bar_width(2.4);
923        assert_eq!(config.activity_bar_width, 2.4);
924    }
925
926    /// `with_activity_bar_width_px` stores a fixed-pixel override,
927    /// letting a consumer pin the bar to VS Code's 48px regardless of the
928    /// app's editor font / line height.
929    #[test]
930    fn shell_config_with_activity_bar_width_px_sets_the_override() {
931        let config = ShellConfig::new("test", Vec::new()).with_activity_bar_width_px(48.0);
932        assert_eq!(config.activity_bar_width_px, Some(48.0));
933    }
934
935    /// A fresh `ShellConfig` defaults the title-bar-min-px floor to `35.0`,
936    /// matching VS Code's tab/title bar on pixel backends.
937    #[test]
938    fn shell_config_title_bar_min_px_defaults_to_35() {
939        let config = ShellConfig::new("test", Vec::new());
940        assert_eq!(config.title_bar_min_px, Some(35.0));
941    }
942
943    /// `with_title_bar_min_px` stores the floor for
944    /// `build_shell_adapter` to pass through to `AppShell`.
945    #[test]
946    fn shell_config_with_title_bar_min_px_sets_the_floor() {
947        let config = ShellConfig::new("test", Vec::new()).with_title_bar_min_px(34.0);
948        assert_eq!(config.title_bar_min_px, Some(34.0));
949    }
950
951    /// A fresh `ShellConfig` defaults the status bar height to `22.0`,
952    /// matching VS Code's status bar on pixel backends.
953    #[test]
954    fn shell_config_status_bar_height_px_defaults_to_22() {
955        let config = ShellConfig::new("test", Vec::new());
956        assert_eq!(config.status_bar_height_px, Some(22.0));
957    }
958
959    /// `with_status_bar_height_px` stores the override for
960    /// `build_shell_adapter` to pass through to `AppShell`.
961    #[test]
962    fn shell_config_with_status_bar_height_px_sets_the_override() {
963        let config = ShellConfig::new("test", Vec::new()).with_status_bar_height_px(30.0);
964        assert_eq!(config.status_bar_height_px, Some(30.0));
965    }
966
967    /// A fresh `ShellConfig` has no panel-icon overrides — nothing
968    /// that predates this field changes behavior.
969    #[test]
970    fn shell_config_panel_icons_defaults_to_empty() {
971        let config = ShellConfig::new("test", Vec::new());
972        assert!(config.panel_icons.is_empty());
973    }
974
975    /// `with_panel_icon` appends `(id, icon)` pairs verbatim, in call
976    /// order, for `build_shell_adapter` to replay onto the `AppShell`.
977    #[test]
978    fn shell_config_with_panel_icon_appends_overrides_in_order() {
979        let config = ShellConfig::new("test", Vec::new())
980            .with_panel_icon(WidgetId::new("panel:explorer"), Icon::new("\u{f07c}", "E"))
981            .with_panel_icon(WidgetId::new("panel:settings"), Icon::new("\u{f013}", "S"));
982        assert_eq!(
983            config.panel_icons,
984            vec![
985                (WidgetId::new("panel:explorer"), Icon::new("\u{f07c}", "E")),
986                (WidgetId::new("panel:settings"), Icon::new("\u{f013}", "S")),
987            ]
988        );
989    }
990
991    /// A fresh `ShellConfig` has no app-icon override — every
992    /// existing consumer keeps whatever generic icon its backend showed
993    /// before this field existed (macOS: the unbundled-executable icon;
994    /// Win-GUI: no titlebar/taskbar icon).
995    #[test]
996    fn shell_config_app_icon_defaults_to_none() {
997        let config = ShellConfig::new("test", Vec::new());
998        assert_eq!(config.app_icon, None);
999    }
1000
1001    /// `with_app_icon` stores the source verbatim for each
1002    /// backend's shell runner to thread onto its own `RunConfig`.
1003    #[test]
1004    fn shell_config_with_app_icon_sets_the_source() {
1005        let config =
1006            ShellConfig::new("test", Vec::new()).with_app_icon(ImageSource::Bytes(vec![1, 2, 3]));
1007        assert_eq!(config.app_icon, Some(ImageSource::Bytes(vec![1, 2, 3])));
1008    }
1009
1010    /// A fresh `ShellConfig` has no accent or selection background
1011    /// override — nothing that predates these fields changes behavior.
1012    #[test]
1013    fn shell_config_accent_defaults_to_none() {
1014        let config = ShellConfig::new("test", Vec::new());
1015        assert_eq!(config.active_accent, None);
1016        assert_eq!(config.selection_bg, None);
1017    }
1018
1019    /// `with_active_accent` and `with_selection_bg` store the colours
1020    /// verbatim for `build_shell_adapter` to forward to `AppShell`.
1021    #[test]
1022    fn shell_config_accent_flows_to_app_shell_via_adapter() {
1023        let accent = Color::rgb(100, 200, 50);
1024        let sel = Color::rgb(50, 50, 80);
1025        let config = ShellConfig::new("test", Vec::new())
1026            .with_active_accent(accent)
1027            .with_selection_bg(sel);
1028        assert_eq!(config.active_accent, Some(accent));
1029        assert_eq!(config.selection_bg, Some(sel));
1030    }
1031
1032    /// A fresh `ShellConfig` registers no bottom bands — nothing
1033    /// that predates this field changes behavior.
1034    #[test]
1035    fn shell_config_bottom_bands_defaults_to_empty() {
1036        let config = ShellConfig::new("test", Vec::new());
1037        assert!(config.bottom_bands.is_empty());
1038    }
1039
1040    /// `with_bottom_bands` stores the ordered list verbatim for
1041    /// `build_shell_adapter` to replay onto the `AppShell`.
1042    #[test]
1043    fn shell_config_with_bottom_bands_stores_the_list() {
1044        let bands = vec![
1045            BottomBand::new(WidgetId::new("band:qf"), 3.0),
1046            BottomBand::new(WidgetId::new("band:wildmenu"), 1.0),
1047        ];
1048        let config = ShellConfig::new("test", Vec::new()).with_bottom_bands(bands.clone());
1049        assert_eq!(config.bottom_bands, bands);
1050    }
1051
1052    /// `ShellContext::bottom_band_bounds`/`in_bottom_band` resolve
1053    /// against a band registered on the underlying `AppShell`, mirroring
1054    /// the existing `bottom_panel_bounds`/`in_bottom_panel` accessors.
1055    #[test]
1056    fn shell_context_resolves_bottom_band_bounds_and_hit_test() {
1057        let band_id = WidgetId::new("band:qf");
1058        let mut shell = AppShell::new(Vec::new(), 20.0)
1059            .with_bottom_bands(vec![BottomBand::new(band_id.clone(), 2.0)]);
1060        let layout = shell.layout(Rect::new(0.0, 0.0, 100.0, 40.0), 1.0);
1061        let c = ctx(&layout, &mut shell);
1062
1063        let bounds = c
1064            .bottom_band_bounds(&band_id)
1065            .expect("registered band has bounds");
1066        assert_eq!(bounds.height, 2.0);
1067        assert!(c.in_bottom_band(&band_id, bounds.x + 1.0, bounds.y + 0.5));
1068        assert!(!c.in_bottom_band(&band_id, bounds.x + 1.0, bounds.y - 1.0));
1069
1070        let unknown = WidgetId::new("band:nope");
1071        assert!(c.bottom_band_bounds(&unknown).is_none());
1072        assert!(!c.in_bottom_band(&unknown, 0.0, 0.0));
1073    }
1074
1075    #[test]
1076    fn window_edge_none_in_the_middle() {
1077        let layout = layout_for(100.0, 40.0);
1078        let mut shell = AppShell::new(Vec::new(), 20.0);
1079        assert_eq!(ctx(&layout, &mut shell).window_edge(50.0, 20.0, 4.0), None);
1080    }
1081
1082    #[test]
1083    fn window_edge_detects_each_side() {
1084        let layout = layout_for(100.0, 40.0);
1085        let mut shell = AppShell::new(Vec::new(), 20.0);
1086        let c = ctx(&layout, &mut shell);
1087        assert_eq!(c.window_edge(50.0, 0.0, 4.0), Some(ResizeEdge::North));
1088        assert_eq!(c.window_edge(50.0, 40.0, 4.0), Some(ResizeEdge::South));
1089        assert_eq!(c.window_edge(0.0, 20.0, 4.0), Some(ResizeEdge::West));
1090        assert_eq!(c.window_edge(100.0, 20.0, 4.0), Some(ResizeEdge::East));
1091    }
1092
1093    #[test]
1094    fn window_edge_detects_each_corner() {
1095        let layout = layout_for(100.0, 40.0);
1096        let mut shell = AppShell::new(Vec::new(), 20.0);
1097        let c = ctx(&layout, &mut shell);
1098        assert_eq!(c.window_edge(0.0, 0.0, 4.0), Some(ResizeEdge::NorthWest));
1099        assert_eq!(c.window_edge(100.0, 0.0, 4.0), Some(ResizeEdge::NorthEast));
1100        assert_eq!(c.window_edge(0.0, 40.0, 4.0), Some(ResizeEdge::SouthWest));
1101        assert_eq!(c.window_edge(100.0, 40.0, 4.0), Some(ResizeEdge::SouthEast));
1102    }
1103
1104    /// A point outside the window bounds entirely never resolves to an
1105    /// edge, even if it would be "within margin" of one in absolute terms.
1106    #[test]
1107    fn window_edge_none_outside_window() {
1108        let layout = layout_for(100.0, 40.0);
1109        let mut shell = AppShell::new(Vec::new(), 20.0);
1110        assert_eq!(ctx(&layout, &mut shell).window_edge(-2.0, 20.0, 4.0), None);
1111        assert_eq!(ctx(&layout, &mut shell).window_edge(50.0, 45.0, 4.0), None);
1112    }
1113
1114    /// A window narrower/shorter than `2 * margin` must still resolve
1115    /// deterministically (no panic) rather than requiring an ambiguous
1116    /// top-vs-bottom / left-vs-right tie-break.
1117    #[test]
1118    fn window_edge_degenerate_tiny_window_is_deterministic() {
1119        let layout = layout_for(3.0, 3.0);
1120        let mut shell = AppShell::new(Vec::new(), 20.0);
1121        let c = ctx(&layout, &mut shell);
1122        // Every point in a 3x3 window is within margin=4.0 of every edge;
1123        // just assert this doesn't panic and returns a corner (checked
1124        // first in the priority chain).
1125        assert_eq!(c.window_edge(1.5, 1.5, 4.0), Some(ResizeEdge::NorthWest));
1126    }
1127
1128    /// `ctx.shell_mut()` reaches the real `AppShell` — a `ShellApp`
1129    /// can drive shell state (e.g. a `Ctrl+B` toggle-sidebar binding)
1130    /// directly on the instance that's actually rendered, with no shadow
1131    /// copy required.
1132    #[test]
1133    fn shell_mut_toggles_the_real_app_shell() {
1134        let layout = layout_for(100.0, 40.0);
1135        let mut shell = AppShell::new(Vec::new(), 20.0);
1136        assert!(shell.sidebar_visible());
1137        {
1138            let c = ctx(&layout, &mut shell);
1139            c.shell_mut().toggle_sidebar();
1140        }
1141        assert!(!shell.sidebar_visible());
1142    }
1143
1144    /// `ctx.shell()` gives read access without requiring a mutable
1145    /// borrow, mirroring the read-only convenience accessors above.
1146    #[test]
1147    fn shell_read_reflects_current_state() {
1148        let layout = layout_for(100.0, 40.0);
1149        let mut shell = AppShell::new(Vec::new(), 20.0);
1150        shell.hide_sidebar();
1151        let c = ctx(&layout, &mut shell);
1152        assert!(!c.shell().sidebar_visible());
1153    }
1154}