quadraui 0.6.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
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
//! `ContextMenu` primitive: a keyboard/mouse-navigable popup of actions
//! triggered at a specific screen location (right-click, keyboard
//! shortcut, explicit open-menu command). Each item is either an
//! action (clickable, emits an id), a separator (visual only), or a
//! submenu parent (opens a nested pull-right popup).
//!
//! Items carry `submenu: Option<Vec<ContextMenuItem>>` for nested menus.
//! The TUI rasteriser renders the `▶` affordance and the pull-right child
//! popup; see [`crate::tui::draw_context_menu`] and
//! [`crate::tui::draw_context_menu_with_submenus`]. The macOS backend
//! wires this into native `NSMenu` nesting.
//!
//! # Backend contract
//!
//! **Modal overlay.** Render as a popup at the computed position;
//! intercept clicks so they don't fall through to the underlying UI.
//! Click on action item activates it (see
//! [`crate::UiEvent::ContextMenuItemActivated`] for the native-menu
//! path via [`crate::Backend::show_context_menu`]); click outside
//! dismisses. Keyboard up/down moves `selected_idx` (skipping
//! separators); Enter activates the selected item; Escape dismisses
//! (see [`crate::UiEvent::ContextMenuDismissed`]).

use crate::accelerator::Accelerator;
use crate::event::Rect;
use crate::types::{Color, Icon, StyledText, WidgetId};
use serde::{Deserialize, Serialize};

/// Shared menu-row type, used by both [`ContextMenu`] and (via
/// [`crate::MenuBarItem::submenu`]) the [`crate::MenuBar`] dropdown.
///
/// Currently a type alias to [`ContextMenuItem`]. New code should
/// prefer this name — it reflects that the same shape backs every
/// menu (right-click context menus, menu-bar dropdowns, NSMenu /
/// future Win32 menu installers). Existing `ContextMenuItem` usages
/// continue to compile unchanged.
pub type MenuItem = ContextMenuItem;

/// Declarative description of a context menu.
///
/// # Examples
///
/// ```
/// use quadraui::{ContextMenu, ContextMenuHit, ContextMenuItem, ContextMenuItemMeasure, Rect, StyledText, WidgetId};
///
/// let menu = ContextMenu {
///     id: WidgetId::new("context_menu:editor"),
///     items: vec![ContextMenuItem {
///         id: Some(WidgetId::new("menu:copy")),
///         label: StyledText::plain("Copy"),
///         ..Default::default()
///     }],
///     selected_idx: 0,
///     bg: None,
///     placement: Default::default(),
/// };
///
/// let viewport = Rect::new(0.0, 0.0, 80.0, 24.0);
/// let layout = menu.layout(5.0, 5.0, viewport, 20.0, |_| ContextMenuItemMeasure::new(1.0));
/// assert_eq!(
///     layout.hit_test(6.0, 5.0),
///     ContextMenuHit::Item(WidgetId::new("menu:copy"))
/// );
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContextMenu {
    pub id: WidgetId,
    pub items: Vec<ContextMenuItem>,
    /// Index of the keyboard-selected item. Use [`Self::move_selection`]
    /// and [`Self::first_selectable`] to navigate — they skip separators
    /// and disabled items automatically.
    pub selected_idx: usize,
    /// Background colour override. `None` = theme default.
    #[serde(default)]
    pub bg: Option<Color>,
    /// How to position the menu relative to the anchor point. Default
    /// `AnchorPoint` (right-click conventions: anchor IS the click
    /// position; menu shifts up/left to fit but doesn't flip
    /// directionality). `Below` / `Above` enable dropdown-style
    /// auto-flip placement.
    #[serde(default)]
    pub placement: ContextMenuPlacement,
}

/// Optional app-supplied override for the glyphs [`native_surface_paint`]
/// paints in place of the default codicon check mark and submenu
/// pull-right arrow.
///
/// A separate, `#[non_exhaustive]` type rather than fields on
/// [`ContextMenu`]/[`ContextMenuItem`] — those two stay exhaustive for
/// downstream struct literals, so a glyph slot can only ever be added
/// here, where a new field can never break an existing construction
/// site. Passed as an extra argument to
/// [`native_surface_paint::paint_with_glyphs`]; the plain
/// [`native_surface_paint::paint`] every existing backend call site
/// already uses keeps calling it with `None`, so today's codicon
/// glyphs render byte-for-byte unchanged until an app opts in.
///
/// Each slot is an [`Icon`] — `glyph` for when
/// [`crate::Backend::nerd_fonts_enabled`] is set, `fallback` otherwise
/// — rather than a bare string. A bare `&str`/`String` still works via
/// `Icon`'s blanket `From` impls.
///
/// The TUI rasteriser paints a single cell per glyph: it resolves the
/// `Icon` to a `&str` and takes only its first `char`, so a
/// multi-character override silently degrades to its leading character.
/// `Icon::color` is resolved but not painted on TUI — the glyph cell
/// always uses the surrounding theme foreground, never the `Icon`'s own
/// colour.
///
/// [`crate::tui::draw_context_menu_with_submenus`] does not take a
/// `glyphs` argument and always paints the default submenu-arrow glyph;
/// only [`native_surface_paint::paint_with_glyphs`] honours this override.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct ContextMenuGlyphs {
    checked: Option<Icon>,
    submenu_arrow: Option<Icon>,
}

impl ContextMenuGlyphs {
    /// An empty override set — [`native_surface_paint::paint_with_glyphs`]
    /// painted with `Some(&Self::new())` is byte-identical to
    /// [`native_surface_paint::paint`].
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Override the checked-item check-mark glyph (default: the codicon
    /// check glyph on GUI).
    #[must_use]
    pub fn with_checked_glyph(mut self, glyph: impl Into<Icon>) -> Self {
        self.checked = Some(glyph.into());
        self
    }

    /// Override the submenu-parent pull-right arrow glyph (default: the
    /// codicon chevron-right glyph on GUI).
    #[must_use]
    pub fn with_submenu_arrow_glyph(mut self, glyph: impl Into<Icon>) -> Self {
        self.submenu_arrow = Some(glyph.into());
        self
    }

    /// The overridden check-mark glyph, if set.
    #[must_use]
    pub fn checked_glyph(&self) -> Option<&Icon> {
        self.checked.as_ref()
    }

    /// The overridden submenu-arrow glyph, if set.
    #[must_use]
    pub fn submenu_arrow_glyph(&self) -> Option<&Icon> {
        self.submenu_arrow.as_ref()
    }
}

/// Preferred placement of a `ContextMenu` relative to its anchor.
///
/// `AnchorPoint` is the right-click default: the anchor IS the cursor
/// position. The menu's top-left corner aligns with the anchor; the
/// menu shifts up/left to keep the box inside the viewport but never
/// flips directionality.
///
/// `Below` and `Above` enable dropdown-style placement: the anchor is
/// the trigger element (e.g. a button), and the menu opens above or
/// below it. The layout auto-flips to the opposite side if the
/// preferred direction would overflow the viewport — same behaviour as
/// [`super::tooltip::TooltipPlacement`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum ContextMenuPlacement {
    /// Right-click default: anchor is the cursor position. No flipping.
    #[default]
    AnchorPoint,
    /// Open below the anchor (e.g. dropdown attached to a top-row
    /// button). Auto-flips to above if it would overflow the bottom.
    Below,
    /// Open above the anchor (e.g. dropdown attached to a bottom-row
    /// button — kubeui's namespace picker). Auto-flips to below if it
    /// would overflow the top.
    Above,
}

