quadraui 0.2.0

Cross-platform Rust UI toolkit: write one AppLogic and run it unmodified on TUI, GTK4 (Linux), macOS, and Windows backends
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
//! Shared [`ShellAdapter`] that wraps a [`ShellApp`] in an [`AppShell`]
//! and implements [`AppLogic`] against it.
//!
//! Both the TUI and GTK shell runners instantiate this struct and pass it to
//! their respective `run()` entry point. All runtime logic — bottom-panel
//! height initialisation, panel-changed routing, resize fraction recalc, and
//! bottom-panel click dispatch — lives here once, avoiding per-backend drift.
//!
//! Backend-specific differences (line-height units, pixel vs cell sizes) are
//! encapsulated in [`crate::Backend`] method calls; the adapter only uses the
//! trait surface.

use std::cell::RefCell;

use crate::compose::app_shell::{AppShell, AppShellEvent, AppShellLayout};
use crate::compose::bottom_panel::{BottomPanelController, BottomPanelEvent};
use crate::event::{Point, Rect};
use crate::runner::{AppLogic, Reaction};
use crate::shell::{ShellApp, ShellConfig, ShellContext};
use crate::types::WidgetId;
use crate::{ActivityBarEvent, Backend, MouseButton, UiEvent};

/// Adapts a [`ShellApp`] into an [`AppLogic`] by composing it with an
/// [`AppShell`] and an optional [`BottomPanelController`].
///
/// The struct and its fields are `pub(crate)` so only the in-crate shell
/// runners can construct it. Downstream consumers drive the shell through
/// [`crate::tui::shell_runner::run_with_shell`] /
/// [`crate::gtk::shell_runner::run_with_shell`] + [`ShellApp`] callbacks —
/// they never touch the adapter directly.
pub(crate) struct ShellAdapter<A: ShellApp> {
    pub(crate) app: A,
    pub(crate) shell: AppShell,
    pub(crate) _last_layout: Option<AppShellLayout>,
    pub(crate) active_panel_id: Option<WidgetId>,
    /// Optional bottom-panel controller. Present when `ShellConfig.bottom_panel`
    /// was `Some`. Wrapped in `RefCell` because `render(&self)` must mutate it
    /// to cache hit regions and render tab content.
    pub(crate) bottom_panel: Option<RefCell<BottomPanelController>>,
    /// Editor font override from `ShellConfig::with_editor_font`
    /// (family, size_pt), applied once in `setup()` via
    /// `Backend::set_editor_font`. `None` leaves the backend's own
    /// default font in place.
    pub(crate) editor_font: Option<(String, f32)>,
}

impl<A: ShellApp> ShellAdapter<A> {
    /// Construct a new [`ShellAdapter`] from its parts. Only called by the
    /// in-crate TUI / GTK shell runners; downstream consumers should use
    /// `run_with_shell` rather than building an adapter directly.
    pub(crate) fn new(
        app: A,
        shell: AppShell,
        active_panel_id: Option<WidgetId>,
        bottom_panel: Option<RefCell<BottomPanelController>>,
        editor_font: Option<(String, f32)>,
    ) -> Self {
        Self {
            app,
            shell,
            _last_layout: None,
            active_panel_id,
            bottom_panel,
            editor_font,
        }
    }

    /// Apply a pending [`ShellApp::take_requested_panel`] switch, if any.
    /// Updates the underlying `AppShell`'s active panel (ActivityBar
    /// highlight + sidebar header), mirrors it into `active_panel_id`, and
    /// re-notifies the app via `on_shell_event_ctx` — the same notification a
    /// mouse-driven switch produces. Returns `true` iff a switch was
    /// applied (the caller should redraw).
    fn apply_requested_panel(&mut self, backend: &mut dyn Backend, area: Rect) -> bool {
        let Some(panel_id) = self.app.take_requested_panel() else {
            return false;
        };
        self.shell.show_panel(&panel_id);
        self.active_panel_id = Some(panel_id);
        self.notify_shell_event(backend, area, &AppShellEvent::PanelChanged { panel_id });
        true
    }

