quadraui 0.1.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
//! macOS rasteriser for [`crate::Palette`].
//!
//! Content painting (background, border, title/query/item rows,
//! scrollbar, create row, preview pane) moved to the shared
//! [`crate::primitives::palette::native_surface_paint::paint`] (#1076,
//! `PaintSurface` Phase 4 slice 3/8) — see that fn's module doc for
//! what's shared. Notably, this migration upgrades macOS's item rows
//! from plain-fg-only text (this module's pre-migration "Scope
//! omissions" below, kept for the historical record) to the same
//! match-position highlighting, icon rendering, query cursor, and
//! `PaletteMode::Input` suppression every other backend now shares.
//!
//! Mirrors [`crate::gtk::palette::draw_palette`]: bordered box, title
//! row, query input with cursor, separator, scrollable item list with
//! selection highlight, optional pinned create-action row, optional
//! preview pane, optional scrollbar.
//!
//! ## Scope omissions (historical, pre-#1076)
//!
//! - **Match-position highlighting** — `PaletteItem.match_positions`
//!   per-character fg highlights were deferred with the unified
//!   text-attribute pass. Items rendered in plain fg. Fixed by #1076.
//! - **Preview pane content** — preview lines rendered as plain text;
//!   syntax-highlighted spans landed with the same pass. Fixed by #1076
//!   (preview lines now honour each span's own `fg`).

use core_graphics::sys::CGContextRef;
use core_text::font::CTFont;

use crate::primitives::palette::{native_surface_paint, Palette, PaletteLayout};
use crate::theme::Theme;

/// Compute the macOS palette layout (used by both paint and host
/// hit-testing). Thin `f64`-converting wrapper over
/// [`crate::primitives::palette::native_surface_paint::layout`] (#1076)
/// — the canonical geometry formula every backend now shares instead of
/// once-per-backend copies (see that module's doc for the three-way
/// drift this closes: `query_height`, row flooring, scrollbar width).
///
/// Coordinate frame: all returned bounds (`title_bounds`,
/// `query_bounds`, `visible_items.bounds`, `create_bounds`,
/// `preview_bounds`, `scrollbar.{track,thumb}`, `hit_regions`) are in
/// **palette-local** coords (origin at 0, 0). Hosts must subtract
/// the palette's `area.x` / `area.y` from absolute click coords before
/// calling `PaletteLayout::hit_test`. The `x` / `y` params are kept in
/// the signature for symmetry with `draw_palette` but do not affect
/// output.
///
/// See `quadraui/docs/decisions/DECISIONS.md` D-007, "Palette: deferred,
/// not missed" for why `Backend::palette_layout` didn't ship until #818
/// (resolved before #1076).
pub fn mac_palette_layout(
    palette: &Palette,
    _x: f64,
    _y: f64,
    w: f64,
    h: f64,
    line_height: f64,
) -> PaletteLayout {
    native_surface_paint::layout(w as f32, h as f32, palette, line_height as f32).0
}

/// Draw a [`Palette`] modal into `(x, y, w, h)` on `ctx`.
///
/// `nerd_fonts_enabled` selects between item icons' Nerd-Font glyph and
/// ASCII fallback (issue #804), matching every other backend's
/// `draw_palette`.
///
/// # Safety
///
/// `ctx` must be a valid `CGContextRef` borrowed for the duration of
/// the call.
#[allow(clippy::too_many_arguments)]
pub unsafe fn draw_palette(
    ctx: CGContextRef,
    font: &CTFont,
    x: f64,
    y: f64,
    w: f64,
    h: f64,
    palette: &Palette,
    theme: &Theme,
    line_height: f64,
    nerd_fonts_enabled: bool,
) {
    if w < 20.0 || h < line_height * 4.0 {
        return;
    }

    let (palette_layout, rows_h) =
        native_surface_paint::layout(w as f32, h as f32, palette, line_height as f32);

    let area = crate::event::Rect::new(x as f32, y as f32, w as f32, h as f32);
    let mut surface = super::surface::CgSurface {
        ctx,
        font: Some(font),
    };
    native_surface_paint::paint(
        palette,
        area,
        &palette_layout,
        rows_h,
        line_height as f32,
        nerd_fonts_enabled,
        &mut surface,
        theme,
    );
}

