rust_widgets 2.9.2

Pure Rust cross-platform native GUI library with hardware-adaptive rendering, 180 widgets, touch/gesture support, i18n, and SVG-pipeline-accurate output
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
// SPDX-FileCopyrightText: Copyright (c) 2026 Mike Li/Mikewolfli/Wei Li(mikewolfli@163.com)
// SPDX-License-Identifier: MIT

//! Companion controls shared by the input-widget family (COMP-C01..COMP-C05).
//!
//! Each control here is the *partner* of a primary input control rather than a standalone
//! feature: a chip for `tag_input`, stepper buttons for `spin_box`/`number_picker`, a scrollbar
//! indicator for the editors, and a format toolbar for `rich_edit`.
//!
//! # SuggestionPopup lives in `suggestion_popup`
//!
//! COMP-C01's candidate-list host and COMP-B01's candidate-list widget were two implementations of
//! one concept, so they were merged into `super::suggestion_popup` (principle #101). This module
//! re-exports the merged [`SuggestionPopup`], [`PopupItem`] and [`MatchMode`] so COMP-C01's
//! documented path still resolves, while filtering, flipping, the scroll window and outside
//! dismissal are implemented exactly once.
//!
//! # Registration
//!
//! This module is written to be re-exported from `src/widget/companions.rs`. Until that line is
//! added (it is owned by the companion surface, not by the input controls), the types are only
//! reachable through `crate::widget::input_widgets::input_companions`.
//!
//! # Safety
//!
//! These controls contain no `unsafe` code and own all of their state. Geometry and numeric
//! inputs are validated and clamped in safe Rust.

use crate::core::{Color, Font, HorizontalAlignment, Point, Rect};
use crate::render::RenderContext;
use crate::signal::{GenericSignal, Signal1};
use crate::style::WidgetStyle;
use crate::widget::metrics::{estimate_text_width, role_font};
use crate::widget::Draw;

/// The theme style a model-only companion paints with.
///
/// These controls own no `BaseWidget`, so their colours come from the active theme's
/// resolution for the control they partner with. A profile without a theme resolves to an
/// empty style, and each painter then falls back to its documented neutral colours.
fn companion_style(control: &str) -> WidgetStyle {
    crate::style::resolved_theme_style_shared(control)
        .map(|style| (*style).clone())
        .unwrap_or_default()
}

/// One palette read shared by the painters, with neutral fallbacks.
fn companion_colors() -> (Color, Color, Color) {
    let palette = crate::style::palette_shared();
    let surface = palette
        .as_ref()
        .map(|colors| colors.surface_container)
        .unwrap_or(Color::rgb(245, 246, 248));
    let ink = palette.as_ref().map(|colors| colors.foreground).unwrap_or(Color::BLACK);
    let accent = palette.as_ref().map(|colors| colors.primary).unwrap_or(Color::PRIMARY);
    (surface, ink, accent)
}

// COMP-C01's single home. Re-exporting rather than re-defining is what keeps one implementation
// path for the concept (principle #101): the merge is announced here and in `suggestion_popup`.
pub use super::suggestion_popup::{MatchMode, PopupItem, SuggestionPopup};

// ───────────────────────────── COMP-C02 Chip ─────────────────────────────

/// Which side of a chip a drop indicator marks.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DropSide {
    /// Before the chip.
    Before,
    /// After the chip.
    After,
}

/// A tag/token chip with an optional remove affordance and drag state (COMP-C02).
///
/// The chip owns one measured ruler ([`Self::intrinsic_width`]) so a host lays a row out, paints
/// it and hit-tests it with the same number.
pub struct Chip {
    label: String,
    removable: bool,
    chip_radius: u32,
    close_radius: u32,
    selected: bool,
    accent: Option<Color>,
    draggable: bool,
    dragging: bool,
    drop_indicator: Option<DropSide>,
    drag_from: usize,
    /// The box this chip paints into when a host draws it directly through [`Draw`].
    ///
    /// A host that owns the layout still passes its own rectangle to
    /// [`Self::press_at`] and [`Self::close_center`]; this field lets the same chip also be
    /// a paint target for the SVG exporter and for a caller that holds it as a widget-like
    /// object.
    bounds: Rect,
    /// Emitted on a press of the chip body.
    pub chip_clicked: GenericSignal,
    /// Emitted when the close affordance is pressed.
    pub remove_requested: GenericSignal,
    /// Emitted with `(from, to)` when a drag finishes.
    pub drag_finished: Signal1<(usize, usize)>,
}