    /// Build a [`ShellContext`] for the current dispatch and notify
    /// [`ShellApp::on_shell_event_ctx`] with it, then return
    /// `Reaction::Redraw` — the shape every `AppShellEvent` arm in
    /// [`Self::handle`] needs.
    ///
    /// Built fresh per call (rather than once before the caller's match)
    /// so it picks up any state the caller already applied to
    /// `self.active_panel_id` / `self.shell` for this event — the
    /// `PanelChanged` arm, for instance, sets `active_panel_id` before
    /// calling this, and the context must reflect that, not the
    /// pre-update value.
    fn notify_shell_event(
        &mut self,
        backend: &mut dyn Backend,
        area: Rect,
        ev: &AppShellEvent,
    ) -> Reaction {
        let layout = self.shell.layout(area, backend.chrome_line_height());
        let sidebar_visible = self.shell.sidebar_visible();
        let ctx = ShellContext::new(
            self.active_panel_id.as_ref(),
            sidebar_visible,
            &layout,
            &mut self.shell,
        );
        self.app.on_shell_event_ctx(ev, &ctx);
        Reaction::Redraw
    }
}

/// Assemble the [`AppShell`] + [`ShellAdapter`] stack for a [`ShellApp`].
///
/// This is the single source of truth for shell construction across every
/// backend — the live runners ([`crate::tui::shell_runner::run_with_shell`],
/// [`crate::gtk::shell_runner::run_with_shell`]) and the headless test
/// drivers ([`crate::tui::testing::driver_with_shell`],
/// [`crate::gtk::testing::driver_with_shell`]) all call this so they cannot
/// drift apart as [`ShellConfig`] grows. It takes only
/// backend-neutral inputs (`ShellConfig` + the app), so a future backend
/// (macOS, win) adopts it by calling it directly — no per-backend copy
/// needed.
pub(crate) fn build_shell_adapter<A: ShellApp + 'static>(
    app: A,
    config: ShellConfig,
) -> ShellAdapter<A> {
    let editor_font = config.editor_font.clone();
    let mut shell = AppShell::new(config.panels, config.default_sidebar_width)
        .with_bottom_items(config.bottom_items)
        .with_min_width(config.min_sidebar_width)
        .with_max_width(config.max_sidebar_width)
        .with_activity_bar_width(config.activity_bar_width)
        .with_position(config.position)
        .with_bottom_bands(config.bottom_bands);

    // Replay every `ShellConfig::with_panel_icon` override onto the
    // built `AppShell`. `AppShell::with_panel_icon` is a consuming
    // `mut self -> Self` builder, and this is the only place an `AppShell`
    // is constructed from a `ShellConfig`. Covers both `config.panels` and
    // `config.bottom_items` — `AppShell::with_panel_icon` does not
    // distinguish the two.
    for (id, icon) in config.panel_icons {
        shell = shell.with_panel_icon(id, icon);
    }

    // Fixed-pixel override wins over the line-height multiple set just
    // above — see `AppShell::with_activity_bar_width_px`.
    if let Some(width_px) = config.activity_bar_width_px {
        shell = shell.with_activity_bar_width_px(width_px);
    }

    // Configure the height unconditionally and let `has_title_bar` control
    // only initial visibility. Gating the whole call on `has_title_bar`
    // (as before) silently discarded `title_bar_height_lh` whenever the
    // bar started hidden — exactly the case where it matters later: a
    // consumer that reveals the bar at runtime via `set_title_bar_visible`
    // got the `AppShell` struct default (1.5 line-heights) instead of the
    // height it configured.
    shell = shell.with_title_bar(config.title_bar_height_lh);
    if let Some(min_px) = config.title_bar_min_px {
        shell = shell.with_title_bar_min_px(min_px);
    }
    shell.set_title_bar_visible(config.has_title_bar);

    // Bottom panel does NOT share this defect, despite the parallel shape:
    // `AppShell` has no runtime setter that flips `has_bottom_panel` alone
    // the way `set_title_bar_visible` flips `has_title_bar`. The only way
    // to enable it is `with_bottom_panel(height)`, which sets the flag and
    // height together — `show_bottom_panel`/`hide_bottom_panel`/
    // `toggle_bottom_panel` only touch the independent `bottom_panel_visible`
    // flag and are no-ops while `has_bottom_panel` is false. So gating this
    // call on `config.has_bottom_panel` cannot later reveal a stale height.
    if config.has_bottom_panel {
        shell = shell
            .with_bottom_panel(config.bottom_panel_height_lh)
            .with_bottom_panel_limits(
                config.min_bottom_panel_height_lh,
                config.max_bottom_panel_height_lh,
            );
    }
    if config.has_command_line {
        shell = shell.with_command_line();
    }
    if config.has_status_bar {
        shell = shell.with_status_bar();
    }
    if let Some(height_px) = config.status_bar_height_px {
        shell = shell.with_status_bar_height_px(height_px);
    }
    if config.status_bar_under_main_column {
        shell = shell.with_status_bar_under_main_column();
    }

    // When a BottomPanelConfig is present, enable the bottom panel region
    // and create the controller. Initial height is set in setup() once we
    // have the backend's viewport + line_height.
    let bottom_panel = if let Some(bp_config) = config.bottom_panel {
        // Enable the panel with a generous initial height; setup() will
        // recalculate from height_fraction once the backend is ready.
        shell = shell
            .with_bottom_panel(10.0)
            .with_bottom_panel_limits(3.0, 40.0);
        Some(RefCell::new(BottomPanelController::new(bp_config)))
    } else {
        None
    };

    let active_panel_id = shell.active_panel_id().cloned();

    ShellAdapter::new(app, shell, active_panel_id, bottom_panel, editor_font)
}