#[cfg(test)]
mod tests {
    use super::super::headless::BitmapSurface;
    use super::super::text::make_font;
    use super::super::MacBackend;
    use super::*;
    use crate::event::{Rect as QRect, Viewport};
    use crate::primitives::palette::{PaletteHit, PaletteItem};
    use crate::types::{StyledText, WidgetId};
    use crate::Backend;

    const W: u32 = 400;
    const H: u32 = 240;

    fn font() -> CTFont {
        make_font("Menlo", 14.0).expect("Menlo installed")
    }

    fn sample_palette() -> Palette {
        Palette {
            id: WidgetId::new("pal"),
            title: "Commands".into(),
            query: "fo".into(),
            query_cursor: 2,
            items: vec![
                PaletteItem {
                    text: StyledText::plain("foo: open file"),
                    detail: Some(StyledText::plain("Ctrl+O")),
                    icon: None,
                    match_positions: vec![0, 1],
                    depth: 0,
                    expandable: false,
                    expanded: false,
                },
                PaletteItem {
                    text: StyledText::plain("foo: close tab"),
                    detail: None,
                    icon: None,
                    match_positions: vec![0, 1],
                    depth: 0,
                    expandable: false,
                    expanded: false,
                },
            ],
            selected_idx: 1,
            scroll_offset: 0,
            total_count: 25,
            has_focus: true,
            show_query: true,
            create_label: None,
            preview: None,
            mode: crate::primitives::palette::PaletteMode::List,
        }
    }

    fn paint_via_backend(palette: &Palette) -> BitmapSurface {
        let surface = BitmapSurface::new(W, H);
        surface.fill(0.0, 0.0, 0.0, 0.0);
        let mut backend = MacBackend::new();
        backend.set_current_font(font());
        // `set_current_font` derives `current_line_height` from the real
        // CTFont metrics (Menlo 14pt's ascent+descent+leading), which is
        // *not* exactly `MacBackend::new()`'s 16.0 default. The
        // pixel-exact drift-regression tests below
        // (`separator_paints_at_corrected_row_not_drifted_one`,
        // `scrollbar_track_width_is_six_px_not_eight`) compute their
        // expected geometry from a literal `16.0` — pin
        // `current_line_height` back to that value so what those tests
        // predict is what `draw_palette` actually paints with.
        backend.set_current_line_height(16.0);
        backend.begin_frame(Viewport::new(W as f32, H as f32, 1.0));
        backend.enter_frame_scope(surface.context_ptr(), |b| {
            b.draw_palette(QRect::new(0.0, 0.0, W as f32, H as f32), palette);
        });
        backend.end_frame();
        surface
    }

    #[test]
    fn palette_paints_surface_bg() {
        let p = sample_palette();
        let surface = paint_via_backend(&p);
        let theme = Theme::default();
        // Probe right edge of the popup, well below all rows.
        let (r, g, b, _) = surface.pixel(W - 4, H - 4);
        assert_eq!(
            (r, g, b),
            (theme.surface_bg.r, theme.surface_bg.g, theme.surface_bg.b),
        );
    }

    #[test]
    fn selected_item_paints_selected_bg() {
        let p = sample_palette();
        let surface = paint_via_backend(&p);
        let theme = Theme::default();
        let layout = mac_palette_layout(&p, 0.0, 0.0, W as f64, H as f64, 16.0);
        let sel = layout
            .visible_items
            .iter()
            .find(|v| v.item_idx == 1)
            .expect("selected item visible");
        let px = (sel.bounds.x + sel.bounds.width - 4.0) as u32;
        let py = (sel.bounds.y + sel.bounds.height / 2.0) as u32;
        let (r, g, b, _) = surface.pixel(px, py);
        assert_eq!(
            (r, g, b),
            (
                theme.selected_bg.r,
                theme.selected_bg.g,
                theme.selected_bg.b
            ),
        );
    }