/// Resolved placement after the layout decided whether to flip the
/// preferred [`ContextMenuPlacement`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResolvedContextMenuPlacement {
    AnchorPoint,
    Below,
    Above,
}

/// One entry in a `ContextMenu`. Also re-exported as
/// [`MenuItem`] for use as the shared shape across menu-bar
/// dropdowns and right-click menus.
///
/// Construct items via the struct literal with `..Default::default()`
/// — only the fields you care about need to be set, the rest
/// (`detail`, `key_equivalent`, `checked`, `submenu`) default to
/// `None`, `disabled` to `false`.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContextMenuItem {
    /// `None` = separator (non-interactive); `Some(id)` = action.
    #[serde(default)]
    pub id: Option<WidgetId>,
    /// Label for the item. Ignored for separators.
    pub label: StyledText,
    /// Optional right-aligned detail (e.g. keyboard shortcut "Ctrl+C").
    /// Prefer [`Self::key_equivalent`] for new code — rasterisers
    /// will render it via `render_accelerator` so the format matches
    /// the host platform (`⌘S` on macOS, `Ctrl+S` elsewhere). `detail`
    /// continues to win when both are set, for back-compat.
    #[serde(default)]
    pub detail: Option<StyledText>,
    /// When true, the item is rendered dimmed and click emits no event.
    #[serde(default)]
    pub disabled: bool,
    /// Structured keyboard shortcut for the item. When `Some` and
    /// [`Self::detail`] is `None`, rasterisers render
    /// `render_accelerator(acc, platform)` as the right-aligned
    /// shortcut hint.
    #[serde(default)]
    pub key_equivalent: Option<Accelerator>,
    /// Checkbox / radio state. `None` = item has no state; `Some(true)`
    /// renders a leading `✓` and (on macOS) sets `NSMenuItem.state =
    /// .on`; `Some(false)` reserves the prefix space without filling it
    /// so a column of items aligns visually.
    #[serde(default)]
    pub checked: Option<bool>,
    /// Nested submenu. When `Some`, activating the item opens the child
    /// menu instead of firing an action. The TUI rasteriser renders a `▶`
    /// affordance and a pull-right popup (see
    /// [`crate::tui::draw_context_menu_with_submenus`]). The macOS NSMenu
    /// installer wires this as a real nested `NSMenu`. GTK is a follow-up.
    #[serde(default)]
    pub submenu: Option<Vec<ContextMenuItem>>,
}

impl ContextMenuItem {
    /// Convenience: is this item a separator (non-clickable)?
    pub fn is_separator(&self) -> bool {
        self.id.is_none()
    }
}

// ── D6 Layout API ───────────────────────────────────────────────────────────

/// Per-item measurement supplied by the backend.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct ContextMenuItemMeasure {
    pub height: f32,
}

impl ContextMenuItemMeasure {
    pub fn new(height: f32) -> Self {
        Self { height }
    }
}

/// Resolved position of one visible context-menu item.
#[derive(Debug, Clone, PartialEq)]
pub struct VisibleContextMenuItem {
    pub item_idx: usize,
    pub bounds: Rect,
    /// `true` iff this item is a separator (no hit region, renders as
    /// a horizontal divider).
    pub is_separator: bool,
    /// `true` iff this item is clickable (has an `id` and isn't
    /// disabled). Separators and disabled items are `false`.
    pub clickable: bool,
}

/// Classification of a hit-test result.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ContextMenuHit {
    /// Click landed on an actionable item.
    Item(WidgetId),
    /// Click landed on a non-interactive item (separator or disabled).
    Inert,
    /// Click landed outside the menu — apps typically dismiss.
    Empty,
}

/// Fully-resolved context-menu layout.
#[derive(Debug, Clone, PartialEq)]
pub struct ContextMenuLayout {
    /// Full bounds of the menu box.
    pub bounds: Rect,
    pub visible_items: Vec<VisibleContextMenuItem>,
    pub hit_regions: Vec<(Rect, ContextMenuHit)>,
    /// Placement actually used. For `AnchorPoint` always
    /// `ResolvedContextMenuPlacement::AnchorPoint`; for `Below` / `Above`
    /// reports whether the layout flipped to the opposite direction
    /// when the preferred side would have overflowed.
    pub resolved_placement: ResolvedContextMenuPlacement,
}

impl ContextMenuLayout {
    pub fn hit_test(&self, x: f32, y: f32) -> ContextMenuHit {
        // Inside menu bounds?
        let inside = x >= self.bounds.x
            && x < self.bounds.x + self.bounds.width
            && y >= self.bounds.y
            && y < self.bounds.y + self.bounds.height;
        if !inside {
            return ContextMenuHit::Empty;
        }
        for (rect, hit) in &self.hit_regions {
            if x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height {
                return hit.clone();
            }
        }
        // Inside menu, but not on any item (e.g. border padding) → treat as Inert.
        ContextMenuHit::Inert
    }
}

impl ContextMenu {
    /// Compute menu placement and per-item bounds.
    ///
    /// # Arguments
    ///
    /// - `anchor_x`, `anchor_y` — preferred top-left origin (typically
    ///   the click position). The menu shifts left / up if placing it
    ///   here would overflow the viewport.
    /// - `viewport` — parent surface bounds; menu is clamped inside.
    /// - `menu_width` — width of the menu box.
    /// - `measure_item(i)` — height for item `i`.
    ///
    /// # Overflow handling
    ///
    /// If `anchor_x + menu_width > viewport.right`, the menu shifts
    /// left so its right edge aligns with the viewport right edge.
    /// Same for the bottom edge. If the menu is taller than the
    /// viewport, items beyond the bottom edge are not emitted as
    /// visible (no scrolling in v1 — if this is a real problem,
    /// consumers should use `Palette` instead).
    pub fn layout<F>(
        &self,
        anchor_x: f32,
        anchor_y: f32,
        viewport: Rect,
        menu_width: f32,
        measure_item: F,
    ) -> ContextMenuLayout
    where
        F: Fn(usize) -> ContextMenuItemMeasure,
    {
        self.layout_at(
            Rect::new(anchor_x, anchor_y, 0.0, 0.0),
            viewport,
            menu_width,
            measure_item,
        )
    }

