-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathwidgets.rs
More file actions
1280 lines (1221 loc) · 58.1 KB
/
Copy pathwidgets.rs
File metadata and controls
1280 lines (1221 loc) · 58.1 KB
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
//!
//! Reusable painted UI helpers: icon buttons, tabs, modals, the white "sheet", the styled
//! scrollbar. The custom window chrome (caption bar, border, resize handles) lives in
//! [`crate::winchrome`].
use crate::theme::p;
use crate::{DIAG_BOXES, RADIUS_CONTROL, RADIUS_ISLAND};
use eframe::egui;
use egui::{Color32, Margin, CornerRadius, Pos2, Stroke, Vec2};
const ICON_GLYPH: f32 = 17.5;
/// Smaller icon glyph for the work-area sub-toolbars (a touch smaller than the main toolbar).
const SM_ICON_GLYPH: f32 = 15.0;
// Icon-button width is no longer fixed: the button is SQUARE — its side = the height of its own row
// (`ui.max_rect().height()`), so the box is larger in the main toolbar than in the sub-toolbars. The
// `qchevron` arrows are square too, so the reserve for a pair = `2 * row_h` is computed at the call site.
/// Full-screen dim backdrop for modal dialogs (translucent black that swallows clicks). Shared by
/// every modal so they all dim identically. `id` must be unique per modal.
/// Backdrop alpha for modal dialogs (translucent black that dims the app behind). The single raw
/// color value shared by every modal's dim layer.
const MODAL_DIM_ALPHA: u8 = 120;
fn dim(ctx: &egui::Context, id: &str) {
let screen = ctx.content_rect();
egui::Area::new(egui::Id::new(id))
.order(egui::Order::Middle)
.fixed_pos(screen.left_top())
.show(ctx, |ui| {
ui.painter().rect_filled(screen, 0.0, Color32::from_black_alpha(MODAL_DIM_ALPHA));
ui.allocate_rect(screen, egui::Sense::click()); // swallow clicks outside the box
});
}
/// Dismissal gestures for [`show_modal`]: which "close me" inputs fired this frame. The caller
/// decides what state to clear — most dialogs close on their own button / × inside, some also on
/// Escape.
///
/// The modal key contract (Design Delta v2.1 §5): **Enter presses the primary/destructive
/// button, Esc presses Cancel** — every modal wires `enter` to its one primary action.
pub(crate) struct ModalDismiss {
pub escape: bool, // Escape was pressed → Cancel/close
pub enter: bool, // Enter was pressed → the primary (or destructive-primary) action
}
/// The one centered modal-dialog scaffold every dialog shares: the dim backdrop + a foreground box
/// framed by the common [`crate::theme::modal_frame`] with the modal widget style applied. Runs
/// `contents` inside the box (fixed `width`) and returns the dismissal gestures so the caller can
/// close the right state. `id` must be unique per modal.
pub(crate) fn show_modal(
ctx: &egui::Context,
id: &str,
width: f32,
contents: impl FnOnce(&mut egui::Ui),
) -> ModalDismiss {
dim(ctx, &format!("{id}_dim")); // draw the dim backdrop + swallow outside clicks
egui::Area::new(egui::Id::new(id).with("box"))
.order(egui::Order::Foreground)
.anchor(egui::Align2::CENTER_CENTER, Vec2::ZERO)
.show(ctx, |ui| {
crate::theme::modal_frame(ctx).show(ui, |ui| {
ui.set_width(width);
crate::theme::style_modal_widgets(ui);
contents(ui);
});
});
ModalDismiss {
escape: ctx.input(|i| i.key_pressed(egui::Key::Escape)),
enter: ctx.input(|i| i.key_pressed(egui::Key::Enter)),
}
}
/// A modal's title row: the 15pt bold heading on the left and a close-× on the right. Returns true
/// when the × was clicked, so the caller clears its own open-state. The single source of the
/// modal-title metrics — dialogs use it instead of re-typing the `horizontal` / `RichText` /
/// `right_to_left` + `close_x` block. (Plain title-only modals just use a bare bold label.)
pub fn modal_header(ui: &mut egui::Ui, title: &str) -> bool {
let mut closed = false;
ui.horizontal(|ui| {
ui.label(egui::RichText::new(title).size(crate::HEADING_SIZE).strong().color(p().text));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if close_x(ui, "Close") {
closed = true;
}
});
});
closed
}
/// Empty-state hint with a real left indent (SPACE_2) instead of leading ASCII spaces, so the
/// hint aligns with the row glyphs in the same island and stays stable under any font.
pub fn empty_hint(ui: &mut egui::Ui, text: &str) {
let galley = ui.painter().layout(
text.to_owned(),
egui::FontId::proportional(crate::theme::BODY_SIZE),
p().text_dim,
f32::INFINITY,
);
let h = galley.size().y;
ui.allocate_ui_with_layout(
Vec2::new(ui.available_width(), h),
egui::Layout::left_to_right(egui::Align::TOP),
|ui| {
ui.add_space(crate::SPACE_2);
ui.label(egui::RichText::new(text).color(p().text_dim));
},
);
}
/// A work-area sub-toolbar strip: a chrome band (same fill as the surrounding chrome, no border
/// of its own) holding a centred icon row. top:2 compensates for the work-area sheet's top seam
/// below (1px frame margin + 1px border) — without it the icon row reads a hair high. Shared by
/// the editor, result-panel, connection-manager and connection-tab toolbars.
/// A structural spacer row: an empty `CHROME_GUTTER`-tall strip in the chrome colour. An explicit
/// 4px gap between horizontal strips — used instead of top margins so the gaps don't add up. In the
/// split zone (under the dock) `Panel::top` occupies only the RIGHT part (over the editor), which is
/// exactly what we want.
pub fn vgap(ui: &mut egui::Ui, id: &'static str) {
egui::Panel::top(id)
.exact_size(crate::CHROME_GUTTER)
.show_separator_line(false)
.frame(egui::Frame::new().fill(p().panel2))
.show(ui, |_ui| {});
}
pub fn subbar(ui: &mut egui::Ui, id: &'static str, left: i8, add: impl FnOnce(&mut egui::Ui)) {
egui::Panel::top(id)
.exact_size(crate::SUBBAR_H)
.show_separator_line(false)
.frame(egui::Frame::new().fill(p().panel2).inner_margin(Margin {
// `left` is set by the caller: in the dock — the full gutter (window edge); in the result
// panel — `dock_left()` (0 under the dock), so the sub-toolbar doesn't drift right of the
// grid body and the tabs.
left,
right: crate::CHROME_GUTTER as i8,
// 4px gap below the header/tabs. The pill is flat now (no inset), so there's no doubling —
// this is the only gap above the sub-toolbar.
top: crate::CHROME_GUTTER as i8,
bottom: 0,
}))
.show(ui, |ui| {
ui.horizontal_centered(|ui| {
// a single inter-icon gap shared by every toolbar (main/managers/results)
ui.spacing_mut().item_spacing.x = crate::ICON_GAP;
add(ui);
});
});
}
/// Frameless icon button (size-parameterized): neutral soft box on hover, glyph keeps its colour.
/// The single icon-button engine (size-parameterized). `enabled=false` → inert, dimmed, no
/// hover box (except under DIAG_BOXES); the glyph takes the `disabled` colour and the `color`
/// argument is ignored. Returns a Response (the off wrappers ignore it). There used to be two
/// nearly identical engines.
fn qbtn_glyph(
ui: &mut egui::Ui,
icon: &str,
color: Color32,
tip: &str,
glyph: f32,
enabled: bool,
) -> egui::Response {
// SQUARE button: side = the row height (≈30 in the main toolbar, ≈22 in the sub-toolbars)
let h = ui.max_rect().height();
let size = Vec2::new(h, h);
let sense = if enabled { egui::Sense::click() } else { egui::Sense::hover() };
let (rect, resp) = ui.allocate_exact_size(size, sense);
// hover soft box fades in/out (~0.1s) — only for the active one; disabled shows it only under DIAG_BOXES
let t = if crate::DIAG_BOXES {
1.0
} else if enabled {
ui.ctx().animate_bool(resp.id, resp.hovered())
} else {
0.0
};
if t > 0.0 {
let box_rect = rect;
ui.painter().rect_filled(
box_rect,
CornerRadius::same(crate::RADIUS_ICON),
p().acc_bg.gamma_multiply(t),
);
}
// hover is neutral: the soft box is the affordance, the glyph keeps its colour (accent is
// reserved for committed/meaningful state, never hover — Design System §2).
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
icon,
egui::FontId::proportional(glyph),
if enabled { color } else { p().disabled },
);
resp.on_hover_text(tip)
}
/// Frameless icon button: neutral soft box on hover, glyph keeps its colour. Fills the row height.
pub fn qbtn(ui: &mut egui::Ui, icon: &str, tip: &str) -> egui::Response {
qbtn_glyph(ui, icon, p().text, tip, ICON_GLYPH, true)
}
/// Full-size icon button with an explicit glyph colour — for the main toolbar's stateful actions
/// (green Execute, red Stop, amber Download). Same hover box as [`qbtn`].
pub fn qbtn_col(ui: &mut egui::Ui, icon: &str, color: Color32, tip: &str) -> egui::Response {
qbtn_glyph(ui, icon, color, tip, ICON_GLYPH, true)
}
/// Smaller frameless icon button — for the work-area sub-toolbars.
pub fn qbtn_sm(ui: &mut egui::Ui, icon: &str, color: Color32, tip: &str) -> egui::Response {
qbtn_glyph(ui, icon, color, tip, SM_ICON_GLYPH, true)
}
/// Toggle icon button: darker background while `active` (pressed), lighter on hover — same
/// accent policy as the tabs / menus. Returns the click response.
pub fn qbtn_toggle(ui: &mut egui::Ui, icon: &str, active: bool, tip: &str) -> egui::Response {
let h = ui.max_rect().height();
let size = Vec2::new(h, h); // square, like the other icon buttons
let (rect, resp) = ui.allocate_exact_size(size, egui::Sense::click());
let box_rect = rect;
let r = CornerRadius::same(crate::RADIUS_ICON);
if active {
// committed state — solid, instant
ui.painter().rect_filled(box_rect, r, p().acc_bg2);
} else {
// hover soft box fades in/out (~0.1s) — cheap, via egui's per-id animation
let t = if DIAG_BOXES { 1.0 } else { ui.ctx().animate_bool(resp.id, resp.hovered()) };
if t > 0.0 {
ui.painter().rect_filled(box_rect, r, p().acc_bg.gamma_multiply(t));
}
}
ui.painter().text(
rect.center(),
egui::Align2::CENTER_CENTER,
icon,
egui::FontId::proportional(ICON_GLYPH),
p().text,
);
resp.on_hover_text(tip)
}
/// Disabled (dimmed, inert) icon at the main-toolbar size — the counterpart of [`qbtn`].
pub fn qbtn_off(ui: &mut egui::Ui, icon: &str, tip: &str) {
qbtn_glyph(ui, icon, p().text, tip, ICON_GLYPH, false);
}
/// Smaller disabled icon — for the work-area sub-toolbars.
pub fn qbtn_off_sm(ui: &mut egui::Ui, icon: &str, tip: &str) {
qbtn_glyph(ui, icon, p().text, tip, SM_ICON_GLYPH, false);
}
/// Paint a chevron (or double chevron) pointing left/right, centred in `rect` — the icon set
/// has no left-pointing glyphs, so directional arrows are drawn in the set's own language
/// (1.8 stroke, matching the 24-grid proportions).
pub fn paint_chevron(
painter: &egui::Painter,
rect: egui::Rect,
left: bool,
double: bool,
color: Color32,
) {
let c = rect.center();
let (half_h, half_w) = (4.5_f32, 2.5_f32);
let dir = if left { -1.0 } else { 1.0 };
let st = Stroke::new(1.8, color);
let tip = |cx: f32| {
painter.line_segment(
[egui::pos2(cx - dir * half_w, c.y - half_h), egui::pos2(cx + dir * half_w, c.y)],
st,
);
painter.line_segment(
[egui::pos2(cx + dir * half_w, c.y), egui::pos2(cx - dir * half_w, c.y + half_h)],
st,
);
};
if double {
tip(c.x - dir * 2.5);
tip(c.x + dir * 2.5);
} else {
tip(c.x);
}
}
/// Chevron icon button in the chrome-row style of [`qbtn`] (neutral hover box, glyph keeps
/// its colour) — for the tab-strip scroll arrows.
pub fn qchevron(ui: &mut egui::Ui, left: bool, tip: &str) -> egui::Response {
let h = ui.max_rect().height();
let size = Vec2::new(h, h); // square: a pair of arrows flush = 2 * row_h (see the reserve at the call sites)
let (rect, resp) = ui.allocate_exact_size(size, egui::Sense::click());
if resp.hovered() || DIAG_BOXES {
let box_rect = rect;
ui.painter().rect_filled(box_rect, CornerRadius::same(crate::RADIUS_ICON), p().acc_bg);
}
paint_chevron(ui.painter(), rect, left, false, p().text);
resp.on_hover_text(tip)
}
/// Small painted close "×" — neutral at rest, `danger` red on hover (destructive action).
/// A square hit-area (side = the row height), like the other icon buttons, so it sits flush in the row.
pub fn close_x(ui: &mut egui::Ui, tip: &str) -> bool {
const HALF: f32 = 4.0; // half-length of each × arm
let h = ui.max_rect().height();
let (rect, resp) = ui.allocate_exact_size(Vec2::new(h, h), egui::Sense::click());
let col = if resp.hovered() { p().danger } else { p().text_dim };
crate::icons::paint_cross(ui.painter(), rect.center(), HALF, Stroke::new(1.4, col));
resp.on_hover_text(tip).clicked()
}
/// One tab's leading status mark in [`tab_strip`]: a spinning loader (a query is running on this tab)
/// or a static glyph. `tint` (when `Some`) colours the WHOLE pill — glyph **and** label — to carry a
/// result's state; `None` keeps a neutral glyph and the default active/inactive label colour. The
/// fixed 16px slot is reserved on every tab so the strip never jumps between the two.
pub struct TabMark {
pub spinning: bool,
pub glyph: &'static str,
pub tint: Option<Color32>,
}
/// Pill tabs — the studio signature (Design System v2 §6). Active tab is a pill:
/// `accent_soft` fill, radius 4 rectangle (v2.2 — no pills), `accent_hi` text, blur-2 shadow.
/// Inactive: transparent, `text_dim`, a neutral hover pill. No underline bars.
/// An × shows on the active tab when `closable`.
/// Returns `(selected, closed)` indices.
#[allow(clippy::too_many_arguments)]
pub fn tab_strip(
ui: &mut egui::Ui,
labels: &[String],
active: usize,
closable: bool,
marks: Option<&[TabMark]>, // Some → leading status mark per tab (spinner while busy / state glyph)
gap: f32, // inter-tab spacing (0 for the result strip, a touch of air for editors)
reorderable: bool, // drag a tab to reorder it (editor tabs)
scroll_active_into_view: bool, // scroll the active pill into view (the row just changed tab non-click)
) -> (Option<usize>, Option<usize>, Option<(usize, usize)>) {
ui.spacing_mut().item_spacing.x = gap;
let mut drag_end: Option<usize> = None; // a tab whose drag just finished (→ reorder on drop)
let mut centers: Vec<f32> = Vec::with_capacity(labels.len()); // cell-center x, for the drop target
let h = ui.max_rect().height();
let font = egui::FontId::proportional(crate::theme::BODY_SIZE);
let pad = 10.0; // a touch more side padding — pills read better with air around the label
// fixed-width leading slot for the marker, reserved on every tab so the width never jumps
let mark_w = if marks.is_some() { 16.0 } else { 0.0 };
let pill_radius = CornerRadius::same(RADIUS_CONTROL);
let mut select = None;
let mut close = None;
for (i, label) in labels.iter().enumerate() {
let is_active = i == active;
let galley = ui.painter().layout_no_wrap(label.clone(), font.clone(), p().text);
// reserve the close-× width on every closable tab (not just the active one) so the
// strip doesn't jump when the active tab changes
let close_w = if closable { 6.0 + 12.0 } else { 0.0 };
let cell_w = pad + mark_w + galley.size().x + close_w + pad;
let sense = if reorderable { egui::Sense::click_and_drag() } else { egui::Sense::click() };
let (rect, resp) = ui.allocate_exact_size(Vec2::new(cell_w, h), sense);
centers.push(rect.center().x);
if reorderable {
if resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
}
if resp.drag_stopped() {
drag_end = Some(i);
}
}
// the pill fills the whole row: top/bottom air comes from the spacer rows (vgap), not an inset
let pill_rect = rect;
if is_active && scroll_active_into_view {
// keep the active pill in view when the row overflows (open/new/from a manager/Ctrl-Tab).
// `None` = scroll the minimum needed → no tug when it's already visible.
resp.scroll_to_me(None);
}
if is_active {
// subtle lift: offset [0,1], blur 2 — softer than the island shadow
ui.painter().add(
egui::epaint::Shadow { offset: [0, 1], blur: 2, spread: 0, color: p().shadow }
.as_shape(pill_rect, pill_radius),
);
ui.painter().rect_filled(pill_rect, pill_radius, p().accent_soft);
} else if resp.hovered() || DIAG_BOXES {
ui.painter().rect_filled(pill_rect, pill_radius, p().hover);
}
// glyph + label share ONE colour: a mark's `tint` (an error) colours the whole pill red;
// otherwise the active tab is normal `text`, inactive `text_dim` — NOT the accent colour, so an
// active tab never gets confused with a red error tab (the pill background also marks active)
let mk = marks.and_then(|m| m.get(i));
let mk_col = mk
.and_then(|m| m.tint)
.unwrap_or(if is_active { p().text } else { p().text_dim });
// leading marker: a small spinning loader while a query runs on this tab, otherwise the glyph
if marks.is_some() {
let my = pill_rect.center().y;
if mk.is_some_and(|m| m.spinning) {
let tsec = ui.input(|inp| inp.time) as f32;
// small ring (≈ cap height), right edge aligned to where the rest-state glyph ends so
// the gap to the label matches the at-rest glyph→label gap
spinner(ui.painter(), egui::pos2(rect.left() + pad + 6.5, my), 4.5, p().accent_hi, p().border, tsec);
ui.ctx().request_repaint_after(std::time::Duration::from_millis(33));
} else if let Some(m) = mk {
ui.painter().text(
egui::pos2(rect.left() + pad, my),
egui::Align2::LEFT_CENTER,
m.glyph,
egui::FontId::proportional(12.0),
mk_col,
);
}
}
ui.painter().text(
egui::pos2(rect.left() + pad + mark_w, pill_rect.center().y),
egui::Align2::LEFT_CENTER,
label,
font.clone(),
mk_col,
);
// close × on every tab (own hit-area so it doesn't trigger a tab switch) — always visible,
// not just the active one, so it's always one click away (a dirty tab still confirms first)
let mut close_hit = false;
if closable {
let cc = egui::pos2(rect.right() - pad - 6.0, pill_rect.center().y);
let xr = egui::Rect::from_center_size(cc, Vec2::new(14.0, h));
let xresp = ui.interact(xr, ui.id().with(("tab_close", i)), egui::Sense::click());
// match the label colour (normal on the active tab, dim otherwise) — never accent, only
// danger on hover; so the active tab's × isn't highlighted in the accent colour
let col = if xresp.hovered() {
p().danger
} else if is_active {
p().text
} else {
p().text_dim
};
crate::icons::paint_cross(ui.painter(), cc, 3.0, Stroke::new(1.4, col));
if xresp.clicked() {
close = Some(i);
close_hit = true;
}
xresp.on_hover_text("Close tab");
}
if resp.clicked() && !close_hit {
select = Some(i);
}
}
// a tab drag has finished → where to insert it (by pointer X relative to the cell centers)
let mut reorder = None;
if let Some(from) = drag_end {
if let Some(px) = ui.input(|i| i.pointer.interact_pos().or(i.pointer.latest_pos())) {
let to = centers.iter().position(|&cx| px.x < cx).unwrap_or(centers.len());
// to == from or from+1 — the same position (insert before itself / right after) → don't move
if to != from && to != from + 1 {
reorder = Some((from, to));
}
}
}
(select, close, reorder)
}
/// A one-sector loading ring drawn via `painter` (so it fits a tab-marker slot): a faint full-circle
/// track plus a bright ~0.3-turn arc that rotates with `t` (seconds). The caller must keep requesting
/// repaints while it spins. Hand-rolled because egui's `Painter` cannot rotate a glyph.
pub fn spinner(painter: &egui::Painter, center: Pos2, radius: f32, arc: Color32, track: Color32, t: f32) {
use std::f32::consts::TAU;
let w = (hairline(painter.ctx()) * 2.0).max(1.4);
let ring: Vec<Pos2> = (0..=40)
.map(|k| {
let a = (k as f32 / 40.0) * TAU;
center + Vec2::new(a.cos(), a.sin()) * radius
})
.collect();
painter.add(egui::Shape::line(ring, Stroke::new(w, track)));
let a0 = (t * 2.6) % TAU;
let sweep = TAU * 0.3;
let arc_pts: Vec<Pos2> = (0..=14)
.map(|k| {
let a = a0 + sweep * (k as f32 / 14.0);
center + Vec2::new(a.cos(), a.sin()) * radius
})
.collect();
painter.add(egui::Shape::line(arc_pts, Stroke::new(w, arc)));
}
/// The activity pill centred over an (empty / still-loading) result area: accent-filled like the
/// primary button, non-interactive, with animated trailing dots after `label` ("Running" /
/// "Cancelling"). Sized to the widest state so the dots don't jitter it. The caller must keep
/// requesting repaints while it shows.
pub fn running_overlay(ui: &mut egui::Ui, area: egui::Rect, t: f32, label: &str) {
let dots = ".".repeat(((t * 2.0) as usize) % 4);
let font = crate::theme::ui_bold_font(13.0);
let painter = ui.painter().clone();
let g = painter.layout_no_wrap(format!("{label}..."), font.clone(), p().on_accent);
let pad = Vec2::new(14.0, 3.0);
let rect = snap_rect(&painter, egui::Rect::from_center_size(area.center(), g.size() + pad * 2.0));
painter.rect_filled(rect, CornerRadius::same(RADIUS_CONTROL), p().accent);
painter.text(
egui::pos2(rect.left() + pad.x, rect.center().y),
egui::Align2::LEFT_CENTER,
format!("{label}{dots}"),
font,
p().on_accent,
);
ui.ctx().request_repaint_after(std::time::Duration::from_millis(120));
}
/// A clickable status-bar chip (version / scan): the text plus a hover pill so it reads as a button.
/// Keeps the plain arrow cursor (the chip sits over the window-edge resize strip). Returns the click
/// response so the caller can attach a tooltip and act on `.clicked()`.
pub fn chip_button(ui: &mut egui::Ui, text: &str, color: egui::Color32, sz: f32) -> egui::Response {
// Family mirrors the status bar's override (Proportional now — swap to Monospace to match the
// editor). Chips paint via the painter, so they set the family explicitly rather than inheriting.
let font = egui::FontId::new(sz, egui::FontFamily::Proportional);
let galley = ui.painter().layout_no_wrap(text.to_owned(), font, color);
// The hover accent extends 4px past the glyphs horizontally (1px vertically) — this 4px is the
// "button accent"; the bare left labels match it with extra item-spacing so the dividers align.
let padding = Vec2::new(4.0, 1.0);
let (rect, resp) = ui.allocate_exact_size(galley.size() + padding * 2.0, egui::Sense::click());
if resp.hovered() {
ui.painter()
.rect_filled(rect, CornerRadius::same(RADIUS_CONTROL), p().hover);
ui.ctx().set_cursor_icon(egui::CursorIcon::Default);
}
ui.painter().galley(rect.center() - galley.size() / 2.0, galley, color);
resp
}
/// White result/editing sheet (field_bg fill, soft shadow). See [`island_panel`].
pub fn island<R>(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
island_panel(ui, p().field_bg, add)
}
/// A rounded, shadowed island hosting (scrollable) content. The shadow and the `fill` go UNDER the
/// content and the crisp 1px border goes ON TOP of it — all keyed to ONE pixel-snapped rect, so:
/// * the border covers any content fill that runs to the edge (no white halo / corner triangles);
/// * fill and border trace the SAME snapped rounded path, so there is no corner seam (the trap the
/// old "snap only the overlay border over an unsnapped Frame fill" pattern fell into);
/// * straight edges are razor-sharp on the device grid at any DPI.
///
/// The content closure runs inside a frameless `Frame` (we paint the fill/shadow ourselves, snapped).
pub fn island_panel<R>(
ui: &mut egui::Ui,
fill: Color32,
add: impl FnOnce(&mut egui::Ui) -> R,
) -> R {
let r = CornerRadius::same(RADIUS_ISLAND);
// Reserve the shadow + fill slots BEFORE the content so they paint beneath it; backfill them
// once the content's rect (and thus the snapped rect) is known.
let sh = ui.painter().add(egui::Shape::Noop);
let bg = ui.painter().add(egui::Shape::Noop);
let inner = egui::Frame::new()
.corner_radius(r)
.inner_margin(Margin::ZERO)
.show(ui, add);
let rect = snap_rect(ui.painter(), inner.response.rect);
ui.painter().set(sh, crate::theme::island_shadow().as_shape(rect, r));
ui.painter().set(bg, egui::Shape::rect_filled(rect, r, fill));
crisp_border(ui.painter(), rect, p().border_strong);
inner.inner
}
/// One form row — THE form law, hard numbers (Design Delta v2.2 §6): a 16px label line
/// (`Small`/`text_dim`) → exactly 4px → the control → exactly 16px to the next row. Vertical
/// item-spacing is zeroed inside so nothing pads the gaps — the label visually belongs to
/// ITS control. Hand-rolled label+field stacks in forms are forbidden; use this.
pub fn form_row<R>(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
ui.scope(|ui| {
ui.spacing_mut().item_spacing.y = 0.0;
ui.allocate_ui_with_layout(
Vec2::new(ui.available_width(), 16.0),
egui::Layout::left_to_right(egui::Align::Center),
|ui| ui.label(egui::RichText::new(label).color(p().text_dim).size(crate::LABEL_SIZE)),
);
ui.add_space(4.0);
let r = add(ui);
ui.add_space(16.0);
r
})
.inner
}
/// Paint the soft studio shadow under a hand-drawn island. Call BEFORE the island's fill —
/// the shadow is a blurred rect that would otherwise darken the island itself.
pub fn island_shadow_under(painter: &egui::Painter, rect: egui::Rect) {
painter.add(
crate::theme::island_shadow().as_shape(rect, CornerRadius::same(RADIUS_ISLAND)),
);
}
/// Width of a physically-crisp hairline: exactly ONE device pixel expressed in logical units. A
/// `Stroke` this wide, laid on a pixel-snapped path, renders as a single hard device pixel at any
/// DPI — unlike a `1.0` *logical* stroke, which smears across ~1.5 device pixels at 125/150% scale.
pub fn hairline(ctx: &egui::Context) -> f32 {
1.0 / ctx.pixels_per_point()
}
/// Stroke-only overlay border (focus/danger rings, the square window outline, selection
/// frames). Crisp-1px experiment (V1): the rect is snapped to whole device pixels and the stroke is
/// exactly one device pixel ([`hairline`]) with `StrokeKind::Inside`, so straight edges land razor
/// sharp on the grid while feathering keeps the rounded corners smooth. Static island borders that
/// sit over a SEPARATE fill must key both to the SAME snapped rect (see [`island_panel`]) — pairing
/// fill+stroke in one shape ([`island_box`]) is the seam-free alternative.
pub fn crisp_border(painter: &egui::Painter, rect: egui::Rect, color: Color32) {
crisp_border_r(painter, rect, color, crate::theme::RADIUS_ISLAND);
}
/// [`crisp_border`] with an explicit radius (0 = the square window outline).
pub fn crisp_border_r(painter: &egui::Painter, rect: egui::Rect, color: Color32, radius: u8) {
let rect = snap_rect(painter, rect);
painter.rect_stroke(
rect,
CornerRadius::same(radius),
Stroke::new(hairline(painter.ctx()), color),
egui::StrokeKind::Inside,
);
}
/// An island/field/popup body: fill + a 1-device-px inside stroke as ONE `RectShape` (Design Delta
/// v2.2 §3) — the single-shape law kills the seams the split fill/stroke passes used to leave
/// around rounded corners. The rect is pixel-snapped so the fill edge AND the border are crisp.
/// Shadow (if any) goes UNDER this via [`island_shadow_under`].
pub fn island_box(painter: &egui::Painter, rect: egui::Rect, fill: Color32, radius: u8) {
let rect = snap_rect(painter, rect);
painter.add(egui::epaint::RectShape::new(
rect,
CornerRadius::same(radius),
fill,
Stroke::new(hairline(painter.ctx()), p().border_strong),
egui::StrokeKind::Inside,
));
}
/// Measure a button's size the way it will actually be painted. `bold` selects the label font so
/// the measured width matches what the bold families (primary/destructive) need — measuring with the
/// regular font starved the wider bold glyphs of their side padding.
fn button_size(ui: &egui::Ui, label: &str, bold: bool) -> Vec2 {
let font = if bold {
crate::theme::ui_bold_font(13.0)
} else {
egui::FontId::proportional(crate::theme::BODY_SIZE)
};
let galley = ui.painter().layout_no_wrap(label.to_owned(), font, p().text);
// unified controls: CONTROL_H tall, 14px side padding (Design Delta v2.2 §4)
Vec2::new(galley.size().x + 14.0 * 2.0, crate::theme::CONTROL_H)
}
/// The widest a button needs to be to fit any of `labels` at the standard geometry. Use it to give
/// every button on one modal the same width (Design System §7 Modals: uniform, right-aligned, at
/// the bottom) — measure the modal's labels once, then render each with the `*_button_w` variant.
///
/// Measures each label with BOTH the regular and the bold font and takes the max, so the width fits
/// whichever family actually paints the widest label (primary/destructive paint bold).
pub fn uniform_button_width(ui: &egui::Ui, labels: &[&str]) -> f32 {
labels.iter().fold(crate::theme::CONTROL_H, |w, l| {
w.max(button_size(ui, l, false).x).max(button_size(ui, l, true).x)
})
}
/// The single filled (accent) button a dialog is allowed: white text, [`RADIUS_CONTROL`], and
/// [`ACCENT_PRESS`] while held. Sizes to its label. Returns true on click.
pub fn primary_button(ui: &mut egui::Ui, label: &str, enabled: bool) -> bool {
primary_button_w(ui, label, enabled, button_size(ui, label, true).x)
}
/// The shared core of the two filled modal buttons (primary / destructive): identical geometry, text
/// (`on_accent`, bold) and disabled look; only the rest / hover / press fill triple differs.
fn filled_button_w(
ui: &mut egui::Ui,
label: &str,
enabled: bool,
width: f32,
rest: Color32,
hover: Color32,
press: Color32,
) -> bool {
let size = Vec2::new(width, crate::theme::CONTROL_H);
let sense = if enabled { egui::Sense::click() } else { egui::Sense::hover() };
let (rect, resp) = ui.allocate_exact_size(size, sense);
if !enabled {
paint_disabled_button(ui.painter(), rect, label);
return false;
}
let fill = if resp.is_pointer_button_down_on() {
press
} else if resp.hovered() {
hover
} else {
rest
};
let pt = ui.painter();
pt.rect_filled(rect, CornerRadius::same(RADIUS_CONTROL), fill);
pt.text(
rect.center(),
egui::Align2::CENTER_CENTER,
label,
crate::theme::ui_bold_font(13.0),
p().on_accent,
);
resp.clicked()
}
/// [`primary_button`] at an explicit width (for uniform modal button bars). Accent fill, darkening
/// on hover/press.
pub fn primary_button_w(ui: &mut egui::Ui, label: &str, enabled: bool, width: f32) -> bool {
filled_button_w(
ui,
label,
enabled,
width,
p().accent,
crate::theme::tint(p().accent, Color32::BLACK, 0.10),
p().accent_press,
)
}
/// One inactive-button look, shared by every button kind (Design System: an inactive button reads
/// like the disabled **Apply** on the Scan page — light field fill, 1px border, grey text; never a
/// heavy solid-grey fill).
fn paint_disabled_button(painter: &egui::Painter, rect: egui::Rect, label: &str) {
island_box(painter, rect, p().field_bg, RADIUS_CONTROL);
painter.text(
rect.center(),
egui::Align2::CENTER_CENTER,
label,
egui::FontId::proportional(crate::theme::BODY_SIZE),
p().disabled,
);
}
/// Destructive primary (Design Delta v2.1 §5): the confirming button of a destructive modal —
/// `danger` fill, `on_accent` text, the exact primary geometry. A modal carries either a primary
/// OR a destructive primary, never both. Width is explicit (for uniform modal button bars).
/// Returns true on click.
pub fn destructive_button_w(ui: &mut egui::Ui, label: &str, enabled: bool, width: f32) -> bool {
filled_button_w(
ui,
label,
enabled,
width,
p().danger,
crate::theme::tint(p().danger, Color32::BLACK, 0.10),
crate::theme::tint(p().danger, Color32::BLACK, 0.22),
)
}
/// Outline (secondary) button at an explicit width (for uniform modal button bars): white fill,
/// 1px `border_strong`, text colour, neutral `hover` fill. Returns true on click.
pub fn secondary_button_w(ui: &mut egui::Ui, label: &str, enabled: bool, width: f32) -> bool {
let size = Vec2::new(width, crate::theme::CONTROL_H);
let sense = if enabled { egui::Sense::click() } else { egui::Sense::hover() };
let (rect, resp) = ui.allocate_exact_size(size, sense);
let (fill, text_col) = if !enabled {
(p().field_bg, p().disabled)
} else if resp.hovered() {
(p().hover, p().text)
} else {
(p().field_bg, p().text)
};
let pt = ui.painter();
island_box(pt, rect, fill, RADIUS_CONTROL);
pt.text(
rect.center(),
egui::Align2::CENTER_CENTER,
label,
crate::theme::ui_bold_font(13.0), // bold — same weight as primary/destructive in a uniform bar
text_col,
);
enabled && resp.clicked()
}
/// A bare single-line input sized to the shared field height, with the accent focus ring drawn
/// over its border when it holds keyboard focus (Design System §6 Text fields). The caller paints
/// the label and the gap; this is just the field, so spacing comes from the `SPACE_*` scale.
pub fn focus_field(ui: &mut egui::Ui, value: &mut String, password: bool, width: f32) -> egui::Response {
let h = crate::theme::FIELD_H; // shared field height so a form's controls line up exactly
let mut te = egui::TextEdit::singleline(value)
.desired_width(width)
// the canonical field inset (never hugs the rounding) + vertical centring, so the text sits
// dead-centre like every combo / list row — one knob in theme.rs
.margin(crate::theme::field_margin())
.vertical_align(egui::Align::Center);
if password {
te = te.password(true);
}
let r = ui.add_sized(Vec2::new(width, h), te);
if r.has_focus() {
crisp_border_r(ui.painter(), r.rect, p().accent, RADIUS_CONTROL);
}
r
}
/// Snap a rect's edges to whole physical pixels. A `rect_filled` whose edge lands on a fractional
/// pixel (e.g. a white sheet whose left = a fractionally-wide side panel) anti-aliases that edge
/// into a soft smear; snapping the rect first keeps the fill edge — and the crisp_border laid over
/// it — razor sharp on every side.
pub fn snap_rect(painter: &egui::Painter, rect: egui::Rect) -> egui::Rect {
let ppp = painter.ctx().pixels_per_point();
let snap = |v: f32| (v * ppp).round() / ppp;
egui::Rect::from_min_max(
egui::pos2(snap(rect.left()), snap(rect.top())),
egui::pos2(snap(rect.right()), snap(rect.bottom())),
)
}
/// Blank the side-panel resize line for the current Ui and return the previous style to restore
/// afterwards. egui 0.34 paints that line from THIS ui's `widgets.{hovered,active}.fg_stroke`
/// (panel.rs) — mutating the global/ctx style has no effect because the panel reads `ui.style()`,
/// which was already cloned. Call before `Panel::…show(ui, …)`, then `ui.set_style(saved)`.
pub fn hush_resize_line(ui: &mut egui::Ui) -> std::sync::Arc<egui::Style> {
let saved = ui.style().clone();
let s = ui.style_mut();
s.visuals.widgets.hovered.fg_stroke = Stroke::NONE;
s.visuals.widgets.active.fg_stroke = Stroke::NONE;
saved
}
/// Style egui's native scrollbar for the current Ui as a DISAPPEARING FLOATING overlay with the SAME
/// idle-timer fade as the grid/editor bars: it shows on activity in the viewport (a wheel scroll, or
/// the pointer moving / pressed inside it), holds briefly, then EASES OUT — even while the pointer
/// still rests inside (egui's native floating bar would stay lit the whole time it's merely hovered).
/// Reserves NO width, so content fills edge-to-edge and a bar toggling never reflows it. Muted handle,
/// pill corners, 8px. No arrows (egui has none).
///
/// The fade uses `vscroll::Fade`, one state per scroll area kept in `ctx.data` keyed by this ui's id.
/// (Safe here where the grid's shared-id bleed can't happen: each manager area is a singleton, so its
/// ui id is stable and unique. The grid/editor keep their own per-tab `Fade` and don't use this path.)
/// The activity area is `ui.clip_rect()` — every caller sets that to the scroll viewport before styling.
pub fn style_scrollbar(ui: &mut egui::Ui) {
// fade FIRST (needs &Ui + ctx), before taking the &mut style borrow below
let area = ui.clip_rect();
let scrolled = crate::vscroll::wheel_delta(ui, area) != egui::Vec2::ZERO;
let fade_id = ui.id().with("sb_fade");
let mut fade: crate::vscroll::Fade =
ui.ctx().data_mut(|d| d.get_temp(fade_id).unwrap_or_default());
let a = fade.alpha(ui, area, scrolled); // 0..SCROLL_OPACITY, eased on the idle timer
ui.ctx().data_mut(|d| d.insert_temp(fade_id, fade));
let st = ui.style_mut();
for (wv, c) in [
(&mut st.visuals.widgets.inactive, p().scroll_dormant),
(&mut st.visuals.widgets.hovered, p().scroll_hot),
(&mut st.visuals.widgets.active, p().scroll_pressed),
(&mut st.visuals.widgets.noninteractive, p().scroll_dormant),
] {
wv.bg_fill = c;
wv.weak_bg_fill = c;
wv.fg_stroke = Stroke::new(1.0, c);
wv.bg_stroke = Stroke::NONE;
// pill handle: radius = half the 8px bar width (Design Delta v2.1 §4)
wv.corner_radius = CornerRadius::same(4);
}
let sc = &mut st.spacing.scroll;
// Floating overlay: no reserved gutter (content reaches the frame), the handle rides on top.
sc.floating = true;
sc.bar_width = 8.0;
sc.floating_width = 8.0;
sc.floating_allocated_width = 0.0; // reserve NO width → no reflow, content reaches the frame
sc.bar_inner_margin = 0.0;
sc.bar_outer_margin = 0.0;
// Fold the idle fade into the handle opacity (backgrounds stay off): the bar's final alpha is
// egui's hover show-factor × this, so when `a` eases to 0 the handle vanishes even while the
// pointer sits inside. dormant stays 0 so it's fully gone once the pointer leaves.
sc.dormant_background_opacity = 0.0;
sc.dormant_handle_opacity = 0.0;
sc.active_background_opacity = 0.0;
sc.active_handle_opacity = a;
sc.interact_background_opacity = 0.0;
sc.interact_handle_opacity = a;
}
/// Scrollbar for the manager LISTS — identical to [`style_scrollbar`] (a disappearing floating overlay
/// that fades on the idle timer) but with egui's content edge-fade gradient re-enabled (it's globally
/// off). An overlay bar lets that gradient span the full width instead of cutting off at a reserved gutter.
pub fn style_scrollbar_overlay(ui: &mut egui::Ui) {
style_scrollbar(ui);
ui.style_mut().spacing.scroll.fade = Default::default(); // bring back egui's edge fade
}
// (The app logo lives in the per-project `crate::brand` — see brand::logo / brand::paint_logo.)
/// A white, thin-bordered single-select list of fixed `size`, styled like the connection
/// dropdown's option rows: full-width rows flush to the edges, no inter-row gap, hover + selected
/// highlight, and a styled scrollbar that only appears when the rows overflow. Clicking a row
/// stores it in `selected`.
pub fn list_pane(
ui: &mut egui::Ui,
id: &str,
size: Vec2,
items: &[String],
sel: &mut Vec<String>,
anchor: &mut Option<usize>,
) -> (egui::Rect, Option<String>) {
// Reserve an exact box in the parent layout (so a horizontal parent advances by size.x), then
// draw into a fresh top-down child — don't inherit the parent's layout direction. Returns the
// pane rect (so the caller can clear the selection on an outside click) + a double-clicked item.
let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover());
island_shadow_under(ui.painter(), rect);
island_box(ui.painter(), rect, p().field_bg, RADIUS_ISLAND);
let (ctrl, shift) = ui.input(|i| (i.modifiers.ctrl, i.modifiers.shift));
let mut dbl: Option<String> = None;
let mut child = ui.new_child(
egui::UiBuilder::new().max_rect(rect).layout(egui::Layout::top_down(egui::Align::Min)),
);
child.set_clip_rect(rect);
style_scrollbar(&mut child);
egui::ScrollArea::vertical()
.id_salt(id)
.auto_shrink([false, false])
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded)
.show(&mut child, |ui| {
ui.set_width(ui.available_width());
ui.spacing_mut().item_spacing = Vec2::ZERO; // flush rows, no gaps
let row_h = MGR_ROW_H; // every selectable row (lists, tree, combo popup) shares one height
for (i, it) in items.iter().enumerate() {
let is_sel = sel.iter().any(|s| s == it);
let (r, resp) = ui
.allocate_exact_size(Vec2::new(ui.available_width(), row_h), egui::Sense::click());
if is_sel {
ui.painter().rect_filled(r, CornerRadius::ZERO, p().select);
} else if resp.contains_pointer() {
// contains_pointer (not hovered) so the accent persists while the button is held
ui.painter().rect_filled(r, CornerRadius::ZERO, p().acc_bg);
}
ui.painter().text(
egui::pos2(r.left() + crate::theme::TEXT_INSET, r.center().y),
egui::Align2::LEFT_CENTER,
it,
egui::FontId::proportional(crate::theme::BODY_SIZE),
p().text,
);
if resp.double_clicked() {
dbl = Some(it.clone());
} else if resp.clicked() {
select_click(sel, anchor, items, i, ctrl, shift);
ui.ctx().request_repaint(); // render the new accent next frame, not on mouse-move
}
}
// clicking the empty space below the rows clears this pane's selection
let rem = ui.available_height();
if rem > 0.0 {
let (_, resp) = ui
.allocate_exact_size(Vec2::new(ui.available_width(), rem), egui::Sense::click());
if resp.clicked() {
sel.clear();
*anchor = None;
}
}
});
(rect, dbl)
}
/// A square transfer button with a painter-centred icon glyph (exactly centred regardless of the
/// font's side-bearings). White field + thin border to match the modal's buttons; greyed and inert
/// when `enabled` is false. Returns true on click.
pub fn transfer_btn(
ui: &mut egui::Ui,
size: Vec2,
left: bool,
double: bool,
enabled: bool,
tip: &str,
) -> bool {
let sense = if enabled { egui::Sense::click() } else { egui::Sense::hover() };
let (rect, resp) = ui.allocate_exact_size(size, sense);
let (bg, fg) = if !enabled {
(p().field_bg, p().disabled)
} else if resp.is_pointer_button_down_on() {
(p().acc_bg2, p().text)
} else if resp.hovered() {
(p().acc_bg, p().text)
} else {
(p().field_bg, p().text)
};
island_box(ui.painter(), rect, bg, RADIUS_CONTROL);
paint_chevron(ui.painter(), rect, left, double, fg);
enabled && resp.on_hover_text(tip).clicked()
}
// Shared metrics for the side-dock manager lists (Connection / Metadata, and the future Git /
// File managers) so every manager renders rows identically.
pub const MGR_ROW_H: f32 = 24.0; // row height
const MGR_LPAD: f32 = crate::theme::TEXT_INSET; // left padding before the leading glyph (the shared text inset)
/// Glyph-column width (chevron OR type icon); the label starts after it. Also the indent step:
/// a child row passes `indent = MGR_GLYPH_COL` so its icon lines up under the parent's label.
pub const MGR_GLYPH_COL: f32 = 20.0;
/// Screen x (relative to a row's left) where a manager row's LABEL text begins: the left pad plus the
/// glyph column. Single-sources the position so an inline rename editor can align its first glyph to
/// exactly where the static label sits (see `connections_ui`); moves with [`crate::theme::TEXT_INSET`].
pub const MGR_LABEL_X: f32 = MGR_LPAD + MGR_GLYPH_COL;
const MGR_GLYPH_SIZE: f32 = 14.0;
const MGR_LABEL_SIZE: f32 = 13.0;
/// One manager-list row: a leading glyph (a disclosure chevron or a type icon) + a label, painted
/// at the shared height / font. `indent` shifts the whole row right (managers are one level deep,
/// so a leaf passes `MGR_GLYPH_COL` to sit under the parent label). `selected` paints the selection
/// tint; hover paints the soft accent. Returns the click response (use `.rect` for an inline
/// editor). Pass `label = ""` to draw only the glyph.
pub fn manager_row(
ui: &mut egui::Ui,
indent: f32,
glyph: &str,
label: &str,
selected: bool,
) -> egui::Response {
manager_row_fg(ui, indent, glyph, label, selected, None)
}
/// Like [`manager_row`] but with an optional foreground colour for the glyph + label — used to mark
/// the live/active connection green (`Some(p().ok)`). `None` keeps the default text colours.