    /// Regression for issue #503: `query_cursor` is a host-supplied byte
    /// offset with no guarantee it lands on a char boundary —
    /// `&palette.query[..query_cursor]` used to panic the moment a
    /// multibyte character sat left of the cursor.
    #[test]
    fn draw_palette_with_multibyte_cursor_does_not_panic() {
        let mut p = sample_palette();
        // "café🎉" — byte 4 sits inside the 2-byte 'é' (starts at byte 3).
        p.query = "café🎉".into();
        assert!(!p.query.is_char_boundary(4));
        p.query_cursor = 4;

        // Must not panic.
        let _surface = paint_via_backend(&p);
    }

    /// Regression for #1076: pre-migration, macOS's `draw_palette`
    /// painted every item row in plain `surface_fg` regardless of
    /// `match_positions` — this module's own "Scope omissions" doc used
    /// to name it directly. `paint`'s shared `matched_runs` splitter
    /// (ported from `win::palette`) now highlights matched characters in
    /// `match_fg` on every backend, including macOS. Scanning for
    /// `match_fg`-nearest ink (rather than an exact-equality probe at a
    /// guessed glyph coordinate) mirrors `win::palette`'s own
    /// `renders_and_highlights_match_positions` test, since real
    /// CoreText glyph placement inside the row box isn't predictable
    /// ahead of a live paint pass.
    #[test]
    fn selected_item_text_highlights_match_positions() {
        let p = sample_palette();
        let surface = paint_via_backend(&p);
        let theme = Theme::default();
        let layout = mac_palette_layout(&p, 0.0, 0.0, W as f64, H as f64, 16.0);
        // Item 0 ("foo: open file", match_positions [0, 1], not selected)
        // — plain fg row, so any match_fg ink on it is exclusively from
        // the highlight, not a selection-bg blend.
        let row = layout
            .visible_items
            .iter()
            .find(|v| v.item_idx == 0)
            .expect("item 0 visible")
            .bounds;
        let dist2 = |px: (u8, u8, u8), c: crate::types::Color| {
            let dr = px.0 as i32 - c.r as i32;
            let dg = px.1 as i32 - c.g as i32;
            let db = px.2 as i32 - c.b as i32;
            dr * dr + dg * dg + db * db
        };
        let found = (row.y as u32..(row.y + row.height) as u32).any(|y| {
            (row.x as u32..(row.x + row.width) as u32).any(|x| {
                let (r, g, b, _) = surface.pixel(x, y);
                let d_match = dist2((r, g, b), theme.match_fg);
                d_match < dist2((r, g, b), theme.surface_fg)
                    && d_match < dist2((r, g, b), theme.surface_bg)
            })
        });
        assert!(
            found,
            "expected match_fg-coloured pixels on item 0's row (match_positions highlight)"
        );
    }

    /// Regression for #1076: pre-migration, macOS's `draw_palette` never
    /// rendered `PaletteItem::icon` at all — only GTK did. `paint` now
    /// paints it via `PaintSurface::surface_draw_icon_glyph` on every
    /// backend.
    #[test]
    fn item_icon_paints_left_of_the_label() {
        let mut p = sample_palette();
        p.items[0].icon = Some(crate::types::Icon {
            glyph: "\u{f15b}".into(),
            fallback: "F".into(),
            color: None,
        });
        let surface = paint_via_backend(&p);
        let theme = Theme::default();
        let layout = mac_palette_layout(&p, 0.0, 0.0, W as f64, H as f64, 16.0);
        let row = layout
            .visible_items
            .iter()
            .find(|v| v.item_idx == 0)
            .expect("item 0 visible")
            .bounds;
        // Scan a narrow band right after the "  "/"▶ " prefix — the
        // fallback icon glyph "F" paints in surface_fg there when
        // nerd_fonts_enabled is false (`draw_palette`'s default). Any
        // non-background ink in that band is the icon (item 0's label
        // starts further right, past the icon + its 6px gutter).
        let dist2 = |px: (u8, u8, u8), c: crate::types::Color| {
            let dr = px.0 as i32 - c.r as i32;
            let dg = px.1 as i32 - c.g as i32;
            let db = px.2 as i32 - c.b as i32;
            dr * dr + dg * dg + db * db
        };
        let band_x0 = row.x as u32 + 8;
        let band_x1 = (row.x + 30.0) as u32;
        let found = (row.y as u32..(row.y + row.height) as u32).any(|y| {
            (band_x0..band_x1).any(|x| {
                let (r, g, b, _) = surface.pixel(x, y);
                dist2((r, g, b), theme.surface_bg) > 400
            })
        });
        assert!(
            found,
            "expected non-background ink in the icon gutter when PaletteItem::icon is set"
        );
    }