impl Chip {
    /// The corner radius a host should paint this chip with.
    ///
    /// The companion owns no paint surface of its own; it states the radius so every host that
    /// draws a chip (tag input, filter row, token field) uses one value instead of a local literal.
    pub fn chip_radius(&self) -> u32 {
        self.chip_radius
    }

    /// Overrides the painted corner radius.
    pub fn set_chip_radius(&mut self, radius: u32) {
        self.chip_radius = radius;
    }
    /// Creates a removable chip for `label`.
    pub fn new(label: impl Into<String>) -> Self {
        Self {
            label: label.into(),
            removable: true,
            chip_radius: 12,
            close_radius: 7,
            selected: false,
            accent: None,
            draggable: false,
            dragging: false,
            drop_indicator: None,
            drag_from: 0,
            bounds: Rect::new(0, 0, 0, 0),
            chip_clicked: GenericSignal::new(),
            remove_requested: GenericSignal::new(),
            drag_finished: Signal1::new(),
        }
    }

    /// Sets the label.
    pub fn set_label(&mut self, label: &str) {
        self.label = label.to_string();
    }

    /// The label.
    pub fn label(&self) -> &str {
        &self.label
    }

    /// Sets the box this chip paints into when drawn through [`Draw`].
    ///
    /// The box is the caller's, not the chip's: a chip is laid out by the row that owns it
    /// (a `tag_input`, a filter row), so the paint entry accepts the rectangle it was given
    /// rather than inventing one. This stores that rectangle for the `Draw` path.
    pub fn set_bounds(&mut self, bounds: Rect) {
        self.bounds = bounds;
    }

    /// The box this chip paints into through [`Draw`].
    pub fn bounds(&self) -> Rect {
        self.bounds
    }

    /// Paints the chip into `rect`.
    ///
    /// The close affordance uses [`Self::close_center`], the same geometry
    /// [`Self::press_at`] hit-tests, so what the user sees and what the control accepts
    /// cannot disagree.
    pub fn paint(&self, context: &mut RenderContext, rect: Rect) {
        if rect.width == 0 || rect.height == 0 {
            return;
        }
        let style = companion_style("tag_input");
        let font = role_font(&style, 12.0);
        let (surface, ink, theme_accent) = companion_colors();
        let accent = self.accent.unwrap_or(theme_accent);
        let fill = if self.selected {
            accent
        } else if self.dragging {
            accent.with_alpha(40)
        } else {
            surface
        };
        let border =
            if self.selected || self.dragging { accent } else { surface.blend(&ink, 0.35) };
        let radius = self.chip_radius.min(rect.height / 2).max(1);
        context.fill_rounded_rect(rect, radius, fill);
        context.draw_rounded_rect_stroke(rect, radius, border, 1);

        let text_ink = if self.selected { accent.contrast_color() } else { ink };
        let close_band = if self.removable { self.close_radius * 2 + 12 } else { 0 };
        let text_width = rect.width.saturating_sub(12 + close_band);
        if text_width > 0 {
            context.draw_text_line_cow(
                Rect::new(rect.x + 6, rect.y, text_width, rect.height),
                &self.label,
                &font,
                text_ink,
                HorizontalAlignment::Left,
            );
        }
        if let Some(center) = self.close_center(rect) {
            let reach = self.close_radius.min(rect.height / 2) as i32;
            let mark = if self.selected { accent.contrast_color() } else { ink.with_alpha(160) };
            context.draw_line(
                Point::new(center.x - reach, center.y - reach),
                Point::new(center.x + reach, center.y + reach),
                mark,
            );
            context.draw_line(
                Point::new(center.x + reach, center.y - reach),
                Point::new(center.x - reach, center.y + reach),
                mark,
            );
        }
        if let Some(side) = self.drop_indicator {
            let x = match side {
                DropSide::Before => rect.x,
                DropSide::After => rect.right(),
            };
            context.fill_rect(Rect::new(x.saturating_sub(1), rect.y, 2, rect.height), accent);
        }
    }

    /// Sets whether the close affordance is drawn.
    pub fn set_removable(&mut self, removable: bool) {
        self.removable = removable;
    }

    /// Whether the close affordance is drawn.
    pub fn removable(&self) -> bool {
        self.removable
    }

    /// Sets the selected state.
    pub fn set_selected(&mut self, selected: bool) {
        self.selected = selected;
    }

    /// Whether the chip is selected.
    pub fn selected(&self) -> bool {
        self.selected
    }

    /// Sets the accent; `None` derives it from the theme at paint time.
    pub fn set_accent(&mut self, accent: Option<Color>) {
        self.accent = accent;
    }