    /// Anchor-rect variant of [`Self::layout`]. The anchor is a
    /// rectangle (typically the trigger button's bounds) instead of a
    /// single point — required for `Below` / `Above` placement so the
    /// menu can sit flush against the trigger's bottom or top edge.
    /// For `AnchorPoint` placement only `anchor.x` and `anchor.y` are
    /// used (top-left of the rect).
    pub fn layout_at<F>(
        &self,
        anchor: Rect,
        viewport: Rect,
        menu_width: f32,
        measure_item: F,
    ) -> ContextMenuLayout
    where
        F: Fn(usize) -> ContextMenuItemMeasure,
    {
        let measures: Vec<ContextMenuItemMeasure> =
            (0..self.items.len()).map(&measure_item).collect();
        let total_height: f32 = measures.iter().map(|m| m.height).sum();

        // Horizontal positioning (same for all placement modes): align
        // the menu's left edge with the anchor's left edge, but shift
        // left if the menu would overflow the viewport's right side.
        let x = if anchor.x + menu_width > viewport.x + viewport.width {
            (viewport.x + viewport.width - menu_width).max(viewport.x)
        } else {
            anchor.x.max(viewport.x)
        };

        // Vertical positioning depends on placement mode.
        let viewport_top = viewport.y;
        let viewport_bottom = viewport.y + viewport.height;
        let (y, resolved_placement) = match self.placement {
            ContextMenuPlacement::AnchorPoint => {
                // Right-click default: anchor IS the click point;
                // menu top-left aligns with anchor; shift up to fit if
                // the menu would overflow the viewport bottom.
                let y_pref = anchor.y;
                let y = if y_pref + total_height > viewport_bottom {
                    (viewport_bottom - total_height).max(viewport_top)
                } else {
                    y_pref.max(viewport_top)
                };
                (y, ResolvedContextMenuPlacement::AnchorPoint)
            }
            ContextMenuPlacement::Below => {
                // Dropdown opens below the trigger. Menu's top is at
                // the trigger's bottom edge. Auto-flip to Above if it
                // would overflow the viewport bottom AND there's more
                // room above than below.
                let space_below = viewport_bottom - (anchor.y + anchor.height);
                let space_above = anchor.y - viewport_top;
                if total_height > space_below && space_above > space_below {
                    // Flip to Above.
                    let y_pref = anchor.y - total_height;
                    let y = y_pref.max(viewport_top);
                    (y, ResolvedContextMenuPlacement::Above)
                } else {
                    let y_pref = anchor.y + anchor.height;
                    let y = if y_pref + total_height > viewport_bottom {
                        (viewport_bottom - total_height).max(viewport_top)
                    } else {
                        y_pref.max(viewport_top)
                    };
                    (y, ResolvedContextMenuPlacement::Below)
                }
            }
            ContextMenuPlacement::Above => {
                // Dropdown opens above the trigger (kubeui's status-bar
                // segment). Menu's bottom is at the trigger's top edge.
                // Auto-flip to Below if it would overflow the viewport
                // top AND there's more room below than above.
                let space_below = viewport_bottom - (anchor.y + anchor.height);
                let space_above = anchor.y - viewport_top;
                if total_height > space_above && space_below > space_above {
                    // Flip to Below.
                    let y_pref = anchor.y + anchor.height;
                    let y = if y_pref + total_height > viewport_bottom {
                        (viewport_bottom - total_height).max(viewport_top)
                    } else {
                        y_pref.max(viewport_top)
                    };
                    (y, ResolvedContextMenuPlacement::Below)
                } else {
                    let y_pref = anchor.y - total_height;
                    let y = y_pref.max(viewport_top);
                    (y, ResolvedContextMenuPlacement::Above)
                }
            }
        };

        let clipped_height = total_height.min(viewport_bottom - y);
        let bounds = Rect::new(x, y, menu_width, clipped_height);

        let mut visible_items: Vec<VisibleContextMenuItem> = Vec::new();
        let mut hit_regions: Vec<(Rect, ContextMenuHit)> = Vec::new();

        let mut cursor_y = y;
        for (i, item) in self.items.iter().enumerate() {
            if cursor_y >= y + clipped_height {
                break;
            }
            let h = measures[i].height;
            let remaining = y + clipped_height - cursor_y;
            let draw_h = h.min(remaining).max(0.0);
            if draw_h <= 0.0 {
                break;
            }
            let item_bounds = Rect::new(x, cursor_y, menu_width, draw_h);
            let is_sep = item.is_separator();
            let clickable = !is_sep && !item.disabled;
            visible_items.push(VisibleContextMenuItem {
                item_idx: i,
                bounds: item_bounds,
                is_separator: is_sep,
                clickable,
            });
            if clickable {
                if let Some(id) = &item.id {
                    hit_regions.push((item_bounds, ContextMenuHit::Item(*id)));
                }
            } else {
                hit_regions.push((item_bounds, ContextMenuHit::Inert));
            }
            cursor_y += h;
        }

        ContextMenuLayout {
            bounds,
            visible_items,
            hit_regions,
            resolved_placement,
        }
    }

    /// Index of the first non-separator, non-disabled item.
    /// Returns 0 if no selectable items exist.
    pub fn first_selectable(&self) -> usize {
        self.items
            .iter()
            .position(|item| !item.is_separator() && !item.disabled)
            .unwrap_or(0)
    }

    /// Navigate selection by `delta` steps (positive = down, negative = up),
    /// skipping separators and disabled items, wrapping at boundaries.
    /// Returns the new selected index, or `current` if no selectable item
    /// exists.
    pub fn move_selection(&self, current: usize, delta: i32) -> usize {
        if self.items.is_empty() {
            return current;
        }
        let n = self.items.len() as i32;
        let mut idx = current as i32;
        for _ in 0..self.items.len() {
            idx = (idx + delta).rem_euclid(n);
            let item = &self.items[idx as usize];
            if !item.is_separator() && !item.disabled {
                return idx as usize;
            }
        }
        current
    }