    /// Regression for #1076's **query_height** geometry drift: pre-#1076
    /// `mac_palette_layout` reserved a bare `line_height` for the query
    /// row; GTK alone reserved `line_height + 1.0`, the `+ 1.0` being the
    /// separator stroke that sits in the query band's **last pixel**
    /// (D-007 §2). `native_surface_paint::layout` now uses GTK's formula
    /// for every backend, so both the separator and the first item row
    /// sit one pixel lower than the pre-#1076 macOS geometry did.
    ///
    /// Paints through the real `MacBackend`/Core Graphics path and pins
    /// the separator to the reserved pixel — *inside* `query_bounds`, not
    /// at `query_bounds.y + query_bounds.height`, which is already the
    /// first item row (painting it there let the first row's fill
    /// overpaint it; see this same test's Windows twin).
    #[test]
    fn separator_paints_at_corrected_row_not_drifted_one() {
        let p = sample_palette();
        let surface = paint_via_backend(&p);
        let theme = Theme::default();
        // `paint_via_backend` pins `MacBackend::current_line_height` to
        // 16.0 (see that helper), so this is the exact value
        // `draw_palette` laid the separator out with.
        let line_height = 16.0_f32;
        let layout = mac_palette_layout(&p, 0.0, 0.0, W as f64, H as f64, 16.0);
        let qb = layout.query_bounds.expect("query bounds present");
        assert_eq!(
            qb.height,
            line_height + 1.0,
            "query band must reserve line_height + 1.0 (the +1 is the separator)",
        );

        let title_h = line_height; // native_surface_paint::layout: title_h = line_height
        let fixed_sep_y = (qb.y + qb.height - 1.0) as u32; // corrected: the reserved pixel
        let drifted_sep_y = fixed_sep_y - 1; // pre-#1076 macOS query band was 1px shorter
        assert_eq!(fixed_sep_y, (title_h + line_height) as u32);
        assert_eq!(
            layout.visible_items[0].bounds.y,
            qb.y + qb.height,
            "first item row starts immediately below the query band",
        );

        let is_border = |x: u32, y: u32| {
            let (r, g, b, _) = surface.pixel(x, y);
            (r, g, b) == (theme.border_fg.r, theme.border_fg.g, theme.border_fg.b)
        };
        let probe_x = W / 2;
        assert!(
            is_border(probe_x, fixed_sep_y),
            "expected separator ink at the corrected row {fixed_sep_y}",
        );
        assert!(
            !is_border(probe_x, drifted_sep_y),
            "separator painted at the pre-#1076 drifted row {drifted_sep_y} — \
             query_bounds.height must be line_height + 1.0, not line_height",
        );
        assert!(
            !is_border(probe_x, fixed_sep_y + 1),
            "separator painted on the first item row {} — it belongs in the \
             pixel query_bounds reserves for it, one row above",
            fixed_sep_y + 1,
        );
    }

    fn many_items(n: usize) -> Vec<PaletteItem> {
        (0..n)
            .map(|i| PaletteItem {
                text: StyledText::plain(format!("item {i}")),
                detail: None,
                icon: None,
                match_positions: vec![],
                depth: 0,
                expandable: false,
                expanded: false,
            })
            .collect()
    }

