← luna

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 +}