    /// The explicit accent, if any.
    pub fn accent(&self) -> Option<Color> {
        self.accent
    }

    /// Enables drag-and-drop for this chip.
    pub fn set_draggable(&mut self, draggable: bool) {
        self.draggable = draggable;
    }

    /// Marks the chip as being dragged.
    pub fn set_dragging(&mut self, dragging: bool) {
        self.dragging = dragging;
    }

    /// Whether the chip is being dragged.
    pub fn is_dragging(&self) -> bool {
        self.dragging
    }

    /// Sets the drop indicator side.
    pub fn set_drop_indicator(&mut self, side: Option<DropSide>) {
        self.drop_indicator = side;
    }

    /// The drop indicator side.
    pub fn drop_indicator(&self) -> Option<DropSide> {
        self.drop_indicator
    }

    /// Begins a drag from the row index `from`.
    pub fn begin_drag(&mut self, from: usize) {
        if self.draggable {
            self.dragging = true;
            self.drag_from = from;
        }
    }

    /// Finishes a drag at row index `to`, emitting [`Self::drag_finished`].
    pub fn finish_drag(&mut self, to: usize) {
        if self.dragging {
            self.dragging = false;
            self.drag_finished.emit((self.drag_from, to));
        }
    }

    /// The chip's own width under `font`, including label, padding and close affordance.
    ///
    /// This is the single ruler the item asks for: layout, paint and hit testing all call it.
    pub fn intrinsic_width(&self, font: &Font) -> u32 {
        let text = estimate_text_width(&self.label, font, 1.0);
        let padding = 12u32;
        let close = if self.removable { self.close_radius * 2 + 4 } else { 0 };
        text + padding + close
    }

    /// The close affordance's centre inside `chip_rect`, if it is drawn.
    pub fn close_center(&self, chip_rect: Rect) -> Option<Point> {
        self.removable.then(|| {
            Point::new(
                chip_rect.x + chip_rect.width as i32 - 6 - self.close_radius as i32,
                chip_rect.y + chip_rect.height as i32 / 2,
            )
        })
    }

    /// Registers a press at `pos` inside `chip_rect`, emitting the matching signal.
    pub fn press_at(&mut self, pos: Point, chip_rect: Rect) {
        if let Some(center) = self.close_center(chip_rect) {
            let dx = (pos.x - center.x) as i64;
            let dy = (pos.y - center.y) as i64;
            let radius = self.close_radius as i64;
            if dx * dx + dy * dy <= radius * radius {
                self.remove_requested.emit();
                return;
            }
        }
        if chip_rect.contains_point(pos) {
            self.chip_clicked.emit();
        }
    }
}

impl Draw for Chip {
    fn draw(&mut self, context: &mut RenderContext) {
        // An unset box paints at the chip's own intrinsic size, so the control is a real paint
        // target even before a host lays it out. A host that sets `bounds` positions it exactly.
        let rect = if self.bounds.width == 0 || self.bounds.height == 0 {
            let style = companion_style("tag_input");
            let font = role_font(&style, 12.0);
            Rect::new(self.bounds.x, self.bounds.y, self.intrinsic_width(&font), 24)
        } else {
            self.bounds
        };
        self.paint(context, rect);
    }
}

// ───────────────────────────── COMP-C03 StepButtons ─────────────────────────────

/// How a [`StepButtons`] pair is arranged.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum StepOrientation {
    /// Up above down.
    #[default]
    Vertical,
    /// Down beside up.
    Horizontal,
}

/// One direction of a stepper pair.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StepDirection {
    /// The increment button.
    Up,
    /// The decrement button.
    Down,
}

/// A pair of stepper buttons with held-button repeat (COMP-C03).
pub struct StepButtons {
    bounds: Rect,
    orientation: StepOrientation,
    repeat_delay_ms: u32,
    repeat_interval_ms: u32,
    up_enabled: bool,
    down_enabled: bool,
    repeat_active: Option<StepDirection>,
    elapsed: u32,
    /// Emitted for each step, including each auto-repeat step.
    pub step_requested: Signal1<StepDirection>,
}

impl StepButtons {
    /// Creates an enabled vertical pair occupying `bounds`.
    pub fn new(bounds: Rect) -> Self {
        Self {
            bounds,
            orientation: StepOrientation::Vertical,
            repeat_delay_ms: 400,
            repeat_interval_ms: 120,
            up_enabled: true,
            down_enabled: true,
            repeat_active: None,
            elapsed: 0,
            step_requested: Signal1::new(),
        }
    }