    /// Regression for #1076's **row-flooring** geometry drift: pre-#1076
    /// `mac_palette_layout` fed `Palette::layout` the full popup height,
    /// so its per-row clamp (`height.min(remaining)`) could hand back a
    /// clipped, partial-height last row whenever the available item-list
    /// height wasn't an exact multiple of `line_height`. GTK's pre-#1076
    /// formula floored the row count and fed a reduced `viewport_height`
    /// instead; `native_surface_paint::layout` now does that for every
    /// backend. `H` (240) and `line_height` (16) are chosen so the
    /// available item area (203px after title/query/bottom-inset) is
    /// *not* an exact multiple of 16 — the exact condition that produced
    /// a partial last row pre-#1076.
    #[test]
    fn item_list_never_shows_a_partial_last_row() {
        let mut p = sample_palette();
        p.items = many_items(40);
        p.selected_idx = 0;
        let line_height = 16.0_f32;
        let layout = mac_palette_layout(&p, 0.0, 0.0, W as f64, H as f64, line_height as f64);
        let last = layout
            .visible_items
            .last()
            .expect("some items should be visible");
        assert_eq!(
            last.bounds.height, line_height,
            "last visible row must be full height — a partial row means the \
             pre-#1076 unfloored-viewport drift regressed",
        );

        // Driver-tier: paint through the real backend and confirm no ink
        // bleeds into the row immediately below the floored boundary —
        // a partial 13th row would have painted glyph ink there.
        let surface = paint_via_backend(&p);
        let theme = Theme::default();
        let boundary_y = (last.bounds.y + last.bounds.height + 1.0) as u32;
        let probe_x = (last.bounds.x + 4.0) as u32;
        let (r, g, b, _) = surface.pixel(probe_x, boundary_y);
        assert_eq!(
            (r, g, b),
            (theme.surface_bg.r, theme.surface_bg.g, theme.surface_bg.b),
            "expected plain surface_bg just below the floored last row, not a \
             partially-clipped row's content",
        );
    }

    /// Regression for #1076's **scrollbar width** geometry drift:
    /// pre-#1076 macOS's own `mac_palette_layout` used `(8.0, 8.0)` for
    /// `(scrollbar_width, min_thumb_len)`; GTK and Windows had already
    /// agreed on `(6.0, 8.0)`. `native_surface_paint` adopts the
    /// GTK/Windows majority value for every backend, narrowing macOS's
    /// scrollbar track from 8px to 6px.
    #[test]
    fn scrollbar_track_width_is_six_px_not_eight() {
        let mut p = sample_palette();
        p.items = many_items(40);
        let line_height = 16.0_f64;
        let layout = mac_palette_layout(&p, 0.0, 0.0, W as f64, H as f64, line_height);
        let sb = layout
            .scrollbar
            .as_ref()
            .expect("scrollbar present when items overflow the viewport");
        assert_eq!(
            sb.track.width, 6.0,
            "scrollbar track width must be 6.0px (GTK/Windows pre-#1076 value), \
             not macOS's pre-#1076 8.0px",
        );

        // Driver-tier: paint through the real backend and probe the
        // columns that tell a 6px track apart from macOS's pre-#1076 8px
        // one — the two columns immediately left of `sb.track.x` must
        // still be plain `surface_bg`, because an 8px track starting 2px
        // further left would have filled them.
        //
        // Exact-equality probes against `surface_bg` (the pattern
        // `macos::tree`'s `paints_vertical_scrollbar_track_when_overflowing`
        // uses), *not* a colour-distance "is there ink here" threshold:
        // `native_surface_paint::paint` fills the track with
        // `theme.scrollbar_track.with_alpha(0.4)`, which over
        // `theme.surface_bg` lands ~5 levels per channel away from it
        // (rgb(28,32,44) → ~rgb(33,37,49)). That is a decisive
        // difference for an equality probe but far below any plausible
        // ink threshold, so the scan this replaces could never find the
        // boundary it was looking for.
        let surface = paint_via_backend(&p);
        let theme = Theme::default();
        // The thumb sits at the top of the track at `scroll_offset == 0`
        // and is painted in a *different* colour (`scrollbar_thumb`),
        // inset 1px horizontally — probe below it so only the track's
        // own fill is under the probe row.
        let thumb_bottom = sb.thumb.y + sb.thumb.height;
        let track_bottom = sb.track.y + sb.track.height;
        assert!(
            thumb_bottom < track_bottom - 2.0,
            "test precondition: 40 items over ~12 visible rows must leave bare \
             track below the thumb (thumb bottom {thumb_bottom}, track bottom \
             {track_bottom})",
        );
        let probe_y = ((thumb_bottom + track_bottom) / 2.0) as u32;
        let bg = (theme.surface_bg.r, theme.surface_bg.g, theme.surface_bg.b);
        let column = |x: u32| {
            let (r, g, b, _) = surface.pixel(x, probe_y);
            (r, g, b)
        };
        let track_x = sb.track.x as u32;
        assert_ne!(
            column(track_x + 1),
            bg,
            "expected the track's own fill at x={} (inside the 6px track), got \
             plain surface_bg — the track didn't paint at all",
            track_x + 1,
        );
        for dx in 1..=2 {
            assert_eq!(
                column(track_x - dx),
                bg,
                "x={} sits left of the shared 6px track and must be plain \
                 surface_bg — macOS's pre-#1076 8px track would have painted \
                 its fill there, so the scrollbar width has drifted back",
                track_x - dx,
            );
        }
    }

