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}