    /// Moves the pair's bounds.
    pub fn set_bounds(&mut self, bounds: Rect) {
        self.bounds = bounds;
    }

    /// The pair's bounds.
    pub fn bounds(&self) -> Rect {
        self.bounds
    }

    /// Sets the arrangement.
    pub fn set_orientation(&mut self, orientation: StepOrientation) {
        self.orientation = orientation;
    }

    /// The arrangement.
    pub fn orientation(&self) -> StepOrientation {
        self.orientation
    }

    /// Sets the repeat timings.
    pub fn set_repeat(&mut self, delay_ms: u32, interval_ms: u32) {
        self.repeat_delay_ms = delay_ms;
        self.repeat_interval_ms = interval_ms.max(1);
    }

    /// Enables or disables each button; a disabled button refuses to step.
    pub fn set_enabled_pair(&mut self, up: bool, down: bool) {
        self.up_enabled = up;
        self.down_enabled = down;
        if (self.repeat_active == Some(StepDirection::Up) && !up)
            || (self.repeat_active == Some(StepDirection::Down) && !down)
        {
            self.repeat_active = None;
        }
    }

    /// Whether each button is enabled.
    pub fn enabled_pair(&self) -> (bool, bool) {
        (self.up_enabled, self.down_enabled)
    }

    /// The up button's rectangle.
    pub fn up_rect(&self) -> Rect {
        match self.orientation {
            StepOrientation::Vertical => {
                Rect::new(self.bounds.x, self.bounds.y, self.bounds.width, self.bounds.height / 2)
            }
            StepOrientation::Horizontal => {
                Rect::new(self.bounds.x, self.bounds.y, self.bounds.width / 2, self.bounds.height)
            }
        }
    }

    /// The down button's rectangle.
    pub fn down_rect(&self) -> Rect {
        match self.orientation {
            StepOrientation::Vertical => Rect::new(
                self.bounds.x,
                self.bounds.y + (self.bounds.height / 2) as i32,
                self.bounds.width,
                self.bounds.height - self.bounds.height / 2,
            ),
            StepOrientation::Horizontal => Rect::new(
                self.bounds.x + (self.bounds.width / 2) as i32,
                self.bounds.y,
                self.bounds.width - self.bounds.width / 2,
                self.bounds.height,
            ),
        }
    }

    /// Whether `pos` is on the up button.
    pub fn contains_up(&self, pos: Point) -> bool {
        self.up_rect().contains_point(pos)
    }

    /// Whether `pos` is on the down button.
    pub fn contains_down(&self, pos: Point) -> bool {
        self.down_rect().contains_point(pos)
    }

    /// The direction `pos` names.
    pub fn direction_at(&self, pos: Point) -> Option<StepDirection> {
        if self.contains_up(pos) {
            Some(StepDirection::Up)
        } else if self.contains_down(pos) {
            Some(StepDirection::Down)
        } else {
            None
        }
    }

    /// Begins a held-button repeat, emitting the first step immediately.
    pub fn begin_repeat(&mut self, direction: StepDirection) {
        let enabled = match direction {
            StepDirection::Up => self.up_enabled,
            StepDirection::Down => self.down_enabled,
        };
        if !enabled {
            return;
        }
        self.repeat_active = Some(direction);
        self.elapsed = 0;
        self.step_requested.emit(direction);
    }

    /// Stops any held-button repeat.
    pub fn end_repeat(&mut self) {
        self.repeat_active = None;
        self.elapsed = 0;
    }

    /// Whether a held-button repeat is running.
    pub fn repeat_active(&self) -> Option<StepDirection> {
        self.repeat_active
    }

    /// Advances a held-button repeat, returning whether another frame is owed.
    pub fn tick(&mut self, delta_ms: u32) -> bool {
        let Some(direction) = self.repeat_active else {
            return false;
        };
        self.elapsed = self.elapsed.saturating_add(delta_ms);
        if self.elapsed < self.repeat_delay_ms {
            return true;
        }
        let mut remaining = self.elapsed - self.repeat_delay_ms;
        while remaining >= self.repeat_interval_ms {
            remaining -= self.repeat_interval_ms;
            self.step_requested.emit(direction);
        }
        self.elapsed = self.repeat_delay_ms + remaining;
        true
    }
}