    /// Derive a menu width (in the same units as `char_width` — TUI
    /// char cells, or pixels on a pixel backend) from each item's label
    /// length — no real text measurement available. Same
    /// char-cell-approximation rationale as
    /// `crate::primitives::dialog::Dialog::measure_generic`.
    ///
    /// Per item, counts: a 2-char prefix slot when `checked` is
    /// `Some(_)` (mirrors the `"✓ "` / `"  "` prefix both
    /// `tui::context_menu::draw_context_menu` and this module's
    /// `native_surface_paint::paint` reserve); the label text; and,
    /// when present, a 1-char gap plus either a 1-char `▶` submenu
    /// arrow or [`ContextMenuItem::detail`]'s length — whichever
    /// trailing content the item carries. [`ContextMenuItem::key_equivalent`]-rendered
    /// shortcuts need a `crate::accelerator::Platform` this helper
    /// doesn't take, so they're not counted; callers that rely on
    /// `key_equivalent` alone should populate `detail` too, or pad the
    /// result. Two char-widths of interior left/right margin are added
    /// on top of the widest item.
    ///
    /// `border_chrome_inset` optionally shrinks the result to leave
    /// room for a border the rasteriser draws *outside* the bounds this
    /// width feeds into [`Self::layout`]/[`Self::layout_at`] — see
    /// `Dialog::measure_generic`'s doc for the same parameter.
    /// `tui::context_menu::draw_context_menu` is exactly this case: its
    /// box-drawing border lives one char cell outside
    /// [`ContextMenuLayout::bounds`] on every side, so a TUI caller
    /// should pass one `char_width` here.
    pub fn measure_generic_width(&self, char_width: f32, border_chrome_inset: f32) -> f32 {
        let max_chars = self
            .items
            .iter()
            .map(Self::item_content_chars)
            .max()
            .unwrap_or(0);
        let content_w = (max_chars as f32 + 2.0) * char_width;
        (content_w - border_chrome_inset * 2.0).max(char_width)
    }

    /// Char-cell width of one item's content: optional `checked` prefix,
    /// label, and optional trailing submenu arrow / detail text. See
    /// [`Self::measure_generic_width`]'s doc for the exact accounting.
    fn item_content_chars(item: &ContextMenuItem) -> usize {
        let prefix_len = if item.checked.is_some() { 2 } else { 0 };
        let label_len: usize = item
            .label
            .spans
            .iter()
            .map(|s| s.text.chars().count())
            .sum();
        let trailing_len = if item.submenu.is_some() {
            1
        } else if let Some(det) = &item.detail {
            det.spans.iter().map(|s| s.text.chars().count()).sum()
        } else {
            0
        };
        let gap = if trailing_len > 0 { 1 } else { 0 };
        prefix_len + label_len + gap + trailing_len
    }
}

// `paint` below is the one shared paint implementation, written against
// [`crate::paint_surface::PaintSurface`] instead of any one backend's
// API — see `crate::primitives::palette::native_surface_paint` for the
// same pattern applied one primitive earlier.
//
// When migrating `gtk::context_menu`, `macos::context_menu` and
// `win::context_menu`, two small differences were resolved:
//
// - **Box corner rounding.** GTK drew 3px-radius rounded rectangles;
//   macOS and Windows used plain square rects. `paint` adopts the
//   majority (square) for consistency.
// - **Separator stroke weight.** GTK stroked at `0.5` width; macOS
//   filled a `1.0`-tall rect; Windows drew a `1.0`-width line. `paint`
//   uses [`PaintSurface::surface_draw_line`] at `1.0` — the majority.
#[cfg(any(
    feature = "gtk",
    feature = "win",
    all(feature = "macos", target_os = "macos")
))]
#[allow(dead_code)]
pub(crate) mod native_surface_paint {
    use super::{ContextMenu, ContextMenuGlyphs, ContextMenuItem, ContextMenuLayout};
    use crate::accelerator::{render_accelerator, Platform};
    use crate::paint_surface::PaintSurface;
    use crate::style::Style;
    use crate::theme::Theme;
    use crate::types::{Color, WidgetId};
    use crate::{Point, Rect};

    /// Right-aligned shortcut text — sourced from `item.detail`
    /// (preferred, back-compat) or rendered from `item.key_equivalent`
    /// for `platform` (`⌘S` on macOS, `Ctrl+S` elsewhere). Returns
    /// `None` if neither is set.
    pub(super) fn shortcut_text(item: &ContextMenuItem, platform: Platform) -> Option<String> {
        if let Some(ref det) = item.detail {
            return Some(det.spans.iter().map(|sp| sp.text.as_str()).collect());
        }
        item.key_equivalent
            .as_ref()
            .map(|acc| render_accelerator(acc, platform))
    }

    /// Paint a [`ContextMenu`] popup at its caller-resolved
    /// `menu_layout` onto `surface`. Returns the per-clickable-item hit
    /// rectangles + their [`WidgetId`]s so the caller's click handler
    /// can resolve mouse events without re-running layout — mirrors
    /// [`crate::Backend::draw_context_menu`]'s own return contract.
    ///
    /// `platform` selects the key-equivalent render form (`⌘S` vs.
    /// `Ctrl+S`) — see [`shortcut_text`]. `style` supplies
    /// the corner radius/border width/shadow elevation the popup box
    /// paints with — callers pass `&self.style()`.
    pub(crate) fn paint(
        menu: &ContextMenu,
        menu_layout: &ContextMenuLayout,
        platform: Platform,
        surface: &mut dyn PaintSurface,
        theme: &Theme,
        style: &Style,
    ) -> Vec<(Rect, WidgetId)> {
        paint_with_glyphs(
            menu,
            menu_layout,
            platform,
            surface,
            theme,
            style,
            None,
            false,
        )
    }