impl<A: ShellApp> AppLogic for ShellAdapter<A> {
    type AreaId = ();

    fn setup(&mut self, backend: &mut dyn Backend) {
        // Apply the configured editor font before anything else so
        // it's in place for the app's own `setup()` and the very first
        // frame. `Backend::set_editor_font` no-ops on backends without a
        // font concept (TUI).
        if let Some((family, size_pt)) = &self.editor_font {
            backend.set_editor_font(family, *size_pt);
        }

        // Initialise bottom panel height from height_fraction on first setup.
        if let Some(ref ctrl_cell) = self.bottom_panel {
            let ctrl = ctrl_cell.borrow();
            let viewport = backend.viewport();
            let lh = backend.line_height().max(1.0);
            let panel_lh = (viewport.height / lh * ctrl.height_fraction).clamp(3.0, 30.0);
            drop(ctrl);
            self.shell.set_bottom_panel_height(panel_lh);
        }
        self.app.setup(backend);
    }

    fn render(&self, backend: &mut dyn Backend, _area: ()) {
        self.app.pre_render(backend);
        let viewport = backend.viewport();
        let area = Rect::new(0.0, 0.0, viewport.width, viewport.height);
        let layout = self.shell.render(backend, area);

        if let Some(ref ctrl_cell) = self.bottom_panel {
            let maximised = ctrl_cell.borrow().maximised;

            if maximised {
                // Expand panel to cover main content area too.
                let main = layout.main_content_bounds;
                let bp_h = layout.bottom_panel_bounds.map(|r| r.height).unwrap_or(0.0);
                let full_panel = Rect::new(main.x, main.y, main.width, main.height + bp_h);
                ctrl_cell.borrow_mut().render(backend, full_panel);

                // Pass a layout with zeroed main area so app skips rendering it.
                let maximised_layout = AppShellLayout {
                    main_content_bounds: Rect::new(
                        main.x,
                        main.y + main.height + bp_h,
                        main.width,
                        0.0,
                    ),
                    bottom_panel_bounds: Some(full_panel),
                    ..layout
                };
                self.app.render_content(backend, &maximised_layout);
            } else if let Some(panel_bounds) = layout.bottom_panel_bounds {
                ctrl_cell.borrow_mut().render(backend, panel_bounds);
                self.app.render_content(backend, &layout);
            } else {
                self.app.render_content(backend, &layout);
            }
        } else {
            self.app.render_content(backend, &layout);
        }

        // Independently-gated bottom bands render after main content,
        // in bottom-up stacking order. No-op for apps that never called
        // `ShellConfig::with_bottom_bands` (empty by default).
        for (id, bounds) in self.shell.bottom_band_bounds() {
            self.app.render_bottom_band(backend, &id, bounds);
        }
    }