impl Draw for StepButtons {
    fn draw(&mut self, context: &mut RenderContext) {
        let bounds = self.bounds;
        if bounds.width == 0 || bounds.height == 0 {
            return;
        }
        let (surface, ink, _accent) = companion_colors();
        let border = surface.blend(&ink, 0.35);
        let radius = 4.min(bounds.height / 2).max(1);
        context.fill_rounded_rect(bounds, radius, surface);
        context.draw_rounded_rect_stroke(bounds, radius, border, 1);

        let up = self.up_rect();
        let down = self.down_rect();
        // The divider is the boundary the two hit rectangles share, so the drawn split and
        // `direction_at` cannot point at different places.
        match self.orientation {
            StepOrientation::Vertical => {
                let y = up.y + up.height as i32;
                context.fill_rect(
                    Rect::new(bounds.x + 2, y, bounds.width.saturating_sub(4), 1),
                    border,
                );
            }
            StepOrientation::Horizontal => {
                let x = up.x + up.width as i32;
                context.fill_rect(
                    Rect::new(x, bounds.y + 2, 1, bounds.height.saturating_sub(4)),
                    border,
                );
            }
        }

        for (rect, enabled, up_direction) in
            [(up, self.up_enabled, true), (down, self.down_enabled, false)]
        {
            if rect.width == 0 || rect.height == 0 {
                continue;
            }
            let color = if enabled { ink } else { ink.with_alpha(80) };
            let cx = rect.x + rect.width as i32 / 2;
            let cy = rect.y + rect.height as i32 / 2;
            let reach = (rect.width.min(rect.height) as i32 / 4).max(2);
            let (tip_y, base_y) = if up_direction {
                (cy - reach, cy + reach / 2)
            } else {
                (cy + reach, cy - reach / 2)
            };
            context.draw_line(Point::new(cx - reach, base_y), Point::new(cx, tip_y), color);
            context.draw_line(Point::new(cx, tip_y), Point::new(cx + reach, base_y), color);
        }
    }
}

// ───────────────────────────── COMP-C04 Scrollbar ─────────────────────────────

/// How a [`Scrollbar`] runs.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ScrollOrientation {
    /// A right-edge vertical bar.
    #[default]
    Vertical,
    /// A bottom-edge horizontal bar.
    Horizontal,
}

/// A proportional scroll indicator (COMP-C04).
pub struct Scrollbar {
    orientation: ScrollOrientation,
    content_size: u32,
    viewport_size: u32,
    scroll_offset: u32,
    thickness: u32,
    auto_hide: bool,
    thumb_min_length: u32,
    track: Rect,
    /// Emitted when a drag produces a new offset.
    pub scroll_offset_changed: Signal1<u32>,
}

impl Scrollbar {
    /// Creates a vertical bar that hides itself when the content fits.
    pub fn new() -> Self {
        Self {
            orientation: ScrollOrientation::Vertical,
            content_size: 0,
            viewport_size: 0,
            scroll_offset: 0,
            thickness: 4,
            auto_hide: true,
            thumb_min_length: 8,
            track: Rect::new(0, 0, 0, 0),
            scroll_offset_changed: Signal1::new(),
        }
    }

    /// Sets the bar's arrangement and thickness.
    pub fn set_orientation(&mut self, orientation: ScrollOrientation, thickness: u32) {
        self.orientation = orientation;
        self.thickness = thickness.max(1);
    }

    /// The arrangement.
    pub fn orientation(&self) -> ScrollOrientation {
        self.orientation
    }

    /// Sets the track rectangle the host laid out.
    pub fn set_track(&mut self, track: Rect) {
        self.track = track;
    }

    /// The track rectangle.
    pub fn track(&self) -> Rect {
        self.track
    }

    /// Sets the content/viewport sizes and the current offset.
    pub fn set_metrics(&mut self, content: u32, viewport: u32, offset: u32) {
        self.content_size = content;
        self.viewport_size = viewport;
        self.scroll_offset = offset;
    }

    /// The content size.
    pub fn content_size(&self) -> u32 {
        self.content_size
    }

    /// The viewport size.
    pub fn viewport_size(&self) -> u32 {
        self.viewport_size
    }

    /// The current offset.
    pub fn scroll_offset(&self) -> u32 {
        self.scroll_offset
    }

    /// Enables or disables hiding when the content fits.
    pub fn set_auto_hide(&mut self, auto_hide: bool) {
        self.auto_hide = auto_hide;
    }

    /// Sets the minimum thumb length.
    pub fn set_thumb_min_length(&mut self, length: u32) {
        self.thumb_min_length = length.max(1);
    }

    /// Whether the bar should be painted at all.
    pub fn is_visible(&self) -> bool {
        !self.auto_hide || self.content_size > self.viewport_size
    }

