Commit 1d82cbe
8
files
+675
added
-5
deleted
@@ -27,10 +27,11 @@ pub use event::{
27
27
pub use geometry::{Insets, Point, Rect, Size};
28
28
pub use scale::Scale;
29
29
pub use style::{
30
- ActionRowMetrics, BannerMetrics, BottomSheetMetrics, ButtonMetrics, CardMetrics, CheckMetrics,
31
- DropDownMetrics, EntryMetrics, HeaderBarMetrics, ItemViewMetrics, MenuMetrics, Metrics,
32
- NavMetrics, PagerMetrics, PathBarMetrics, RadioMetrics, Radius, ScrollViewMetrics,
33
- SegmentedMetrics, SliderMetrics, Spacing, SpinnerMetrics, StatusPageMetrics, StepperMetrics,
34
- SwitchMetrics, TabsMetrics, Theme, ToastMetrics, TooltipMetrics, Typography,
30
+ ActionRowMetrics, AvatarMetrics, BannerMetrics, BottomSheetMetrics, ButtonMetrics, CardMetrics,
31
+ CheckMetrics, DropDownMetrics, EntryMetrics, HeaderBarMetrics, ItemViewMetrics, MenuMetrics,
32
+ Metrics, NavMetrics, PagerMetrics, PathBarMetrics, RadioMetrics, Radius, ScrollViewMetrics,
33
+ SegmentedMetrics, ShortcutMetrics, SliderMetrics, Spacing, SpinnerMetrics, SplitMetrics,
34
+ StatusPageMetrics, StepperMetrics, SwitchMetrics, TabsMetrics, Theme, ToastMetrics,
35
+ TooltipMetrics, Typography,
35
36
};
36
37
pub use theme_file::{available as available_themes, StyledTheme, ThemePatch};
@@ -512,6 +512,35 @@ pub struct BannerMetrics {
512
512
pub button_h: f64,
513
513
}
514
514
515
+/// Geometria awatara ([`Avatar`](crate::style)).
516
+#[derive(Debug, Clone, Copy, PartialEq)]
517
+pub struct AvatarMetrics {
518
+ /// Domyślna średnica.
519
+ pub size: f64,
520
+}
521
+
522
+/// Geometria etykiety skrótu ([`ShortcutLabel`](crate::style)).
523
+#[derive(Debug, Clone, Copy, PartialEq)]
524
+pub struct ShortcutMetrics {
525
+ /// Padding poziomy klawisza.
526
+ pub pad_x: f64,
527
+ /// Padding pionowy klawisza.
528
+ pub pad_y: f64,
529
+ /// Promień klawisza.
530
+ pub radius: f64,
531
+ /// Odstęp między klawiszami (na separator `+`).
532
+ pub gap: f64,
533
+}
534
+
535
+/// Geometria przycisku dzielonego ([`SplitButton`](crate::style)).
536
+#[derive(Debug, Clone, Copy, PartialEq)]
537
+pub struct SplitMetrics {
538
+ /// Szerokość części ze strzałką.
539
+ pub arrow_w: f64,
540
+ /// Grubość separatora między częściami.
541
+ pub divider: f64,
542
+}
543
+
515
544
/// Geometria kontrolek („component metrics”) — rozmiary i paddingi w pikselach logicznych.
516
545
///
517
546
/// Widgety nie mają już własnych stałych rozmiarów: czytają je z motywu, dzięki czemu
@@ -544,6 +573,9 @@ pub struct Metrics {
544
573
pub spinner: SpinnerMetrics,
545
574
pub tooltip: TooltipMetrics,
546
575
pub banner: BannerMetrics,
576
+ pub avatar: AvatarMetrics,
577
+ pub shortcut: ShortcutMetrics,
578
+ pub split: SplitMetrics,
547
579
}
548
580
549
581
impl Metrics {
@@ -690,6 +722,17 @@ impl Metrics {
690
722
min_height: 56.0,
691
723
button_h: 30.0,
692
724
},
725
+ avatar: AvatarMetrics { size: 40.0 },
726
+ shortcut: ShortcutMetrics {
727
+ pad_x: 6.0,
728
+ pad_y: 3.0,
729
+ radius: 5.0,
730
+ gap: 4.0,
731
+ },
732
+ split: SplitMetrics {
733
+ arrow_w: 30.0,
734
+ divider: 1.0,
735
+ },
693
736
}
694
737
}
695
738
}
696
739
new file mode 100644
@@ -0,0 +1,67 @@
1
+//! `Avatar` — okrągły awatar z inicjałami (zastępstwo obrazka).
2
+//!
3
+//! Widget immediate: [`Avatar::paint`] rysuje wypełniony okrąg z inicjałami. Rozmiar z tokenów
4
+//! ([`AvatarMetrics`](libluna_core::AvatarMetrics)).
5
+
6
+use libluna_core::style::weight;
7
+use libluna_core::{Rect, Theme};
8
+
9
+use crate::ctx::PaintCtx;
10
+
11
+/// Awatar z inicjałami.
12
+#[derive(Debug, Clone, Default)]
13
+pub struct Avatar {
14
+ initials: String,
15
+}
16
+
17
+impl Avatar {
18
+ pub fn new(initials: impl Into<String>) -> Self {
19
+ Self {
20
+ initials: initials.into(),
21
+ }
22
+ }
23
+
24
+ pub fn initials(&self) -> &str {
25
+ &self.initials
26
+ }
27
+
28
+ /// Domyślna średnica awatara (z metryk).
29
+ pub fn size(&self, theme: &Theme) -> f64 {
30
+ theme.metrics.avatar.size
31
+ }
32
+
33
+ /// Rysuje awatar w (kwadratowym) prostokącie `rect`.
34
+ pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
35
+ let theme = *ctx.theme;
36
+ let radius = rect.width().min(rect.height()) / 2.0;
37
+ ctx.scene
38
+ .fill_rounded_rect(rect, radius, theme.colors.accent);
39
+ // Maksymalnie dwa znaki inicjałów (np. „Ada Lovelace” → „AL”).
40
+ let initials: String = self
41
+ .initials
42
+ .chars()
43
+ .take(2)
44
+ .collect::<String>()
45
+ .to_uppercase();
46
+ ctx.label_center(
47
+ rect.center().x,
48
+ rect.center().y,
49
+ &initials,
50
+ theme.typography.body.size,
51
+ weight::BOLD,
52
+ theme.colors.accent_fg,
53
+ );
54
+ }
55
+}
56
+
57
+#[cfg(test)]
58
+mod tests {
59
+ use super::*;
60
+
61
+ #[test]
62
+ fn size_from_metrics() {
63
+ let theme = Theme::light();
64
+ assert_eq!(Avatar::new("AL").size(&theme), theme.metrics.avatar.size);
65
+ assert_eq!(Avatar::new("AL").initials(), "AL");
66
+ }
67
+}
0
68
new file mode 100644
@@ -0,0 +1,92 @@
1
+//! `Bin` — kontener jednego dziecka z wcięciem i wyrównaniem.
2
+//!
3
+//! Pomocnik układu (nie rysuje nic): [`Bin::content_rect`] wylicza prostokąt dziecka o zadanym
4
+//! naturalnym rozmiarze wewnątrz prostokąta, zgodnie z wcięciem i wyrównaniem.
5
+
6
+use libluna_core::{Insets, Rect, Size};
7
+
8
+/// Wyrównanie wzdłuż osi.
9
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10
+pub enum BinAlign {
11
+ Start,
12
+ #[default]
13
+ Center,
14
+ End,
15
+}
16
+
17
+/// Kontener jednego dziecka.
18
+#[derive(Debug, Clone, Copy, Default)]
19
+pub struct Bin {
20
+ padding: Insets,
21
+ halign: BinAlign,
22
+ valign: BinAlign,
23
+}
24
+
25
+impl Bin {
26
+ pub fn new() -> Self {
27
+ Self::default()
28
+ }
29
+
30
+ pub fn padding(mut self, padding: Insets) -> Self {
31
+ self.padding = padding;
32
+ self
33
+ }
34
+
35
+ pub fn halign(mut self, align: BinAlign) -> Self {
36
+ self.halign = align;
37
+ self
38
+ }
39
+
40
+ pub fn valign(mut self, align: BinAlign) -> Self {
41
+ self.valign = align;
42
+ self
43
+ }
44
+
45
+ pub fn align(mut self, halign: BinAlign, valign: BinAlign) -> Self {
46
+ self.halign = halign;
47
+ self.valign = valign;
48
+ self
49
+ }
50
+
51
+ /// Prostokąt dziecka o rozmiarze `child` wewnątrz `rect`.
52
+ pub fn content_rect(&self, rect: Rect, child: Size) -> Rect {
53
+ let inner = rect.deflate(self.padding);
54
+ let w = child.width.min(inner.width());
55
+ let h = child.height.min(inner.height());
56
+ let x = match self.halign {
57
+ BinAlign::Start => inner.x(),
58
+ BinAlign::Center => inner.x() + (inner.width() - w) / 2.0,
59
+ BinAlign::End => inner.right() - w,
60
+ };
61
+ let y = match self.valign {
62
+ BinAlign::Start => inner.y(),
63
+ BinAlign::Center => inner.y() + (inner.height() - h) / 2.0,
64
+ BinAlign::End => inner.bottom() - h,
65
+ };
66
+ Rect::from_xywh(x, y, w, h)
67
+ }
68
+}
69
+
70
+#[cfg(test)]
71
+mod tests {
72
+ use super::*;
73
+
74
+ #[test]
75
+ fn centers_child() {
76
+ let bin = Bin::new();
77
+ let rect = Rect::from_xywh(0.0, 0.0, 100.0, 60.0);
78
+ let r = bin.content_rect(rect, Size::new(40.0, 20.0));
79
+ assert_eq!(r, Rect::from_xywh(30.0, 20.0, 40.0, 20.0));
80
+ }
81
+
82
+ #[test]
83
+ fn end_align_and_padding() {
84
+ let bin = Bin::new()
85
+ .align(BinAlign::End, BinAlign::Start)
86
+ .padding(Insets::splat(10.0));
87
+ let rect = Rect::from_xywh(0.0, 0.0, 100.0, 60.0);
88
+ let r = bin.content_rect(rect, Size::new(20.0, 20.0));
89
+ assert_eq!(r.right(), 90.0);
90
+ assert_eq!(r.y(), 10.0);
91
+ }
92
+}
@@ -34,6 +34,11 @@
34
34
//! - [`Spinner`] — wskaźnik zajętości (obracający się łuk).
35
35
//! - [`Tooltip`] — dymek podpowiedzi nad elementem.
36
36
//! - [`Banner`] — pasek komunikatu (info/ostrzeżenie/błąd) z akcjami.
37
+//! - [`Avatar`] — okrągły awatar z inicjałami.
38
+//! - [`ShortcutLabel`] — skrót klawiszowy jako klawisze (`Ctrl` `+` `N`).
39
+//! - [`ToggleGroup`] — grupa przełączników (wielokrotny wybór).
40
+//! - [`SplitButton`] — przycisk dzielony (akcja + strzałka).
41
+//! - [`Bin`] — kontener jednego dziecka (wcięcie + wyrównanie).
37
42
//!
38
43
//! Układ (retencyjne drzewo, `measure`/`allocate`): [`layout`] ([`Node`], [`Layout`], [`Constraints`]).
39
44
//!
@@ -46,7 +51,9 @@
46
51
//! ```
47
52
48
53
pub mod action_row;
54
+pub mod avatar;
49
55
pub mod banner;
56
+pub mod bin;
50
57
pub mod bottom_sheet;
51
58
pub mod button;
52
59
pub mod card;
@@ -65,19 +72,24 @@ pub mod path_bar;
65
72
pub mod radio_group;
66
73
pub mod scroll_view;
67
74
pub mod search_entry;
75
+pub mod shortcut_label;
68
76
pub mod slider;
69
77
pub mod spinner;
78
+pub mod split_button;
70
79
pub mod status_page;
71
80
pub mod stepper;
72
81
pub mod switch;
73
82
pub mod tab_bar;
74
83
pub mod tab_view;
75
84
pub mod toast;
85
+pub mod toggle_group;
76
86
pub mod tooltip;
77
87
pub mod view_switcher;
78
88
79
89
pub use action_row::ActionRow;
90
+pub use avatar::Avatar;
80
91
pub use banner::{Banner, BannerKind, BannerRects};
92
+pub use bin::{Bin, BinAlign};
81
93
pub use bottom_sheet::BottomSheet;
82
94
pub use button::{Button, ButtonVariant};
83
95
pub use card::Card;
@@ -95,13 +107,16 @@ pub use path_bar::PathBar;
95
107
pub use radio_group::RadioGroup;
96
108
pub use scroll_view::ScrollView;
97
109
pub use search_entry::SearchEntry;
110
+pub use shortcut_label::ShortcutLabel;
98
111
pub use slider::Slider;
99
112
pub use spinner::Spinner;
113
+pub use split_button::{SplitButton, SplitRects};
100
114
pub use status_page::{StatusIcon, StatusPage, StatusRects};
101
115
pub use stepper::{Stepper, StepperSide};
102
116
pub use switch::Switch;
103
117
pub use tab_bar::{ScrollDir, Tab, TabBar};
104
118
pub use tab_view::TabView;
105
119
pub use toast::Toast;
120
+pub use toggle_group::ToggleGroup;
106
121
pub use tooltip::Tooltip;
107
122
pub use view_switcher::ViewSwitcher;
108
123
new file mode 100644
@@ -0,0 +1,161 @@
1
+//! `ShortcutLabel` — skrót klawiszowy jako klawisze (`Ctrl` `+` `N`).
2
+//!
3
+//! Widget immediate: [`ShortcutLabel::key_rects`] liczy prostokąty klawiszy, a
4
+//! [`ShortcutLabel::paint`] rysuje je z separatorem `+`. `width`/`height` pozwalają wyrównać
5
+//! skrót do prawej (np. w [`Menu`](crate::Menu) albo [`ActionRow`](crate::ActionRow)).
6
+
7
+use libluna_core::style::weight;
8
+use libluna_core::{Rect, Theme};
9
+use libluna_render::TextEngine;
10
+
11
+use crate::ctx::PaintCtx;
12
+
13
+/// Etykieta skrótu klawiszowego.
14
+#[derive(Debug, Clone, Default)]
15
+pub struct ShortcutLabel {
16
+ keys: Vec<String>,
17
+}
18
+
19
+impl ShortcutLabel {
20
+ pub fn new<I, S>(keys: I) -> Self
21
+ where
22
+ I: IntoIterator<Item = S>,
23
+ S: Into<String>,
24
+ {
25
+ Self {
26
+ keys: keys.into_iter().map(Into::into).collect(),
27
+ }
28
+ }
29
+
30
+ pub fn keys(&self) -> &[String] {
31
+ &self.keys
32
+ }
33
+
34
+ /// Skrót jako tekst (np. `Ctrl+N`).
35
+ pub fn string(&self) -> String {
36
+ self.keys.join("+")
37
+ }
38
+
39
+ pub fn is_empty(&self) -> bool {
40
+ self.keys.is_empty()
41
+ }
42
+
43
+ fn caption(theme: &Theme) -> f64 {
44
+ theme.typography.caption.size
45
+ }
46
+
47
+ fn key_width(&self, key: &str, theme: &Theme, text: &TextEngine) -> f64 {
48
+ let m = theme.metrics.shortcut;
49
+ 2.0 * m.pad_x
50
+ + text
51
+ .measure(key, Self::caption(theme), weight::NORMAL)
52
+ .width
53
+ }
54
+
55
+ fn plus_width(theme: &Theme, text: &TextEngine) -> f64 {
56
+ text.measure("+", Self::caption(theme), weight::NORMAL)
57
+ .width
58
+ }
59
+
60
+ pub fn height(&self, theme: &Theme) -> f64 {
61
+ let m = theme.metrics.shortcut;
62
+ 2.0 * m.pad_y + theme.typography.caption.line_height
63
+ }
64
+
65
+ pub fn width(&self, theme: &Theme, text: &TextEngine) -> f64 {
66
+ let m = theme.metrics.shortcut;
67
+ if self.keys.is_empty() {
68
+ return 0.0;
69
+ }
70
+ let keys: f64 = self
71
+ .keys
72
+ .iter()
73
+ .map(|k| self.key_width(k, theme, text))
74
+ .sum();
75
+ let joints = (self.keys.len() - 1) as f64 * (2.0 * m.gap + Self::plus_width(theme, text));
76
+ keys + joints
77
+ }
78
+
79
+ /// Prostokąty klawiszy, wyrównane do lewej, wyśrodkowane pionowo w `rect`.
80
+ pub fn key_rects(&self, rect: Rect, theme: &Theme, text: &TextEngine) -> Vec<Rect> {
81
+ let m = theme.metrics.shortcut;
82
+ let h = self.height(theme);
83
+ let y = rect.y() + (rect.height() - h) / 2.0;
84
+ let plus = Self::plus_width(theme, text);
85
+ let mut x = rect.x();
86
+ let mut out = Vec::with_capacity(self.keys.len());
87
+ for (i, key) in self.keys.iter().enumerate() {
88
+ let w = self.key_width(key, theme, text);
89
+ out.push(Rect::from_xywh(x, y, w, h));
90
+ x += w;
91
+ if i + 1 < self.keys.len() {
92
+ x += 2.0 * m.gap + plus;
93
+ }
94
+ }
95
+ out
96
+ }
97
+
98
+ pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
99
+ let theme = *ctx.theme;
100
+ let colors = theme.colors;
101
+ let m = theme.metrics.shortcut;
102
+ let caption = theme.typography.caption.size;
103
+ let rects = self.key_rects(rect, &theme, ctx.text);
104
+
105
+ for (i, key_rect) in rects.iter().enumerate() {
106
+ ctx.scene
107
+ .fill_rounded_rect(*key_rect, m.radius, colors.shade);
108
+ ctx.scene
109
+ .stroke_rounded_rect(*key_rect, m.radius, 1.0, colors.border);
110
+ ctx.label_center(
111
+ key_rect.center().x,
112
+ key_rect.center().y,
113
+ &self.keys[i],
114
+ caption,
115
+ weight::NORMAL,
116
+ colors.text,
117
+ );
118
+ // Separator „+” między klawiszami.
119
+ if i + 1 < rects.len() {
120
+ let cx = key_rect.right() + m.gap + Self::plus_width(&theme, ctx.text) / 2.0;
121
+ ctx.label_center(
122
+ cx,
123
+ key_rect.center().y,
124
+ "+",
125
+ caption,
126
+ weight::NORMAL,
127
+ colors.dim_text,
128
+ );
129
+ }
130
+ }
131
+ }
132
+}
133
+
134
+#[cfg(test)]
135
+mod tests {
136
+ use super::*;
137
+
138
+ #[test]
139
+ fn keys_are_laid_out_left_to_right() {
140
+ let theme = Theme::light();
141
+ let text = TextEngine::system();
142
+ let s = ShortcutLabel::new(["Ctrl", "Shift", "N"]);
143
+ let rect = Rect::from_xywh(0.0, 0.0, 400.0, 24.0);
144
+ let keys = s.key_rects(rect, &theme, &text);
145
+ assert_eq!(keys.len(), 3);
146
+ assert!(keys[1].x() > keys[0].right());
147
+ assert!(keys[2].x() > keys[1].right());
148
+ assert!(s.width(&theme, &text) > 0.0);
149
+ assert_eq!(s.string(), "Ctrl+Shift+N");
150
+ }
151
+
152
+ #[test]
153
+ fn empty_is_zero_width() {
154
+ let theme = Theme::light();
155
+ let text = TextEngine::system();
156
+ assert_eq!(
157
+ ShortcutLabel::new(Vec::<String>::new()).width(&theme, &text),
158
+ 0.0
159
+ );
160
+ }
161
+}
0
162
new file mode 100644
@@ -0,0 +1,136 @@
1
+//! `SplitButton` — przycisk dzielony: akcja główna + osobna część ze strzałką (menu).
2
+//!
3
+//! Widget immediate: [`SplitButton::rects`] dzieli prostokąt na część główną i strzałkę,
4
+//! [`SplitButton::main_at`] / [`SplitButton::arrow_at`] obsługują kliknięcia.
5
+
6
+use libluna_core::style::weight;
7
+use libluna_core::{Point, Rect, Theme};
8
+
9
+use crate::ctx::PaintCtx;
10
+use crate::icon;
11
+
12
+/// Przycisk dzielony.
13
+#[derive(Debug, Clone, Default)]
14
+pub struct SplitButton {
15
+ label: String,
16
+ enabled: bool,
17
+}
18
+
19
+/// Podział przycisku na część główną i strzałkę.
20
+#[derive(Debug, Clone, Copy, PartialEq)]
21
+pub struct SplitRects {
22
+ pub main: Rect,
23
+ pub arrow: Rect,
24
+}
25
+
26
+impl SplitButton {
27
+ pub fn new(label: impl Into<String>) -> Self {
28
+ Self {
29
+ label: label.into(),
30
+ enabled: true,
31
+ }
32
+ }
33
+
34
+ pub fn enabled(mut self, enabled: bool) -> Self {
35
+ self.enabled = enabled;
36
+ self
37
+ }
38
+
39
+ pub fn label(&self) -> &str {
40
+ &self.label
41
+ }
42
+
43
+ pub fn is_enabled(&self) -> bool {
44
+ self.enabled
45
+ }
46
+
47
+ pub fn height(&self, theme: &Theme) -> f64 {
48
+ theme.metrics.button.height
49
+ }
50
+
51
+ /// Podział prostokąta (część główna z lewej, strzałka z prawej).
52
+ pub fn rects(&self, rect: Rect, theme: &Theme) -> SplitRects {
53
+ let arrow_w = theme.metrics.split.arrow_w;
54
+ SplitRects {
55
+ main: Rect::from_xywh(
56
+ rect.x(),
57
+ rect.y(),
58
+ (rect.width() - arrow_w).max(0.0),
59
+ rect.height(),
60
+ ),
61
+ arrow: Rect::from_xywh(rect.right() - arrow_w, rect.y(), arrow_w, rect.height()),
62
+ }
63
+ }
64
+
65
+ pub fn main_at(&self, rect: Rect, theme: &Theme, p: Point) -> bool {
66
+ self.enabled && self.rects(rect, theme).main.contains(p)
67
+ }
68
+
69
+ pub fn arrow_at(&self, rect: Rect, theme: &Theme, p: Point) -> bool {
70
+ self.enabled && self.rects(rect, theme).arrow.contains(p)
71
+ }
72
+
73
+ pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
74
+ let theme = *ctx.theme;
75
+ let colors = theme.colors;
76
+ let r = self.rects(rect, &theme);
77
+ let fg = if self.enabled {
78
+ colors.accent_fg
79
+ } else {
80
+ colors.dim_text
81
+ };
82
+
83
+ ctx.scene
84
+ .fill_rounded_rect(rect, theme.radius.small, colors.accent);
85
+ // Separator między akcją a strzałką.
86
+ ctx.scene.fill_rect(
87
+ Rect::from_xywh(
88
+ r.main.right(),
89
+ rect.y() + 5.0,
90
+ theme.metrics.split.divider,
91
+ (rect.height() - 10.0).max(0.0),
92
+ ),
93
+ colors.accent_fg.scale_alpha(0.35),
94
+ );
95
+
96
+ ctx.label_center(
97
+ r.main.center().x,
98
+ r.main.center().y,
99
+ &self.label,
100
+ theme.typography.body.size,
101
+ weight::MEDIUM,
102
+ fg,
103
+ );
104
+ icon::chevron_down(ctx, fg, r.arrow.center(), 4.0, 2.0);
105
+ }
106
+}
107
+
108
+#[cfg(test)]
109
+mod tests {
110
+ use super::*;
111
+
112
+ #[test]
113
+ fn rects_partition() {
114
+ let theme = Theme::light();
115
+ let b = SplitButton::new("Save");
116
+ let rect = Rect::from_xywh(0.0, 0.0, 160.0, 34.0);
117
+ let r = b.rects(rect, &theme);
118
+ assert!((r.main.right() - r.arrow.x()).abs() < 1e-9);
119
+ assert!((r.arrow.width() - theme.metrics.split.arrow_w).abs() < 1e-9);
120
+ assert!((r.arrow.right() - rect.right()).abs() < 1e-9);
121
+ }
122
+
123
+ #[test]
124
+ fn hit_test_main_and_arrow() {
125
+ let theme = Theme::light();
126
+ let b = SplitButton::new("Save");
127
+ let rect = Rect::from_xywh(0.0, 0.0, 160.0, 34.0);
128
+ let r = b.rects(rect, &theme);
129
+ assert!(b.main_at(rect, &theme, r.main.center()));
130
+ assert!(b.arrow_at(rect, &theme, r.arrow.center()));
131
+ assert!(!b.main_at(rect, &theme, r.arrow.center()));
132
+ assert!(!SplitButton::new("x")
133
+ .enabled(false)
134
+ .main_at(rect, &theme, r.main.center()));
135
+ }
136
+}
0
137
new file mode 100644
@@ -0,0 +1,155 @@
1
+//! `ToggleGroup` — grupa przełączników (segmentowa, wielokrotnego wyboru).
2
+//!
3
+//! Widget immediate: trzyma etykiety i stan „wł.” każdego segmentu. Aplikacja wywołuje
4
+//! [`ToggleGroup::paint`] oraz [`ToggleGroup::click_at`] / [`ToggleGroup::index_at`].
5
+//! Różni się od [`ViewSwitcher`](crate::ViewSwitcher) tym, że segmenty mogą być włączone
6
+//! jednocześnie (np. `Pogrubienie | Kursywa`).
7
+
8
+use libluna_core::style::weight;
9
+use libluna_core::{Insets, Point, Rect};
10
+
11
+use crate::ctx::PaintCtx;
12
+
13
+/// Grupa przełączników.
14
+#[derive(Debug, Clone, Default)]
15
+pub struct ToggleGroup {
16
+ labels: Vec<String>,
17
+ active: Vec<bool>,
18
+ hovered: Option<usize>,
19
+}
20
+
21
+impl ToggleGroup {
22
+ pub fn new<I, S>(labels: I) -> Self
23
+ where
24
+ I: IntoIterator<Item = S>,
25
+ S: Into<String>,
26
+ {
27
+ let labels: Vec<String> = labels.into_iter().map(Into::into).collect();
28
+ let active = vec![false; labels.len()];
29
+ Self {
30
+ labels,
31
+ active,
32
+ hovered: None,
33
+ }
34
+ }
35
+
36
+ /// Tworzy grupę z jednym aktywnym segmentem.
37
+ pub fn with_active(mut self, index: usize, on: bool) -> Self {
38
+ self.set_active(index, on);
39
+ self
40
+ }
41
+
42
+ pub fn labels(&self) -> &[String] {
43
+ &self.labels
44
+ }
45
+
46
+ pub fn count(&self) -> usize {
47
+ self.labels.len()
48
+ }
49
+
50
+ pub fn is_active(&self, index: usize) -> bool {
51
+ self.active.get(index).copied().unwrap_or(false)
52
+ }
53
+
54
+ pub fn set_active(&mut self, index: usize, on: bool) {
55
+ if let Some(slot) = self.active.get_mut(index) {
56
+ *slot = on;
57
+ }
58
+ }
59
+
60
+ pub fn set_hovered(&mut self, hovered: Option<usize>) {
61
+ self.hovered = hovered;
62
+ }
63
+
64
+ /// Prostokąty segmentów (równy podział szerokości `rect`).
65
+ pub fn segments(&self, rect: Rect) -> Vec<Rect> {
66
+ let n = self.labels.len();
67
+ if n == 0 || rect.width() <= 0.0 {
68
+ return Vec::new();
69
+ }
70
+ let w = rect.width() / n as f64;
71
+ (0..n)
72
+ .map(|i| Rect::from_xywh(rect.x() + i as f64 * w, rect.y(), w, rect.height()))
73
+ .collect()
74
+ }
75
+
76
+ pub fn index_at(&self, rect: Rect, p: Point) -> Option<usize> {
77
+ if !rect.contains(p) || self.labels.is_empty() {
78
+ return None;
79
+ }
80
+ let rel = (p.x - rect.x()) / rect.width();
81
+ Some(((rel * self.labels.len() as f64) as usize).min(self.labels.len() - 1))
82
+ }
83
+
84
+ /// Obsługuje klik: przełącza segment. Zwraca indeks trafionego segmentu.
85
+ pub fn click_at(&mut self, rect: Rect, p: Point) -> Option<usize> {
86
+ let i = self.index_at(rect, p)?;
87
+ self.active[i] = !self.active[i];
88
+ Some(i)
89
+ }
90
+
91
+ pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
92
+ let theme = *ctx.theme;
93
+ let radius = theme.radius;
94
+ let colors = theme.colors;
95
+ let size = theme.typography.body.size;
96
+
97
+ let track = rect.inflate(Insets::splat(theme.metrics.segmented.track_pad));
98
+ ctx.scene
99
+ .fill_rounded_rect(track, radius.medium, colors.shade);
100
+
101
+ for (i, seg) in self.segments(rect).iter().enumerate() {
102
+ let selected = self.is_active(i);
103
+ if selected {
104
+ ctx.scene
105
+ .fill_rounded_rect(*seg, radius.small, colors.accent);
106
+ } else if self.hovered == Some(i) {
107
+ ctx.scene
108
+ .fill_rounded_rect(*seg, radius.small, colors.shade);
109
+ }
110
+ let fg = if selected {
111
+ colors.accent_fg
112
+ } else {
113
+ colors.text
114
+ };
115
+ ctx.label_center(
116
+ seg.center().x,
117
+ seg.center().y,
118
+ &self.labels[i],
119
+ size,
120
+ weight::MEDIUM,
121
+ fg,
122
+ );
123
+ }
124
+ }
125
+}
126
+
127
+#[cfg(test)]
128
+mod tests {
129
+ use super::*;
130
+
131
+ fn rect() -> Rect {
132
+ Rect::from_xywh(0.0, 0.0, 240.0, 32.0)
133
+ }
134
+
135
+ #[test]
136
+ fn segments_and_index() {
137
+ let g = ToggleGroup::new(["Bold", "Italic", "Underline"]);
138
+ let segs = g.segments(rect());
139
+ assert_eq!(segs.len(), 3);
140
+ assert_eq!(segs[0].width(), 80.0);
141
+ assert_eq!(g.index_at(rect(), Point::new(90.0, 16.0)), Some(1));
142
+ }
143
+
144
+ #[test]
145
+ fn click_toggles_multiple() {
146
+ let mut g = ToggleGroup::new(["Bold", "Italic"]);
147
+ assert_eq!(g.click_at(rect(), Point::new(60.0, 16.0)), Some(0));
148
+ assert_eq!(g.click_at(rect(), Point::new(180.0, 16.0)), Some(1));
149
+ assert!(g.is_active(0) && g.is_active(1));
150
+ // Ponowny klik wyłącza.
151
+ g.click_at(rect(), Point::new(60.0, 16.0));
152
+ assert!(!g.is_active(0));
153
+ assert!(g.is_active(1));
154
+ }
155
+}