    fn handle(&mut self, event: UiEvent, backend: &mut dyn Backend) -> Reaction {
        let viewport = backend.viewport();
        let area = Rect::new(0.0, 0.0, viewport.width, viewport.height);

        // On viewport resize, recalculate the bottom panel height from fraction.
        if matches!(event, UiEvent::WindowResized { .. }) {
            if let Some(ref ctrl_cell) = self.bottom_panel {
                let height_fraction = ctrl_cell.borrow().height_fraction;
                let lh = backend.line_height().max(1.0);
                let panel_lh = (viewport.height / lh * height_fraction).clamp(3.0, 30.0);
                self.shell.set_bottom_panel_height(panel_lh);
            }
        }

        // An app-level modal (MenuSystem dropdown, Palette, Dialog, ...) can
        // visually overlap shell chrome (activity bar, sidebar, dividers,
        // bottom-panel grip). `AppShell::handle` below — and the
        // bottom-panel tab-strip click check further down — both hit-test
        // purely on screen position, so an open modal that overlaps chrome
        // would otherwise have its `MouseDown` / `MouseUp` silently
        // swallowed by the chrome. Consult the backend's
        // `ModalStack` directly — the same source `dispatch_click` /
        // `dispatch_mouse_down` (`crate::dispatch`) used to tag the event's
        // `widget` field — and route straight to the app when the position
        // lands inside an open modal. This upholds the modal-stack contract
        // ("events landing inside an open modal cannot fall through to the
        // base layer", see `crate::dispatch` module docs) for shell chrome
        // too, not just base-layer widgets.
        if let Some(position) = mouse_event_position(&event) {
            let modal_hit = {
                let stack_rc = backend.modal_stack_handle();
                let stack = stack_rc.borrow();
                stack.hit_test(position).is_some()
            };
            if modal_hit {
                let layout = self.shell.layout(area, backend.chrome_line_height());
                let sidebar_visible = self.shell.sidebar_visible();
                let ctx = ShellContext::new(
                    self.active_panel_id.as_ref(),
                    sidebar_visible,
                    &layout,
                    &mut self.shell,
                );
                return self.app.handle(event, backend, &ctx);
            }
        }

        let shell_ev = self.shell.handle(&event, backend, area);
        match &shell_ev {
            AppShellEvent::PanelChanged { panel_id } => {
                self.active_panel_id = Some(*panel_id);
                return self.notify_shell_event(backend, area, &shell_ev);
            }
            AppShellEvent::SidebarHidden => {
                return self.notify_shell_event(backend, area, &shell_ev);
            }
            AppShellEvent::SidebarResized { .. } => {
                return self.notify_shell_event(backend, area, &shell_ev);
            }
            AppShellEvent::BottomPanelResized { new_height } => {
                // Forward as BottomPanelEvent::Resized to app when a controller is present.
                // Also update `height_fraction` so a subsequent WindowResized doesn't snap
                // the panel back to its initial proportion: the drag is the new intent.
                if let Some(ref ctrl_cell) = self.bottom_panel {
                    let new_fraction = resized_to_fraction(*new_height, viewport.height);
                    ctrl_cell.borrow_mut().height_fraction = new_fraction;
                    let ev = BottomPanelEvent::Resized(*new_height);
                    self.app.on_bottom_panel_event(&ev);
                }
                return self.notify_shell_event(backend, area, &shell_ev);
            }
            AppShellEvent::BottomPanelHidden => {
                return self.notify_shell_event(backend, area, &shell_ev);
            }
            AppShellEvent::BottomItemClicked { .. } => {
                return self.notify_shell_event(backend, area, &shell_ev);
            }
            AppShellEvent::Consumed => return Reaction::Redraw,
            AppShellEvent::Ignored => {}
        }

        // ── Built-in activity-bar keyboard navigation ─────────────────────
        //
        // `AppShell::build_activity_bar()` (compose/app_shell.rs) already
        // implements the full keyboard-cursor API (select_next/prev,
        // activate_selected, ...) and the backends already translate a
        // `KeyPressed` into `UiEvent::ActivityBar(id, KeyPressed { key, .. })`
        // once `activity_keyboard_focused()` is true — see
        // `TuiBackend::apply_dispatch`. But `ShellApp` consumers had no path
        // to reach any of it (the raw `AppLogic` pattern in
        // `examples/common/shell_app.rs` owns `AppShell` directly and wires
        // this itself). Mirror that pattern here, once, for every `ShellApp`
        // consumer: intercept the synthesized `ActivityBar` event before it
        // would otherwise reach `ShellApp::handle` (which never sees this
        // event type — there is nothing for a consumer to opt into or
        // conflict with) and drive `AppShell` directly. Resulting
        // `AppShellEvent`s are reported through the existing
        // `on_shell_event_ctx` notification, exactly like a mouse-driven panel
        // switch.
        if let UiEvent::ActivityBar(_, ActivityBarEvent::KeyPressed { ref key, .. }) = event {
            return match key.as_str() {
                "j" | "Down" => {
                    self.shell.activity_select_next();
                    Reaction::Redraw
                }
                "k" | "Up" => {
                    self.shell.activity_select_prev();
                    Reaction::Redraw
                }
                "l" | "Enter" | " " | "Space" => {
                    if let Some(ev) = self.shell.activity_activate_selected() {
                        self.shell.set_activity_keyboard_focused(false);
                        if let AppShellEvent::PanelChanged { ref panel_id } = ev {
                            self.active_panel_id = Some(*panel_id);
                        }
                        self.notify_shell_event(backend, area, &ev);
                    }
                    Reaction::Redraw
                }
                "Escape" | "h" | "Left" => {
                    self.shell.set_activity_keyboard_focused(false);
                    Reaction::Redraw
                }
                _ => Reaction::Continue,
            };
        }

        // If the shell didn't consume the event, check the bottom panel tab strip.
        if let Some(ref ctrl_cell) = self.bottom_panel {
            if let UiEvent::MouseDown {
                button: MouseButton::Left,
                position,
                ..
            } = &event
            {
                let bp_ev = ctrl_cell.borrow_mut().handle_click(position.x, position.y);
                if let Some(bp_ev) = bp_ev {
                    // Auto-hide the panel once its last tab is closed.
                    if matches!(bp_ev, BottomPanelEvent::TabClosed(_))
                        && ctrl_cell.borrow().tabs().is_empty()
                    {
                        self.shell.hide_bottom_panel();
                    }
                    self.app.on_bottom_panel_event(&bp_ev);
                    return Reaction::Redraw;
                }
            }
        }

        let layout = self.shell.layout(area, backend.chrome_line_height());
        let sidebar_visible = self.shell.sidebar_visible();
        let ctx = ShellContext::new(
            self.active_panel_id.as_ref(),
            sidebar_visible,
            &layout,
            &mut self.shell,
        );
        let mut reaction = self.app.handle(event, backend, &ctx);

        // The app may have called `ctx.request_activity_keyboard_focus()`
        // (e.g. in response to its own `Tab` / `Ctrl+W` binding) to enter
        // the keyboard-cursor mode the block above then drives.
        if ctx.take_activity_focus_requested() {
            self.shell.set_activity_keyboard_focused(true);
            self.shell.activity_set_cursor(0);
            if reaction == Reaction::Continue {
                reaction = Reaction::Redraw;
            }
        }

        if self.apply_requested_panel(backend, area) {
            reaction = Reaction::Redraw;
        }

        reaction
    }