    /// The thumb rectangle within `track`.
    pub fn thumb_rect(&self, track: Rect) -> Rect {
        let (track_len, content, viewport) = self.axis(track);
        if content == 0 || viewport == 0 {
            return Rect::new(track.x, track.y, track.width, track.height);
        }
        let max_offset = content.saturating_sub(viewport);
        let thumb_len = ((viewport as f32 / content as f32) * track_len as f32)
            .max(self.thumb_min_length as f32)
            .min(track_len as f32) as u32;
        let travel = track_len.saturating_sub(thumb_len);
        let fraction = if max_offset == 0 {
            0.0
        } else {
            self.scroll_offset.min(max_offset) as f32 / max_offset as f32
        };
        match self.orientation {
            ScrollOrientation::Vertical => Rect::new(
                track.x,
                track.y + (travel as f32 * fraction) as i32,
                track.width,
                thumb_len,
            ),
            ScrollOrientation::Horizontal => Rect::new(
                track.x + (travel as f32 * fraction) as i32,
                track.y,
                thumb_len,
                track.height,
            ),
        }
    }

    /// Whether `pos` is on the thumb of `track`.
    pub fn hit_thumb(&self, pos: Point) -> bool {
        self.thumb_rect(self.track).contains_point(pos)
    }

    /// Maps a drag position to an offset, updates the bar and emits the change.
    pub fn drag_thumb(&mut self, pos: Point) -> u32 {
        let track = self.track;
        let thumb = self.thumb_rect(track);
        let (track_len, content, viewport) = self.axis(track);
        let max_offset = content.saturating_sub(viewport);
        if max_offset == 0 {
            return self.scroll_offset;
        }
        let thumb_len = match self.orientation {
            ScrollOrientation::Vertical => thumb.height,
            ScrollOrientation::Horizontal => thumb.width,
        };
        let travel = track_len.saturating_sub(thumb_len).max(1) as f32;
        let position = match self.orientation {
            ScrollOrientation::Vertical => (pos.y - track.y - thumb_len as i32 / 2) as f32,
            ScrollOrientation::Horizontal => (pos.x - track.x - thumb_len as i32 / 2) as f32,
        };
        let fraction = (position / travel).clamp(0.0, 1.0);
        let next = (fraction * max_offset as f32).round() as u32;
        if next != self.scroll_offset {
            self.scroll_offset = next;
            self.scroll_offset_changed.emit(next);
        }
        next
    }

    fn axis(&self, track: Rect) -> (u32, u32, u32) {
        match self.orientation {
            ScrollOrientation::Vertical => (track.height, self.content_size, self.viewport_size),
            ScrollOrientation::Horizontal => (track.width, self.content_size, self.viewport_size),
        }
    }
}

impl Default for Scrollbar {
    fn default() -> Self {
        Self::new()
    }
}

impl Draw for Scrollbar {
    fn draw(&mut self, context: &mut RenderContext) {
        let track = self.track;
        if !self.is_visible() || track.width == 0 || track.height == 0 {
            return;
        }
        let (surface, ink, accent) = companion_colors();
        let radius = (self.thickness / 2).max(1);
        // The lane is a quiet step off the surface; the thumb is the accent. Both come from
        // `thumb_rect`, the same rectangle `hit_thumb` and `drag_thumb` use.
        context.fill_rounded_rect(track, radius, surface.blend(&ink, 0.12));
        let thumb = self.thumb_rect(track);
        if thumb.width > 0 && thumb.height > 0 {
            context.fill_rounded_rect(thumb, radius, accent.with_alpha(210));
        }
    }
}

// ───────────────────────────── COMP-C05 FormatToolbar ─────────────────────────────

use crate::widget::input_widgets::rich_edit::CharFormat;

/// Which format buttons a [`FormatToolbar`] publishes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FormatActionSet {
    /// The bold button.
    pub bold: bool,
    /// The italic button.
    pub italic: bool,
    /// The underline button.
    pub underline: bool,
    /// The colour button.
    pub color: bool,
    /// The clear-format button.
    pub clear_format: bool,
}

impl Default for FormatActionSet {
    fn default() -> Self {
        Self { bold: true, italic: true, underline: true, color: true, clear_format: true }
    }
}

/// One action a [`FormatToolbar`] can request.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FormatAction {
    /// Toggle bold.
    Bold,
    /// Toggle italic.
    Italic,
    /// Toggle underline.
    Underline,
    /// Apply a colour (carried beside the action).
    Color,
    /// Clear every format.
    ClearFormat,
}