    /// Identical to [`paint`], plus an optional [`ContextMenuGlyphs`]
    /// override for the check-mark / submenu-arrow glyphs and the
    /// `nerd_fonts_enabled` flag (see [`crate::Backend::nerd_fonts_enabled`])
    /// that selects each override [`Icon`]'s `glyph` vs. `fallback` form.
    /// `glyphs: None` (what [`paint`] passes) renders byte-for-byte the
    /// same as today's codicon defaults.
    #[allow(clippy::too_many_arguments)]
    pub(crate) fn paint_with_glyphs(
        menu: &ContextMenu,
        menu_layout: &ContextMenuLayout,
        platform: Platform,
        surface: &mut dyn PaintSurface,
        theme: &Theme,
        style: &Style,
        glyphs: Option<&ContextMenuGlyphs>,
        nerd_fonts_enabled: bool,
    ) -> Vec<(Rect, WidgetId)> {
        let bounds = menu_layout.bounds;
        if bounds.width <= 0.0 || bounds.height <= 0.0 {
            return Vec::new();
        }

        // Resolved once, outside the per-item loop below — these two
        // values are constant across the whole popup, not per-item.
        let check_glyph: std::borrow::Cow<'_, str> = glyphs
            .and_then(ContextMenuGlyphs::checked_glyph)
            .map(|icon| std::borrow::Cow::Borrowed(icon.resolve(nerd_fonts_enabled)))
            .unwrap_or(std::borrow::Cow::Owned(crate::codicon::CHECK.to_string()));
        let submenu_arrow_glyph: std::borrow::Cow<'_, str> = glyphs
            .and_then(ContextMenuGlyphs::submenu_arrow_glyph)
            .map(|icon| std::borrow::Cow::Borrowed(icon.resolve(nerd_fonts_enabled)))
            .unwrap_or(std::borrow::Cow::Owned(
                crate::codicon::CHEVRON_RIGHT.to_string(),
            ));

        let bg = menu.bg.unwrap_or(theme.hover_bg);
        // VS-Code-style elevated card — a shadow behind, a
        // rounded fill, and a border this popup never had before.
        surface.surface_draw_shadow(
            bounds,
            style.corner_radius,
            style.shadow_elevation,
            Color::rgba(0, 0, 0, 100),
        );
        surface.surface_fill_rounded_rect(bounds, style.corner_radius, bg);
        surface.surface_stroke_rounded_rect(
            bounds,
            style.corner_radius,
            theme.border_fg,
            style.border_width,
        );

        let mut rects: Vec<(Rect, WidgetId)> = Vec::new();

        // Pass 1: backgrounds (separators + selection highlights).
        for vis in &menu_layout.visible_items {
            let row = vis.bounds;

            if vis.is_separator {
                let sep_y = row.y + row.height * 0.5;
                surface.surface_draw_line(
                    Point::new(row.x + 4.0, sep_y),
                    Point::new(row.x + row.width - 4.0, sep_y),
                    theme.muted_fg,
                    1.0,
                );
                continue;
            }

            let is_selected = vis.item_idx == menu.selected_idx && vis.clickable;
            if is_selected {
                let sel_rect =
                    Rect::new(row.x + 1.0, row.y, (row.width - 2.0).max(0.0), row.height);
                surface.surface_fill_rect(sel_rect, theme.selected_bg);
            }

            if vis.clickable {
                if let Some(ref id) = menu.items[vis.item_idx].id {
                    rects.push((row, *id));
                }
            }
        }

        // Pass 2: text (labels + detail/shortcut). Rendered on top of
        // all backgrounds so descenders are never clipped.
        for vis in &menu_layout.visible_items {
            if vis.is_separator {
                continue;
            }

            let item = &menu.items[vis.item_idx];
            let row = vis.bounds;

            // Reserve a fixed-width leading slot for the check glyph so
            // a column of mixed checked/unchecked items aligns —
            // `Some(false)`/`None` reserve it with two plain spaces,
            // `Some(true)` fills it with the codicon check glyph
            // painted *separately* via `surface_draw_icon_glyph`
            // (below) rather than embedded in this plain
            // `surface_draw_text_run` call: a PUA codepoint baked into
            // an ordinary text run resolves through whatever font that
            // run's own call site already carries, which is not
            // guaranteed to be the fallback-wrapped description
            // `surface_draw_icon_glyph` resolves through — the same
            // reason the submenu pull-right arrow below gets its own
            // draw call instead of living in this label string.
            let has_check_slot = item.checked.is_some();
            let label_text: String = if has_check_slot {
                std::iter::once("  ".to_string())
                    .chain(item.label.spans.iter().map(|s| s.text.clone()))
                    .collect()
            } else {
                item.label.spans.iter().map(|s| s.text.clone()).collect()
            };
            let label_fg = if vis.clickable {
                theme.foreground
            } else {
                theme.muted_fg
            };
            let (_, lh) = surface.surface_measure_text(&label_text);
            let text_y = row.y + (row.height - lh) / 2.0;
            surface.surface_draw_text_run(
                Rect::new(row.x + 8.0, text_y, (row.width - 8.0).max(0.0), lh),
                &label_text,
                label_fg,
            );

            if item.checked == Some(true) {
                let (cw, ch) = surface.surface_measure_text(&check_glyph);
                surface.surface_draw_icon_glyph(
                    Rect::new(row.x + 8.0, text_y, cw.max(1.0), ch.max(1.0)),
                    &check_glyph,
                    label_fg,
                );
            }

            if item.submenu.is_some() {
                // Submenu-parent: show a codicon chevron pull-right
                // affordance at the far-right column instead of a
                // keyboard-shortcut hint — mirrors the shape
                // `crate::tui::draw_context_menu` paints with a
                // plain `▶` glyph instead. Submenu parents open a child
                // menu rather than dispatching an action, so a shortcut
                // hint would never fire anyway.
                let (aw, ah) = surface.surface_measure_text(&submenu_arrow_glyph);
                surface.surface_draw_icon_glyph(
                    Rect::new(row.x + row.width - aw - 8.0, text_y, aw.max(1.0), ah),
                    &submenu_arrow_glyph,
                    label_fg,
                );
            } else if let Some(shortcut) = shortcut_text(item, platform) {
                if !shortcut.is_empty() {
                    let (sw, sh) = surface.surface_measure_text(&shortcut);
                    surface.surface_draw_text_run(
                        Rect::new(row.x + row.width - sw - 8.0, text_y, sw.max(1.0), sh),
                        &shortcut,
                        theme.muted_fg,
                    );
                }
            }
        }

        // Pass 3: border on top so selection bg never obscures edges.
        surface.surface_stroke_rect(bounds, theme.hover_border, 1.0);

        rects
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::{StyledText, WidgetId};

    fn action(id: &str) -> ContextMenuItem {
        ContextMenuItem {
            id: Some(WidgetId::new(id)),
            label: StyledText::plain(id),
            ..Default::default()
        }
    }

    fn separator() -> ContextMenuItem {
        ContextMenuItem::default()
    }

    fn disabled(id: &str) -> ContextMenuItem {
        ContextMenuItem {
            id: Some(WidgetId::new(id)),
            label: StyledText::plain(id),
            disabled: true,
            ..Default::default()
        }
    }

    fn menu(items: Vec<ContextMenuItem>) -> ContextMenu {
        ContextMenu {
            id: WidgetId::new("test-menu"),
            items,
            selected_idx: 0,
            bg: None,
            placement: ContextMenuPlacement::Below,
        }
    }

    #[test]
    fn first_selectable_skips_separator_and_disabled() {
        let m = menu(vec![separator(), disabled("x"), action("a"), action("b")]);
        assert_eq!(m.first_selectable(), 2);
    }

    #[test]
    fn first_selectable_returns_zero_when_all_inert() {
        let m = menu(vec![separator(), disabled("x")]);
        assert_eq!(m.first_selectable(), 0);
    }

    #[test]
    fn first_selectable_first_item() {
        let m = menu(vec![action("a"), separator(), action("b")]);
        assert_eq!(m.first_selectable(), 0);
    }

    #[test]
    fn move_selection_skips_separators() {
        let m = menu(vec![action("a"), separator(), action("b")]);
        assert_eq!(m.move_selection(0, 1), 2);
        assert_eq!(m.move_selection(2, -1), 0);
    }

    #[test]
    fn move_selection_skips_disabled() {
        let m = menu(vec![action("a"), disabled("x"), action("b")]);
        assert_eq!(m.move_selection(0, 1), 2);
    }

    #[test]
    fn move_selection_wraps_forward() {
        let m = menu(vec![action("a"), separator(), action("b")]);
        assert_eq!(m.move_selection(2, 1), 0);
    }

    #[test]
    fn move_selection_wraps_backward() {
        let m = menu(vec![action("a"), separator(), action("b")]);
        assert_eq!(m.move_selection(0, -1), 2);
    }

    #[test]
    fn move_selection_returns_current_when_no_selectable() {
        let m = menu(vec![separator(), disabled("x")]);
        assert_eq!(m.move_selection(0, 1), 0);
    }

    #[test]
    fn move_selection_empty_menu() {
        let m = menu(vec![]);
        assert_eq!(m.move_selection(0, 1), 0);
    }

    #[test]
    fn deserialises_old_shape_without_new_fields() {
        // Existing on-disk JSON (e.g. plugin manifests) predates the
        // key_equivalent / checked / submenu fields. Verify it still
        // parses, with the new fields defaulting to None.
        let json = r#"{
            "id": "copy",
            "label": {"spans":[{"text":"Copy","fg":null,"bg":null,"bold":false,"italic":false,"underline":false}]},
            "detail": null,
            "disabled": false
        }"#;
        let item: ContextMenuItem = serde_json::from_str(json).expect("old shape parses");
        assert_eq!(item.id, Some(WidgetId::new("copy")));
        assert!(item.key_equivalent.is_none());
        assert!(item.checked.is_none());
        assert!(item.submenu.is_none());
    }