    #[test]
    fn hit_test_resolves_query_row() {
        let p = sample_palette();
        let layout = mac_palette_layout(&p, 0.0, 0.0, W as f64, H as f64, 16.0);
        let qb = layout.query_bounds.expect("query bounds present");
        let hit = layout.hit_test(qb.x + 20.0, qb.y + qb.height * 0.5);
        assert_eq!(hit, PaletteHit::Query);
    }

    #[test]
    fn layout_returns_local_coords_when_area_offset() {
        // Cross-backend contract: all bounds returned by
        // mac_palette_layout are in palette-local coords (origin
        // 0, 0), regardless of where the rasteriser paints, matching
        // `tui_palette_layout` and `gtk_palette_layout`. Hosts
        // subtract area.x/area.y from absolute click coords before
        // hit_test.
        //
        // Regression for #190: prior to the fix, mac_palette_layout
        // shifted hit_regions to absolute coords. Latent today (no
        // `Backend::palette_layout` trait method exposes the layout
        // to consumers), but ready to bite the moment one is added —
        // same shape as #44's tree/form click drift.
        let p = sample_palette();
        // Area offset by (40, 80) — palette modals are typically
        // centred / inset within a window.
        let area_x: f64 = 40.0;
        let area_y: f64 = 80.0;
        let layout = mac_palette_layout(&p, area_x, area_y, W as f64, H as f64, 16.0);
        // Locality: title_bounds.y must be 0, not area_y.
        let tb = layout.title_bounds.expect("title present");
        assert_eq!(
            tb.y, 0.0,
            "title_bounds.y must be local (0.0), got {}",
            tb.y,
        );
        let qb = layout.query_bounds.expect("query bounds present");
        assert!(
            qb.y < area_y as f32,
            "query_bounds.y must be local (< area_y), got {} vs area_y={}",
            qb.y,
            area_y,
        );
        // Round-trip: simulate a click at the absolute centre of the
        // query row, localise the way AppLogic does, and assert it
        // still hits Query. Pre-fix this returned the wrong region.
        let abs_y = area_y as f32 + qb.y + qb.height * 0.5;
        let abs_x = area_x as f32 + qb.x + 20.0;
        let local_x = abs_x - area_x as f32;
        let local_y = abs_y - area_y as f32;
        assert_eq!(
            layout.hit_test(local_x, local_y),
            PaletteHit::Query,
            "query click → wrong hit (coord-frame drift)",
        );
        // Round-trip each visible item the same way.
        for vi in &layout.visible_items {
            let abs_x = area_x as f32 + vi.bounds.x + vi.bounds.width * 0.5;
            let abs_y = area_y as f32 + vi.bounds.y + vi.bounds.height * 0.5;
            let local_x = abs_x - area_x as f32;
            let local_y = abs_y - area_y as f32;
            assert_eq!(
                layout.hit_test(local_x, local_y),
                PaletteHit::Item(vi.item_idx),
                "item {} click → wrong hit (coord-frame drift)",
                vi.item_idx,
            );
        }
    }
}