/// The actions a toolbar paints, in the order their cells are laid out.
const FORMAT_ACTIONS: [FormatAction; 5] = [
    FormatAction::Bold,
    FormatAction::Italic,
    FormatAction::Underline,
    FormatAction::Color,
    FormatAction::ClearFormat,
];
/// Width of one format cell, in logical pixels.
const FORMAT_CELL_WIDTH: u32 = 28;
/// Gap between two format cells, in logical pixels.
const FORMAT_CELL_GAP: u32 = 2;
/// Height of a cell when the toolbar has not been given a box yet.
const FORMAT_DEFAULT_HEIGHT: u32 = 28;

/// A format toolbar that reports the user's intent without touching an editor (COMP-C05).
///
/// The host connects [`Self::format_requested`] to `rich_edit::apply_char_format` (or the selected
/// span set), which keeps the editor from depending on the toolbar.
pub struct FormatToolbar {
    actions: FormatActionSet,
    active_format: CharFormat,
    accent: Option<Color>,
    ink: Option<Color>,
    compact: bool,
    /// The box this toolbar paints into when a host draws it directly through [`Draw`].
    bounds: Rect,
    /// Emitted with the requested action and an optional colour value.
    pub format_requested: Signal1<(FormatAction, Option<Color>)>,
}

impl FormatToolbar {
    /// Creates a toolbar publishing every action.
    pub fn new() -> Self {
        Self {
            actions: FormatActionSet::default(),
            active_format: CharFormat::default(),
            accent: None,
            ink: None,
            compact: false,
            bounds: Rect::new(0, 0, 0, 0),
            format_requested: Signal1::new(),
        }
    }

    /// The cursor's current format, which drives the buttons' highlight state.
    pub fn active_format(&self) -> CharFormat {
        self.active_format.clone()
    }

    /// Sets the cursor's current format.
    pub fn set_active_format(&mut self, format: CharFormat) {
        self.active_format = format;
    }

    /// The published action set.
    pub fn actions(&self) -> FormatActionSet {
        self.actions
    }

    /// Restricts the toolbar to a subset of the actions.
    pub fn set_enabled(&mut self, actions: FormatActionSet) {
        self.actions = actions;
    }

    /// Sets the accent and ink colours; `None` derives them from the theme.
    pub fn set_colors(&mut self, accent: Option<Color>, ink: Option<Color>) {
        self.accent = accent;
        self.ink = ink;
    }

    /// The explicit accent.
    pub fn accent(&self) -> Option<Color> {
        self.accent
    }

    /// The explicit ink.
    pub fn ink(&self) -> Option<Color> {
        self.ink
    }

    /// Whether the compact (icon-only) layout is requested.
    pub fn compact(&self) -> bool {
        self.compact
    }

    /// Requests the compact layout.
    pub fn set_compact(&mut self, compact: bool) {
        self.compact = compact;
    }

    /// Whether `action` is published by the current set.
    pub fn is_enabled(&self, action: FormatAction) -> bool {
        match action {
            FormatAction::Bold => self.actions.bold,
            FormatAction::Italic => self.actions.italic,
            FormatAction::Underline => self.actions.underline,
            FormatAction::Color => self.actions.color,
            FormatAction::ClearFormat => self.actions.clear_format,
        }
    }

    /// Reports a press on `action`; disabled actions are ignored.
    ///
    /// A colour action also carries the current accent, which is what the host applies.
    pub fn press(&mut self, action: FormatAction) {
        if !self.is_enabled(action) {
            return;
        }
        let value = matches!(action, FormatAction::Color).then_some(self.accent).flatten();
        self.format_requested.emit((action, value));
    }

    /// The format an action would produce, for the host's preview/highlight.
    pub fn preview(&self, action: FormatAction) -> CharFormat {
        let mut format = self.active_format.clone();
        match action {
            FormatAction::Bold => format.bold = !format.bold,
            FormatAction::Italic => format.italic = !format.italic,
            FormatAction::Underline => format.underline = !format.underline,
            FormatAction::Color => format.color = self.accent,
            FormatAction::ClearFormat => format = CharFormat::default(),
        }
        format
    }

    /// Sets the box this toolbar paints into through [`Draw`].
    pub fn set_bounds(&mut self, bounds: Rect) {
        self.bounds = bounds;
    }

    /// The box this toolbar paints into through [`Draw`].
    pub fn bounds(&self) -> Rect {
        self.bounds
    }