    #[test]
    fn menu_item_type_alias_compiles() {
        // `MenuItem` is a type alias for `ContextMenuItem`. Any
        // ContextMenuItem assigns straight to a MenuItem binding —
        // this test mostly exists to lock in the alias as a
        // compile-time contract.
        let item: super::MenuItem = action("save");
        assert_eq!(item.id, Some(WidgetId::new("save")));
    }

    #[test]
    fn nested_submenu_round_trips_through_serde() {
        let json = r#"{
            "id": "view",
            "label": {"spans":[{"text":"View","fg":null,"bg":null,"bold":false,"italic":false,"underline":false}]},
            "submenu": [
                {
                    "id": "toggle_sidebar",
                    "label": {"spans":[{"text":"Toggle Sidebar","fg":null,"bg":null,"bold":false,"italic":false,"underline":false}]},
                    "checked": true
                }
            ]
        }"#;
        let item: ContextMenuItem = serde_json::from_str(json).expect("nested shape parses");
        let nested = item.submenu.as_ref().expect("submenu present");
        assert_eq!(nested.len(), 1);
        assert_eq!(nested[0].checked, Some(true));
    }

    // ── ContextMenu primitive tests (D6) ──────────────────────────────

    fn cm_action(id: &str, label: &str) -> ContextMenuItem {
        ContextMenuItem {
            id: Some(WidgetId::new(id)),
            label: StyledText::plain(label),
            ..Default::default()
        }
    }

    fn cm_separator() -> ContextMenuItem {
        ContextMenuItem::default()
    }

    #[test]
    fn context_menu_layout_flat() {
        let menu = ContextMenu {
            id: WidgetId::new("m"),
            items: vec![
                cm_action("cut", "Cut"),
                cm_action("copy", "Copy"),
                cm_separator(),
                cm_action("paste", "Paste"),
            ],
            selected_idx: 0,
            bg: None,
            placement: ContextMenuPlacement::default(),
        };
        let viewport = Rect::new(0.0, 0.0, 800.0, 600.0);
        let layout = menu.layout(100.0, 100.0, viewport, 160.0, |_| {
            ContextMenuItemMeasure::new(20.0)
        });
        assert_eq!(layout.bounds.x, 100.0);
        assert_eq!(layout.bounds.y, 100.0);
        assert_eq!(layout.bounds.width, 160.0);
        assert_eq!(layout.bounds.height, 80.0); // 4 × 20
        assert_eq!(layout.visible_items.len(), 4);
        // Separator at index 2 is visually present, non-clickable.
        assert!(layout.visible_items[2].is_separator);
        assert!(!layout.visible_items[2].clickable);
        // Hit-test on Copy (2nd item, y=120..140).
        match layout.hit_test(120.0, 125.0) {
            ContextMenuHit::Item(id) => assert_eq!(id.as_str(), "copy"),
            _ => panic!("expected Item(copy)"),
        }
        // Hit-test on separator (y=140..160) → Inert.
        assert_eq!(layout.hit_test(120.0, 150.0), ContextMenuHit::Inert);
        // Hit-test far outside → Empty.
        assert_eq!(layout.hit_test(500.0, 500.0), ContextMenuHit::Empty);
    }

    #[test]
    fn context_menu_layout_shifts_left_when_overflow() {
        let menu = ContextMenu {
            id: WidgetId::new("m"),
            items: vec![cm_action("a", "A")],
            selected_idx: 0,
            bg: None,
            placement: ContextMenuPlacement::default(),
        };
        let viewport = Rect::new(0.0, 0.0, 200.0, 200.0);
        // Anchor at x=180, menu_width=100 → right edge would be 280 > 200.
        let layout = menu.layout(180.0, 50.0, viewport, 100.0, |_| {
            ContextMenuItemMeasure::new(20.0)
        });
        assert_eq!(layout.bounds.x, 100.0); // 200 - 100 = 100
    }

    #[test]
    fn context_menu_layout_shifts_up_when_overflow() {
        let menu = ContextMenu {
            id: WidgetId::new("m"),
            items: vec![cm_action("a", "A"), cm_action("b", "B")],
            selected_idx: 0,
            bg: None,
            placement: ContextMenuPlacement::default(),
        };
        let viewport = Rect::new(0.0, 0.0, 200.0, 100.0);
        // Anchor at y=80, 2 items × 20 = 40, bottom would be 120 > 100.
        let layout = menu.layout(10.0, 80.0, viewport, 100.0, |_| {
            ContextMenuItemMeasure::new(20.0)
        });
        assert_eq!(layout.bounds.y, 60.0); // 100 - 40
    }

    #[test]
    fn context_menu_layout_below_places_at_anchor_bottom() {
        // Trigger button is anchor (10, 10, 80, 20); menu opens below.
        let menu = ContextMenu {
            id: WidgetId::new("m"),
            items: vec![cm_action("a", "A"), cm_action("b", "B")],
            selected_idx: 0,
            bg: None,
            placement: ContextMenuPlacement::Below,
        };
        let viewport = Rect::new(0.0, 0.0, 800.0, 600.0);
        let layout = menu.layout_at(Rect::new(10.0, 10.0, 80.0, 20.0), viewport, 100.0, |_| {
            ContextMenuItemMeasure::new(20.0)
        });
        // Menu's y starts at anchor.bottom = 30.
        assert_eq!(layout.bounds.y, 30.0);
        assert_eq!(
            layout.resolved_placement,
            ResolvedContextMenuPlacement::Below
        );
    }

    #[test]
    fn context_menu_layout_below_flips_above_when_no_room() {
        // Trigger near bottom edge; below would overflow; more room above.
        let menu = ContextMenu {
            id: WidgetId::new("m"),
            items: (0..6).map(|i| cm_action(&format!("i{i}"), "X")).collect(),
            selected_idx: 0,
            bg: None,
            placement: ContextMenuPlacement::Below,
        };
        let viewport = Rect::new(0.0, 0.0, 200.0, 200.0);
        // Trigger at y=180, height=20 → bottom=200. Menu height = 6×20 = 120.
        // Space below = 0, space above = 180 → flip.
        let layout = menu.layout_at(Rect::new(10.0, 180.0, 80.0, 20.0), viewport, 100.0, |_| {
            ContextMenuItemMeasure::new(20.0)
        });
        assert_eq!(
            layout.resolved_placement,
            ResolvedContextMenuPlacement::Above
        );
        // Menu's bottom edge sits at the trigger's top edge → y = 180 - 120 = 60.
        assert_eq!(layout.bounds.y, 60.0);
    }

    #[test]
    fn context_menu_layout_above_places_at_anchor_top() {
        // kubeui's status-bar segment use case: trigger at bottom row,
        // menu opens upward.
        let menu = ContextMenu {
            id: WidgetId::new("m"),
            items: vec![
                cm_action("a", "A"),
                cm_action("b", "B"),
                cm_action("c", "C"),
            ],
            selected_idx: 0,
            bg: None,
            placement: ContextMenuPlacement::Above,
        };
        let viewport = Rect::new(0.0, 0.0, 200.0, 100.0);
        // Trigger at y=99 (last row, status bar). Menu height = 60.
        // Space above = 99 (room for menu); resolves to Above.
        let layout = menu.layout_at(Rect::new(10.0, 99.0, 80.0, 1.0), viewport, 100.0, |_| {
            ContextMenuItemMeasure::new(20.0)
        });
        assert_eq!(
            layout.resolved_placement,
            ResolvedContextMenuPlacement::Above
        );
        // Menu y = 99 - 60 = 39.
        assert_eq!(layout.bounds.y, 39.0);
    }

    #[test]
    fn context_menu_layout_disabled_items_inert() {
        let mut menu = ContextMenu {
            id: WidgetId::new("m"),
            items: vec![cm_action("delete", "Delete")],
            selected_idx: 0,
            bg: None,
            placement: ContextMenuPlacement::default(),
        };
        menu.items[0].disabled = true;
        let viewport = Rect::new(0.0, 0.0, 800.0, 600.0);
        let layout = menu.layout(10.0, 10.0, viewport, 100.0, |_| {
            ContextMenuItemMeasure::new(20.0)
        });
        assert!(!layout.visible_items[0].clickable);
        // Click on disabled item → Inert, not Item.
        assert_eq!(layout.hit_test(50.0, 15.0), ContextMenuHit::Inert);
    }

    // ── native_surface_paint::shortcut_text ───────────────────────────────

    #[cfg(any(
        feature = "gtk",
        feature = "win",
        all(feature = "macos", target_os = "macos")
    ))]
    #[test]
    fn shortcut_text_detail_wins_over_key_equivalent() {
        use crate::accelerator::{Accelerator, AcceleratorId, AcceleratorScope, KeyBinding};
        let item = ContextMenuItem {
            id: Some(WidgetId::new("save")),
            label: StyledText::plain("Save"),
            detail: Some(StyledText::plain("legacy-string")),
            key_equivalent: Some(Accelerator {
                id: AcceleratorId::new("editor.save"),
                binding: KeyBinding::Save,
                scope: AcceleratorScope::Global,
                label: None,
            }),
            ..Default::default()
        };
        let shortcut =
            super::native_surface_paint::shortcut_text(&item, crate::accelerator::Platform::Linux)
                .expect("detail wins");
        assert_eq!(shortcut, "legacy-string");
    }

    #[cfg(any(
        feature = "gtk",
        feature = "win",
        all(feature = "macos", target_os = "macos")
    ))]
    #[test]
    fn shortcut_text_falls_back_to_key_equivalent_per_platform() {
        use crate::accelerator::{Accelerator, AcceleratorId, AcceleratorScope, KeyBinding};
        let item = ContextMenuItem {
            id: Some(WidgetId::new("save")),
            label: StyledText::plain("Save"),
            key_equivalent: Some(Accelerator {
                id: AcceleratorId::new("editor.save"),
                binding: KeyBinding::Save,
                scope: AcceleratorScope::Global,
                label: None,
            }),
            ..Default::default()
        };
        let shortcut =
            super::native_surface_paint::shortcut_text(&item, crate::accelerator::Platform::Macos)
                .expect("key_equivalent produces a string");
        assert!(
            shortcut.contains('⌘'),
            "macOS shortcut should contain ⌘, got {shortcut:?}",
        );
    }

    // ── ContextMenu::measure_generic_width ────────────────────────────────

    #[test]
    fn measure_generic_width_widens_for_longer_label() {
        let short = menu(vec![cm_action("a", "Cut")]);
        let long = menu(vec![cm_action("a", "Toggle Sidebar Visibility")]);
        let short_w = short.measure_generic_width(1.0, 0.0);
        let long_w = long.measure_generic_width(1.0, 0.0);
        assert!(
            long_w > short_w,
            "longer label should produce a wider menu: {long_w} vs {short_w}"
        );
    }

    #[test]
    fn measure_generic_width_scales_with_char_width() {
        let m = menu(vec![cm_action("a", "Copy")]);
        let w1 = m.measure_generic_width(1.0, 0.0);
        let w2 = m.measure_generic_width(2.0, 0.0);
        assert_eq!(w2, w1 * 2.0);
    }

    #[test]
    fn measure_generic_width_checked_item_reserves_prefix_space() {
        let unchecked = menu(vec![cm_action("a", "Wrap")]);
        let mut checked_items = vec![cm_action("a", "Wrap")];
        checked_items[0].checked = Some(true);
        let checked = menu(checked_items);
        assert!(
            checked.measure_generic_width(1.0, 0.0) > unchecked.measure_generic_width(1.0, 0.0),
            "checked item reserves extra prefix width"
        );
    }

    #[test]
    fn measure_generic_width_submenu_arrow_reserves_trailing_space() {
        let plain = menu(vec![cm_action("a", "View")]);
        let mut submenu_items = vec![cm_action("a", "View")];
        submenu_items[0].submenu = Some(vec![cm_action("b", "Toggle Sidebar")]);
        let with_submenu = menu(submenu_items);
        assert!(
            with_submenu.measure_generic_width(1.0, 0.0) > plain.measure_generic_width(1.0, 0.0),
            "submenu arrow reserves extra trailing width"
        );
    }

    #[test]
    fn measure_generic_width_detail_reserves_trailing_space() {
        let plain = menu(vec![cm_action("a", "Save")]);
        let mut detail_items = vec![cm_action("a", "Save")];
        detail_items[0].detail = Some(StyledText::plain("Ctrl+S"));
        let with_detail = menu(detail_items);
        assert!(
            with_detail.measure_generic_width(1.0, 0.0) > plain.measure_generic_width(1.0, 0.0),
            "detail text reserves extra trailing width"
        );
    }

    #[test]
    fn measure_generic_width_border_chrome_inset_shrinks_result() {
        let m = menu(vec![cm_action("a", "Copy")]);
        let no_inset = m.measure_generic_width(1.0, 0.0);
        let with_inset = m.measure_generic_width(1.0, 1.0);
        assert_eq!(with_inset, no_inset - 2.0);
    }

    #[test]
    fn measure_generic_width_never_shrinks_below_one_char() {
        let m = menu(vec![cm_action("a", "X")]);
        let w = m.measure_generic_width(1.0, 1000.0);
        assert_eq!(w, 1.0, "clamped to at least one char_width");
    }

    #[test]
    fn measure_generic_width_empty_menu() {
        let m = menu(vec![]);
        // Only the 2-char interior margin, no item content.
        assert_eq!(m.measure_generic_width(1.0, 0.0), 2.0);
    }

    #[cfg(any(
        feature = "gtk",
        feature = "win",
        all(feature = "macos", target_os = "macos")
    ))]
    mod glyph_override {
        use super::*;
        use crate::accelerator::Platform;
        use crate::paint_surface::PaintSurface;
        use crate::style::Style;
        use crate::theme::Theme;
        use crate::types::Color;
        use crate::{Point, Rect, Viewport};

        /// Records every painted glyph/text run so a test can assert on
        /// what the rasteriser actually drew, not an internal helper.
        #[derive(Default)]
        struct RecordingSurface {
            runs: Vec<String>,
        }

        impl PaintSurface for RecordingSurface {
            fn surface_begin_frame(&mut self, _viewport: Viewport) {}
            fn surface_end_frame(&mut self) {}
            fn surface_viewport(&self) -> Viewport {
                Viewport::new(0.0, 0.0, 1.0)
            }
            fn surface_line_height(&self) -> f32 {
                16.0
            }
            fn surface_char_width(&self) -> f32 {
                8.0
            }
            fn surface_measure_text(&self, text: &str) -> (f32, f32) {
                (text.chars().count() as f32 * 8.0, 16.0)
            }
            fn surface_fill_rect(&mut self, _rect: Rect, _color: Color) {}
            fn surface_fill_rounded_rect(&mut self, _rect: Rect, _radius: f32, _color: Color) {}
            fn surface_stroke_rect(&mut self, _rect: Rect, _color: Color, _stroke_width: f32) {}
            fn surface_stroke_rounded_rect(
                &mut self,
                _rect: Rect,
                _radius: f32,
                _color: Color,
                _stroke_width: f32,
            ) {
            }
            fn surface_draw_text_run(&mut self, _rect: Rect, text: &str, _color: Color) {
                self.runs.push(text.to_string());
            }
            fn surface_draw_line(
                &mut self,
                _from: Point,
                _to: Point,
                _color: Color,
                _stroke_width: f32,
            ) {
            }
            fn surface_push_clip(&mut self, _rect: Rect) {}
            fn surface_pop_clip(&mut self) {}
            fn surface_draw_image(
                &mut self,
                _rect: Rect,
                _image: &crate::Image,
            ) -> crate::backend::ImagePaintResult {
                crate::backend::ImagePaintResult::Unsupported
            }
        }

        fn popup_layout(item: ContextMenuItem) -> (ContextMenu, ContextMenuLayout) {
            let m = menu(vec![item]);
            let viewport = Rect::new(0.0, 0.0, 800.0, 600.0);
            let layout = m.layout(10.0, 10.0, viewport, 100.0, |_| {
                ContextMenuItemMeasure::new(20.0)
            });
            (m, layout)
        }

        /// With no [`ContextMenuGlyphs`] override, `paint_with_glyphs`
        /// (what `paint` always calls) paints the default codicon
        /// check mark — byte-for-byte today's behaviour.
        #[test]
        fn no_override_paints_default_codicon_check() {
            let mut item = cm_action("a", "Save");
            item.checked = Some(true);
            let (m, layout) = popup_layout(item);
            let mut surface = RecordingSurface::default();
            native_surface_paint::paint_with_glyphs(
                &m,
                &layout,
                Platform::Linux,
                &mut surface,
                &Theme::default(),
                &Style::default(),
                None,
                false,
            );
            let expected = crate::codicon::CHECK.to_string();
            assert!(
                surface.runs.iter().any(|r| r == &expected),
                "expected default codicon check among {:?}",
                surface.runs
            );
        }

        /// An app-supplied [`ContextMenuGlyphs`] override replaces the
        /// check mark with the app's own glyph; the codicon default no
        /// longer appears.
        #[test]
        fn override_replaces_check_glyph() {
            let mut item = cm_action("a", "Save");
            item.checked = Some(true);
            let (m, layout) = popup_layout(item);
            let glyphs = ContextMenuGlyphs::new().with_checked_glyph("V");
            let mut surface = RecordingSurface::default();
            native_surface_paint::paint_with_glyphs(
                &m,
                &layout,
                Platform::Linux,
                &mut surface,
                &Theme::default(),
                &Style::default(),
                Some(&glyphs),
                true,
            );
            assert!(surface.runs.iter().any(|r| r == "V"));
            let default_codicon = crate::codicon::CHECK.to_string();
            assert!(!surface.runs.iter().any(|r| r == &default_codicon));
        }

        /// Same override/default contract for the submenu pull-right arrow.
        #[test]
        fn override_replaces_submenu_arrow_glyph() {
            let mut item = cm_action("a", "More");
            item.submenu = Some(vec![cm_action("b", "Nested")]);
            let (m, layout) = popup_layout(item);
            let glyphs = ContextMenuGlyphs::new().with_submenu_arrow_glyph(">");
            let mut surface = RecordingSurface::default();
            native_surface_paint::paint_with_glyphs(
                &m,
                &layout,
                Platform::Linux,
                &mut surface,
                &Theme::default(),
                &Style::default(),
                Some(&glyphs),
                true,
            );
            assert!(surface.runs.iter().any(|r| r == ">"));
            let default_codicon = crate::codicon::CHEVRON_RIGHT.to_string();
            assert!(!surface.runs.iter().any(|r| r == &default_codicon));
        }
    }
}