    fn tick(&mut self, backend: &mut dyn Backend) -> Reaction {
        let mut reaction = self.app.tick(backend);
        // Timer-driven work (background poll completing, a queued command
        // finishing) can also want to land the app on a different panel —
        // poll the same hook `handle()` does so tick-driven switches aren't
        // a second, forgotten code path.
        let viewport = backend.viewport();
        let area = Rect::new(0.0, 0.0, viewport.width, viewport.height);
        if self.apply_requested_panel(backend, area) && reaction == Reaction::Continue {
            reaction = Reaction::Redraw;
        }
        reaction
    }
}

/// Extract the screen position from a `MouseDown` / `MouseUp` event, or
/// `None` for any other event variant.
///
/// Used to consult the backend's [`crate::modal_stack::ModalStack`] before
/// letting shell chrome (`AppShell::handle`) hit-test the same position.
fn mouse_event_position(event: &UiEvent) -> Option<Point> {
    match event {
        UiEvent::MouseDown { position, .. } => Some(*position),
        UiEvent::MouseUp { position, .. } => Some(*position),
        _ => None,
    }
}

/// Recover the bottom-panel `height_fraction` after a drag-resize.
///
/// `new_height` arrives in the backend's native units (cells for TUI, pixels
/// for GTK) from `AppShellEvent::BottomPanelResized`; `viewport_height` is
/// in the same native units (from `Backend::viewport()`). Dividing yields the
/// proportion of the viewport the panel now occupies, clamped to `[0, 1]`.
///
/// Centralised so the in-line `handle()` branch and the regression test
/// stay in lockstep: a missed update here is what made the panel snap back
/// to the initial 30 % on the next `WindowResized`.
fn resized_to_fraction(new_height: f32, viewport_height: f32) -> f32 {
    let h = viewport_height.max(1.0);
    (new_height / h).clamp(0.0, 1.0)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::compose::app_shell::AppShellLayout;

    struct NoopApp;

    impl ShellApp for NoopApp {
        fn render_content(&self, _backend: &mut dyn Backend, _layout: &AppShellLayout) {}

        fn handle(
            &mut self,
            _event: UiEvent,
            _backend: &mut dyn Backend,
            _ctx: &ShellContext,
        ) -> Reaction {
            Reaction::Continue
        }
    }

    /// `title_bar_height_lh` must survive a start-hidden title bar.
    ///
    /// When the title bar starts hidden, `set_title_bar_visible(true)` must
    /// respect the configured height rather than fall back to the default.
    #[test]
    fn title_bar_height_lh_survives_start_hidden_then_shown() {
        let mut config = ShellConfig::new("t", Vec::new());
        config.has_title_bar = false;
        config.title_bar_height_lh = 1.0;

        let mut adapter = build_shell_adapter(NoopApp, config);
        assert!(!adapter.shell.title_bar_visible());

        adapter.shell.set_title_bar_visible(true);
        let layout = adapter.shell.layout(Rect::new(0.0, 0.0, 80.0, 24.0), 1.0);
        let tb = layout.title_bar_bounds.expect("row now reserved");
        assert_eq!(
            tb.height, 1.0,
            "configured height (1.0 lh) must be honoured, not the 1.5 lh AppShell default"
        );
    }

    /// Companion case: `has_title_bar: true` consumers are unaffected —
    /// same height, visible from the first frame, no behaviour change.
    #[test]
    fn title_bar_height_lh_honoured_when_started_visible() {
        let mut config = ShellConfig::new("t", Vec::new());
        config.has_title_bar = true;
        config.title_bar_height_lh = 1.0;

        let adapter = build_shell_adapter(NoopApp, config);
        assert!(adapter.shell.title_bar_visible());

        let layout = adapter.shell.layout(Rect::new(0.0, 0.0, 80.0, 24.0), 1.0);
        let tb = layout
            .title_bar_bounds
            .expect("row reserved from construction");
        assert_eq!(tb.height, 1.0);
    }

    /// `ShellConfig::with_title_bar_min_px` reaches the built
    /// `AppShell` so a floor set on the config actually floors the
    /// resolved title-bar height, not just stores a field nobody reads.
    #[test]
    fn title_bar_min_px_reaches_the_built_app_shell() {
        let config = ShellConfig::new("t", Vec::new())
            .with_title_bar(1.5)
            .with_title_bar_min_px(34.0);

        let adapter = build_shell_adapter(NoopApp, config);
        // `line_height = 6.0` resolves `1.5 * 6.0 = 9.0`, well under the
        // 34.0 floor — the floor must win.
        let layout = adapter.shell.layout(Rect::new(0.0, 0.0, 80.0, 24.0), 6.0);
        let tb = layout.title_bar_bounds.expect("row reserved");
        assert_eq!(tb.height, 34.0);
    }

    /// `ShellConfig::with_status_bar_height_px` reaches the built
    /// `AppShell` so a pixel height set on the config actually sizes the
    /// resolved status bar, not just stores a field nobody reads.
    #[test]
    fn status_bar_height_px_reaches_the_built_app_shell() {
        let config = ShellConfig::new("t", Vec::new())
            .with_status_bar()
            .with_status_bar_height_px(30.0);

        let adapter = build_shell_adapter(NoopApp, config);
        // `line_height = 6.0` would resolve to `6.0` via the one-line-height
        // fallback — the 30.0 px override must win instead.
        let layout = adapter.shell.layout(Rect::new(0.0, 0.0, 80.0, 24.0), 6.0);
        let sb = layout.status_bar_bounds.expect("row reserved");
        assert_eq!(sb.height, 30.0);
    }

    /// A `ShellConfig` with no explicit px override at all — matching the
    /// `appshell_demo` example — resolves to VS Code's own chrome
    /// dimensions (48px activity bar, 35px tab/title bar, 22px status bar)
    /// on a GUI backend (`line_height > 1.0`), with no app configuration
    /// beyond enabling the three bands and zero app-side pixel tuning.
    ///
    /// Drives `AppShell::render` (the paint path, through
    /// [`crate::testing::RecordingBackend`]) rather than the hit-test-only
    /// `AppShell::layout`, so this pins the dimensions a GUI backend
    /// actually paints, not just what a caller without a `Backend` in hand
    /// would compute.
    #[test]
    fn default_shell_config_measures_vs_code_chrome_px_on_a_gui_backend() {
        let config = ShellConfig::new("t", Vec::new())
            .with_title_bar(1.0)
            .with_status_bar();

        let adapter = build_shell_adapter(NoopApp, config);
        // A GUI-sized line height (20px) where a naive line-height-derived
        // title bar (`1.0 * 20.0 = 20.0`) would fall well short of the 35px
        // floor, proving the px default — not a lucky line-height
        // coincidence — is what produces 35.0.
        let mut backend = crate::testing::RecordingBackend::with_viewport(
            crate::Viewport {
                width: 800.0,
                height: 600.0,
                scale: 1.0,
            },
            20.0,
            8.0,
        );
        let layout = adapter
            .shell
            .render(&mut backend, Rect::new(0.0, 0.0, 800.0, 600.0));

        assert_eq!(layout.activity_bar_bounds.width, 48.0);
        let tb = layout.title_bar_bounds.expect("title bar row reserved");
        assert_eq!(tb.height, 35.0);
        let sb = layout.status_bar_bounds.expect("status bar row reserved");
        assert_eq!(sb.height, 22.0);
    }

    /// `ShellConfig::with_panel_icon` overrides are applied to the underlying
    /// `AppShell` for both top panels and bottom items.
    #[test]
    fn build_shell_adapter_replays_panel_icon_overrides() {
        use crate::compose::app_shell::PanelDefinition;
        use crate::types::Icon;

        let panel_id = WidgetId::new("panel:explorer");
        let bottom_id = WidgetId::new("panel:settings");
        let config = ShellConfig::new(
            "t",
            vec![PanelDefinition {
                id: panel_id.clone(),
                icon: "X".to_string(),
                tooltip: "Explorer".to_string(),
                title: "EXPLORER".to_string(),
            }],
        )
        .with_bottom_items(vec![PanelDefinition {
            id: bottom_id.clone(),
            icon: "Y".to_string(),
            tooltip: "Settings".to_string(),
            title: "SETTINGS".to_string(),
        }])
        .with_panel_icon(panel_id, Icon::new("\u{f07c}", "\u{25c6}"))
        .with_panel_icon(bottom_id, Icon::new("\u{f013}", "\u{25a0}"));

        let adapter = build_shell_adapter(NoopApp, config);
        let bar = adapter.shell.build_activity_bar();
        assert_eq!(
            bar.top_items[0].icon,
            Icon::new("\u{f07c}", "\u{25c6}"),
            "top-panel override must reach the built AppShell, not just ShellConfig"
        );
        assert_eq!(
            bar.bottom_items[0].icon,
            Icon::new("\u{f013}", "\u{25a0}"),
            "bottom-item override must reach the built AppShell too (#683 parity)"
        );
    }

    /// `ShellConfig::bottom_bands` reaches the built `AppShell` —
    /// before this call was added to `build_shell_adapter`, a
    /// `ShellApp`-based consumer had no path to the new N-band layout at
    /// all (only a direct `AppShell::with_bottom_bands` chain, i.e.
    /// quadraui's own tests/examples).
    #[test]
    fn build_shell_adapter_replays_bottom_bands() {
        use crate::compose::app_shell::BottomBand;

        let band_id = WidgetId::new("band:qf");
        let config = ShellConfig::new("t", Vec::new())
            .with_bottom_bands(vec![BottomBand::new(band_id.clone(), 3.0)]);

        let adapter = build_shell_adapter(NoopApp, config);
        assert_eq!(adapter.shell.bottom_bands().len(), 1);
        assert_eq!(adapter.shell.bottom_bands()[0].id, band_id);
        assert_eq!(adapter.shell.bottom_bands()[0].height_lh, 3.0);
    }

    #[test]
    fn resized_to_fraction_recovers_proportion() {
        // Drag the panel to 18 cells in a 60-cell viewport → 30 %.
        assert!((resized_to_fraction(18.0, 60.0) - 0.30).abs() < 1e-5);
        // Drag to 36 cells in the same viewport → 60 %.
        assert!((resized_to_fraction(36.0, 60.0) - 0.60).abs() < 1e-5);
    }

    #[test]
    fn resized_to_fraction_clamps_negative_to_zero() {
        // Negative heights would otherwise propagate a bogus fraction
        // into the next viewport recalc.
        assert_eq!(resized_to_fraction(-5.0, 60.0), 0.0);
    }

    #[test]
    fn resized_to_fraction_clamps_over_full_viewport_to_one() {
        // If the drag math overshoots the viewport, cap at 100 %.
        assert_eq!(resized_to_fraction(120.0, 60.0), 1.0);
    }

    #[test]
    fn resized_to_fraction_handles_zero_viewport_safely() {
        // A zero-height viewport (pre-init / minimised) must not divide
        // by zero — the clamp keeps the output finite.
        let f = resized_to_fraction(10.0, 0.0);
        assert!(f.is_finite());
    }

    /// Regression for the bug: dragging the panel to a new height must
    /// update `height_fraction` so a subsequent `WindowResized` reuses
    /// the dragged proportion instead of snapping back to the initial
    /// 30 %. This exercises the same arithmetic the `handle()` branch
    /// uses for `AppShellEvent::BottomPanelResized`.
    #[test]
    fn drag_then_window_resize_preserves_user_intent() {
        // Initial state: viewport 100 cells tall, fraction 0.30
        // → panel 30 cells.
        let mut height_fraction = 0.30_f32;
        let viewport_h_initial = 100.0_f32;
        let initial_panel = viewport_h_initial * height_fraction;
        assert!((initial_panel - 30.0).abs() < 1e-4);

        // User drags to 60 cells. The adapter updates the fraction.
        let dragged_native = 60.0_f32;
        height_fraction = resized_to_fraction(dragged_native, viewport_h_initial);
        assert!((height_fraction - 0.60).abs() < 1e-5);

        // Window then resizes to 50 cells tall. The adapter recalculates
        // panel height from the (now-updated) fraction: 50 * 0.6 = 30 cells.
        let viewport_h_resized = 50.0_f32;
        let recalculated_panel = viewport_h_resized * height_fraction;
        assert!(
            (recalculated_panel - 30.0).abs() < 1e-4,
            "post-resize panel height ({}) should reflect dragged proportion (0.60), \
             not initial 0.30 (which would give {})",
            recalculated_panel,
            viewport_h_resized * 0.30,
        );
    }
}