    /// The cell one published action occupies inside `bounds`, in layout order.
    ///
    /// The index counts only published actions, so disabling an action closes the gap rather
    /// than leaving an empty cell the hit test would still accept.
    fn cell_rect(&self, action: FormatAction, bounds: Rect) -> Option<Rect> {
        if !self.is_enabled(action) {
            return None;
        }
        let column = FORMAT_ACTIONS
            .iter()
            .take_while(|candidate| **candidate != action)
            .filter(|candidate| self.is_enabled(**candidate))
            .count();
        let width =
            if self.compact { FORMAT_CELL_WIDTH.saturating_sub(6) } else { FORMAT_CELL_WIDTH };
        let x_offset = column as u32 * (width + FORMAT_CELL_GAP);
        if x_offset >= bounds.width {
            return None;
        }
        let available = bounds.width - x_offset;
        Some(Rect::new(bounds.x + x_offset as i32, bounds.y, width.min(available), bounds.height))
    }

    /// The published action under `pos` when the toolbar has been given a box.
    ///
    /// This is the geometric partner of [`Self::press`]: a host that routes pointer input can
    /// find the action the same way the painter lays its cells out.
    pub fn action_at(&self, pos: Point) -> Option<FormatAction> {
        FORMAT_ACTIONS.iter().copied().find(|action| {
            self.cell_rect(*action, self.bounds).is_some_and(|rect| rect.contains_point(pos))
        })
    }

    /// Paints the toolbar into `rect`.
    ///
    /// Each enabled action gets one cell; an active format is tinted with the accent so the
    /// toolbar reports the cursor's format in the same colours the editor uses.
    pub fn paint(&self, context: &mut RenderContext, rect: Rect) {
        if rect.width == 0 || rect.height == 0 {
            return;
        }
        let style = companion_style("rich_edit");
        let font = role_font(&style, 12.0);
        let (surface, ink, theme_accent) = companion_colors();
        let accent = self.accent.unwrap_or(theme_accent);
        let ink = self.ink.unwrap_or(ink);
        let border = surface.blend(&ink, 0.3);
        context.fill_rounded_rect(rect, 4, surface);
        context.draw_rounded_rect_stroke(rect, 4, border, 1);

        for action in FORMAT_ACTIONS {
            let Some(cell) = self.cell_rect(action, rect) else {
                continue;
            };
            if cell.width == 0 || cell.height == 0 {
                continue;
            }
            let active = match action {
                FormatAction::Bold => self.active_format.bold,
                FormatAction::Italic => self.active_format.italic,
                FormatAction::Underline => self.active_format.underline,
                FormatAction::Color => self.active_format.color.is_some(),
                FormatAction::ClearFormat => false,
            };
            let cell_fill = if active { accent.with_alpha(40) } else { surface };
            context.fill_rounded_rect(cell, 3, cell_fill);
            let color = if active { accent } else { ink };
            match action {
                FormatAction::Bold => {
                    context.draw_text_line_cow(
                        cell,
                        "B",
                        &font.with_bold(true),
                        color,
                        HorizontalAlignment::Center,
                    );
                }
                FormatAction::Italic => {
                    context.draw_text_line_cow(
                        cell,
                        "I",
                        &font.with_italic(true),
                        color,
                        HorizontalAlignment::Center,
                    );
                }
                FormatAction::Underline => {
                    context.draw_text_line_cow(
                        cell,
                        "U",
                        &font,
                        color,
                        HorizontalAlignment::Center,
                    );
                    let y = cell.y + cell.height as i32 - 5;
                    context.draw_line(
                        Point::new(cell.x + 6, y),
                        Point::new(cell.x + cell.width as i32 - 6, y),
                        color,
                    );
                }
                FormatAction::Color => {
                    let center =
                        Point::new(cell.x + cell.width as i32 / 2, cell.y + cell.height as i32 / 2);
                    context.fill_circle(center, (cell.height / 5).max(3), accent);
                }
                FormatAction::ClearFormat => {
                    context.draw_text_line_cow(
                        cell,
                        "Tx",
                        &font,
                        color,
                        HorizontalAlignment::Center,
                    );
                }
            }
        }
    }
}

impl Default for FormatToolbar {
    fn default() -> Self {
        Self::new()
    }
}

impl Draw for FormatToolbar {
    fn draw(&mut self, context: &mut RenderContext) {
        let rect = if self.bounds.width == 0 || self.bounds.height == 0 {
            let columns =
                FORMAT_ACTIONS.iter().filter(|action| self.is_enabled(**action)).count() as u32;
            let width = columns.saturating_mul(FORMAT_CELL_WIDTH + FORMAT_CELL_GAP);
            Rect::new(self.bounds.x, self.bounds.y, width, FORMAT_DEFAULT_HEIGHT)
        } else {
            self.bounds
        };
        self.paint(context, rect);
    }
}