Commit e27c7d7
10
files
+609
added
-7
deleted
@@ -334,8 +334,10 @@ XDG_CONFIG_HOME="$PWD/examples" cargo run -p luna-demo --bin render -- --lang fr
334
334
liście, z zaznaczaniem i przewijaniem) i `ScrollView` (pionowe przewijanie: rolka + „kciuk”)
335
335
oraz `Stepper`, `CheckButton` i `RadioGroup` (krokowiec, pole wyboru, wybór jednokrotny),
336
336
a także `Card` (karta z nagłówkiem), `ActionRow` (wiersz ustawień), `Toast` (powiadomienie
337
- nakładkowe z akcją, np. „Cofnij”) oraz `StatusPage` (ekran stanu: ikona, tytuł, opis, akcja —
338
- używany jako „pusty stan” listy) i wspólny `PaintCtx`.
337
+ nakładkowe z akcją, np. „Cofnij”), `StatusPage` (ekran stanu: ikona, tytuł, opis, akcja —
338
+ używany jako „pusty stan” listy), `Spinner` (wskaźnik zajętości — obracający się łuk),
339
+ `Tooltip` (dymek podpowiedzi) i `Banner` (pasek komunikatu info/ostrzeżenie/błąd z akcjami)
340
+ oraz wspólny `PaintCtx`.
339
341
- `libluna-widgets::layout`: **dwuprzebiegowy układ** (retencyjne drzewo `measure`/`allocate`) —
340
342
[`Node`] (`VBox`/`HBox`, `Padding`, `Expanded`, `Center`, `Spacer`, `Label`, `Fixed`),
341
343
[`Constraints`](min/max) i [`Layout`](`desired_size`/`fit`). Liście pochłaniają nadmiar
@@ -390,6 +392,10 @@ interfejs w każdym stylu:
390
392
391
393

392
394
395
+Nowe widgety — `Banner`, `Spinner`, `Tooltip` (`cargo run -p luna-demo --bin widgets`):
396
+
397
+
398
+
393
399
Przebieg interakcji (headless `simulate`) — m.in. menu `☰`, dolny panel, powiadomienie (`Toast`)
394
400
karta 12 i ekran stanu („Brak wyników”) klatka 14:
395
401
396
402
new file mode 100644
@@ -0,0 +1,71 @@
1
+//! Przykład nowych widgetów: `Banner`, `Spinner`, `Tooltip` (render headless).
2
+//!
3
+//! Uruchom: `cargo run -p luna-demo --bin widgets`
4
+//! Efekt: `docs/images/widgets.png`
5
+
6
+use std::error::Error;
7
+
8
+use libluna_core::style::weight;
9
+use libluna_core::{Rect, Size, Theme};
10
+use libluna_render::{Scene, SoftwareRenderer, TextEngine};
11
+use libluna_widgets::{Banner, BannerKind, Button, PaintCtx, Spinner, Tooltip};
12
+
13
+const W: u32 = 520;
14
+const H: u32 = 300;
15
+
16
+fn main() -> Result<(), Box<dyn Error>> {
17
+ let theme = Theme::dark();
18
+ let text = TextEngine::system();
19
+ let size = Size::new(W as f64, H as f64);
20
+
21
+ let mut scene = Scene::new();
22
+ let mut paint = PaintCtx {
23
+ scene: &mut scene,
24
+ theme: &theme,
25
+ text: &text,
26
+ };
27
+ paint
28
+ .scene
29
+ .fill_rect(Rect::from_size(size), theme.colors.window_bg);
30
+
31
+ // Banner (ostrzeżenie) z akcją.
32
+ let banner = Banner::new("Storage almost full")
33
+ .kind(BannerKind::Warning)
34
+ .description("Only 2 GB left on the system disk")
35
+ .action("Manage");
36
+ let banner_rect = Rect::from_xywh(16.0, 16.0, size.width - 32.0, banner.height(&theme));
37
+ banner.paint(&mut paint, banner_rect);
38
+
39
+ // Spinner (wskaźnik zajętości) — faza ustawiona ręcznie (brak pętli animacji).
40
+ let mut spinner = Spinner::new();
41
+ spinner.set_progress(0.12);
42
+ let s = spinner.size(&theme);
43
+ let spinner_rect = Rect::from_xywh(size.width / 2.0 - s / 2.0, 112.0, s, s);
44
+ spinner.paint(&mut paint, spinner_rect);
45
+
46
+ // Przycisk + dymek podpowiedzi nad nim.
47
+ let button = Button::new("Refresh");
48
+ let button_rect = Rect::from_xywh(16.0, 220.0, 120.0, button.height(&theme));
49
+ button.paint(&mut paint, button_rect);
50
+
51
+ let tooltip = Tooltip::new("Reload the list");
52
+ let tooltip_rect = tooltip.rect(button_rect, &theme, &text);
53
+ tooltip.paint(&mut paint, tooltip_rect);
54
+
55
+ // Etykieta pomocnicza pod spinnerem.
56
+ paint.label_center(
57
+ size.width / 2.0,
58
+ spinner_rect.bottom() + 20.0,
59
+ "Loading…",
60
+ theme.typography.caption.size,
61
+ weight::NORMAL,
62
+ theme.colors.dim_text,
63
+ );
64
+
65
+ let mut renderer = SoftwareRenderer::new(W, H);
66
+ renderer.draw(&scene, 1.0, theme.colors.window_bg);
67
+ let png = renderer.encode_png().ok_or("failed to encode PNG")?;
68
+ std::fs::write("docs/images/widgets.png", png)?;
69
+ println!("saved docs/images/widgets.png ({W}x{H})");
70
+ Ok(())
71
+}
@@ -27,10 +27,10 @@ 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, BottomSheetMetrics, ButtonMetrics, CardMetrics, CheckMetrics,
30
+ ActionRowMetrics, BannerMetrics, BottomSheetMetrics, ButtonMetrics, CardMetrics, CheckMetrics,
31
31
DropDownMetrics, EntryMetrics, HeaderBarMetrics, ItemViewMetrics, MenuMetrics, Metrics,
32
32
NavMetrics, PagerMetrics, PathBarMetrics, RadioMetrics, Radius, ScrollViewMetrics,
33
- SegmentedMetrics, SliderMetrics, Spacing, StatusPageMetrics, StepperMetrics, SwitchMetrics,
34
- TabsMetrics, Theme, ToastMetrics, Typography,
33
+ SegmentedMetrics, SliderMetrics, Spacing, SpinnerMetrics, StatusPageMetrics, StepperMetrics,
34
+ SwitchMetrics, TabsMetrics, Theme, ToastMetrics, TooltipMetrics, Typography,
35
35
};
36
36
pub use theme_file::{available as available_themes, StyledTheme, ThemePatch};
@@ -473,6 +473,45 @@ pub struct StatusPageMetrics {
473
473
pub button_h: f64,
474
474
}
475
475
476
+/// Geometria wskaźnika zajętości ([`Spinner`](crate::style)).
477
+#[derive(Debug, Clone, Copy, PartialEq)]
478
+pub struct SpinnerMetrics {
479
+ /// Domyślny bok.
480
+ pub size: f64,
481
+ /// Grubość łuku.
482
+ pub thickness: f64,
483
+ /// Długość łuku jako ułamek pełnego okręgu (0..1).
484
+ pub sweep: f64,
485
+}
486
+
487
+/// Geometria dymka podpowiedzi ([`Tooltip`](crate::style)).
488
+#[derive(Debug, Clone, Copy, PartialEq)]
489
+pub struct TooltipMetrics {
490
+ /// Padding poziomy.
491
+ pub pad_x: f64,
492
+ /// Padding pionowy.
493
+ pub pad_y: f64,
494
+ /// Promień zaokrąglenia.
495
+ pub radius: f64,
496
+ /// Odstęp od kotwicy (nad kotwicą).
497
+ pub gap: f64,
498
+}
499
+
500
+/// Geometria banera ([`Banner`](crate::style)).
501
+#[derive(Debug, Clone, Copy, PartialEq)]
502
+pub struct BannerMetrics {
503
+ /// Padding wewnętrzny.
504
+ pub pad: f64,
505
+ /// Odstęp między elementami.
506
+ pub gap: f64,
507
+ /// Bok ikony.
508
+ pub icon: f64,
509
+ /// Minimalna wysokość.
510
+ pub min_height: f64,
511
+ /// Wysokość przycisku akcji.
512
+ pub button_h: f64,
513
+}
514
+
476
515
/// Geometria kontrolek („component metrics”) — rozmiary i paddingi w pikselach logicznych.
477
516
///
478
517
/// Widgety nie mają już własnych stałych rozmiarów: czytają je z motywu, dzięki czemu
@@ -502,6 +541,9 @@ pub struct Metrics {
502
541
pub row: ActionRowMetrics,
503
542
pub toast: ToastMetrics,
504
543
pub status: StatusPageMetrics,
544
+ pub spinner: SpinnerMetrics,
545
+ pub tooltip: TooltipMetrics,
546
+ pub banner: BannerMetrics,
505
547
}
506
548
507
549
impl Metrics {
@@ -630,6 +672,24 @@ impl Metrics {
630
672
button_w: 160.0,
631
673
button_h: 34.0,
632
674
},
675
+ spinner: SpinnerMetrics {
676
+ size: 24.0,
677
+ thickness: 2.5,
678
+ sweep: 0.75,
679
+ },
680
+ tooltip: TooltipMetrics {
681
+ pad_x: 10.0,
682
+ pad_y: 6.0,
683
+ radius: 8.0,
684
+ gap: 8.0,
685
+ },
686
+ banner: BannerMetrics {
687
+ pad: 12.0,
688
+ gap: 12.0,
689
+ icon: 20.0,
690
+ min_height: 56.0,
691
+ button_h: 30.0,
692
+ },
633
693
}
634
694
}
635
695
}
636
696
new file mode 100644
@@ -0,0 +1,250 @@
1
+//! `Banner` — pasek komunikatu (info/ostrzeżenie/błąd) z tytułem, opisem i akcjami.
2
+//!
3
+//! Widget immediate: [`Banner::rects`] liczy rozmieszczenie (ikona, tytuł, opis, przyciski),
4
+//! [`Banner::action_at`] obsługuje klik w akcje, a [`Banner::paint`] rysuje pasek.
5
+
6
+use libluna_core::style::weight;
7
+use libluna_core::{Color, Insets, Point, Rect, Theme};
8
+use libluna_render::TextEngine;
9
+
10
+use crate::ctx::PaintCtx;
11
+
12
+/// Rodzaj komunikatu (wpływa na kolor akcentowy paska).
13
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14
+pub enum BannerKind {
15
+ #[default]
16
+ Info,
17
+ Warning,
18
+ Error,
19
+}
20
+
21
+/// Pasek komunikatu.
22
+#[derive(Debug, Clone, Default)]
23
+pub struct Banner {
24
+ kind: BannerKind,
25
+ title: String,
26
+ description: Option<String>,
27
+ actions: Vec<String>,
28
+}
29
+
30
+/// Rozmieszczenie elementów paska.
31
+#[derive(Debug, Clone, PartialEq)]
32
+pub struct BannerRects {
33
+ pub icon: Rect,
34
+ pub title: Rect,
35
+ pub description: Option<Rect>,
36
+ pub actions: Vec<Rect>,
37
+}
38
+
39
+impl Banner {
40
+ pub fn new(title: impl Into<String>) -> Self {
41
+ Self {
42
+ kind: BannerKind::Info,
43
+ title: title.into(),
44
+ description: None,
45
+ actions: Vec::new(),
46
+ }
47
+ }
48
+
49
+ pub fn kind(mut self, kind: BannerKind) -> Self {
50
+ self.kind = kind;
51
+ self
52
+ }
53
+
54
+ pub fn description(mut self, description: impl Into<String>) -> Self {
55
+ self.description = Some(description.into());
56
+ self
57
+ }
58
+
59
+ /// Dodaje przycisk akcji (od lewej do prawej).
60
+ pub fn action(mut self, label: impl Into<String>) -> Self {
61
+ self.actions.push(label.into());
62
+ self
63
+ }
64
+
65
+ /// Wysokość paska.
66
+ pub fn height(&self, theme: &Theme) -> f64 {
67
+ theme.metrics.banner.min_height
68
+ }
69
+
70
+ /// Kolor akcentowy paska (wg rodzaju).
71
+ pub fn color(&self, theme: &Theme) -> Color {
72
+ match self.kind {
73
+ BannerKind::Info => theme.colors.accent,
74
+ BannerKind::Warning => theme.colors.warning,
75
+ BannerKind::Error => theme.colors.error,
76
+ }
77
+ }
78
+
79
+ pub fn rects(&self, area: Rect, theme: &Theme, text: &TextEngine) -> BannerRects {
80
+ let m = theme.metrics.banner;
81
+ let body = theme.typography.body;
82
+ let inner = area.deflate(Insets::splat(m.pad));
83
+ let cy = inner.center().y;
84
+
85
+ let icon = Rect::from_xywh(inner.x(), cy - m.icon / 2.0, m.icon, m.icon);
86
+
87
+ // Akcje wyrównane do prawej.
88
+ let mut actions: Vec<Rect> = Vec::new();
89
+ let mut x = inner.right();
90
+ for label in self.actions.iter().rev() {
91
+ let w = 2.0 * theme.spacing.md + text.measure(label, body.size, weight::MEDIUM).width;
92
+ actions.push(Rect::from_xywh(x - w, cy - m.button_h / 2.0, w, m.button_h));
93
+ x -= w + m.gap;
94
+ }
95
+ actions.reverse();
96
+ let actions_left = actions.first().map(|r| r.x()).unwrap_or(inner.right());
97
+
98
+ let text_left = icon.right() + m.gap;
99
+ let text_right = (actions_left - m.gap).max(text_left);
100
+ let width = (text_right - text_left).max(0.0);
101
+
102
+ let (title, description) = match self.description.is_some() {
103
+ true => {
104
+ let block = body.line_height * 2.0;
105
+ let top = cy - block / 2.0;
106
+ (
107
+ Rect::from_xywh(text_left, top, width, body.line_height),
108
+ Some(Rect::from_xywh(
109
+ text_left,
110
+ top + body.line_height,
111
+ width,
112
+ body.line_height,
113
+ )),
114
+ )
115
+ }
116
+ false => (
117
+ Rect::from_xywh(
118
+ text_left,
119
+ cy - body.line_height / 2.0,
120
+ width,
121
+ body.line_height,
122
+ ),
123
+ None,
124
+ ),
125
+ };
126
+
127
+ BannerRects {
128
+ icon,
129
+ title,
130
+ description,
131
+ actions,
132
+ }
133
+ }
134
+
135
+ /// Indeks akcji pod punktem `p`.
136
+ pub fn action_at(
137
+ &self,
138
+ area: Rect,
139
+ theme: &Theme,
140
+ text: &TextEngine,
141
+ p: Point,
142
+ ) -> Option<usize> {
143
+ self.rects(area, theme, text)
144
+ .actions
145
+ .iter()
146
+ .position(|r| r.contains(p))
147
+ }
148
+
149
+ pub fn paint(&self, ctx: &mut PaintCtx, area: Rect) {
150
+ let theme = *ctx.theme;
151
+ let colors = theme.colors;
152
+ let accent = self.color(&theme);
153
+ let r = self.rects(area, &theme, ctx.text);
154
+
155
+ // Tło (delikatny odcień) + pasek po lewej w kolorze rodzaju.
156
+ ctx.scene
157
+ .fill_rounded_rect(area, theme.radius.medium, accent.scale_alpha(0.16));
158
+ let bar = Rect::from_xywh(area.x(), area.y(), 4.0, area.height());
159
+ ctx.scene
160
+ .fill_rounded_rect(bar, theme.radius.medium, accent);
161
+
162
+ // Ikona (kropka w kolorze rodzaju).
163
+ ctx.scene
164
+ .fill_rounded_rect(r.icon, r.icon.width() / 2.0, accent);
165
+
166
+ ctx.label_left(
167
+ r.title.x(),
168
+ r.title.center().y,
169
+ &self.title,
170
+ theme.typography.body.size,
171
+ weight::BOLD,
172
+ colors.text,
173
+ );
174
+
175
+ if let (Some(description), Some(rect)) = (&self.description, r.description) {
176
+ ctx.label_left(
177
+ rect.x(),
178
+ rect.center().y,
179
+ description,
180
+ theme.typography.body.size,
181
+ weight::NORMAL,
182
+ colors.dim_text,
183
+ );
184
+ }
185
+
186
+ for (label, rect) in self.actions.iter().zip(&r.actions) {
187
+ ctx.scene
188
+ .fill_rounded_rect(*rect, theme.radius.small, colors.shade);
189
+ ctx.label_center(
190
+ rect.center().x,
191
+ rect.center().y,
192
+ label,
193
+ theme.typography.body.size,
194
+ weight::MEDIUM,
195
+ colors.text,
196
+ );
197
+ }
198
+ }
199
+}
200
+
201
+#[cfg(test)]
202
+mod tests {
203
+ use super::*;
204
+
205
+ fn area() -> Rect {
206
+ Rect::from_xywh(0.0, 0.0, 600.0, 56.0)
207
+ }
208
+
209
+ #[test]
210
+ fn layout_places_icon_left_and_actions_right() {
211
+ let theme = Theme::light();
212
+ let text = TextEngine::system();
213
+ let banner = Banner::new("Błąd")
214
+ .kind(BannerKind::Error)
215
+ .description("Nie udało się zapisać")
216
+ .action("Ponów");
217
+ let rects = banner.rects(area(), &theme, &text);
218
+ assert!(rects.icon.x() < rects.title.x());
219
+ assert!(rects.description.is_some());
220
+ assert_eq!(rects.actions.len(), 1);
221
+ assert!(rects.actions[0].right() <= area().right());
222
+ assert!(rects.title.right() <= rects.actions[0].x());
223
+ }
224
+
225
+ #[test]
226
+ fn action_hit_test() {
227
+ let theme = Theme::light();
228
+ let text = TextEngine::system();
229
+ let banner = Banner::new("Info").action("OK");
230
+ let r = banner.rects(area(), &theme, &text);
231
+ assert_eq!(
232
+ banner.action_at(area(), &theme, &text, r.actions[0].center()),
233
+ Some(0)
234
+ );
235
+ assert_eq!(
236
+ banner.action_at(area(), &theme, &text, Point::new(2.0, 2.0)),
237
+ None
238
+ );
239
+ }
240
+
241
+ #[test]
242
+ fn colors_per_kind() {
243
+ let theme = Theme::light();
244
+ assert_eq!(Banner::new("x").color(&theme), theme.colors.accent);
245
+ assert_eq!(
246
+ Banner::new("x").kind(BannerKind::Error).color(&theme),
247
+ theme.colors.error
248
+ );
249
+ }
250
+}
@@ -31,6 +31,9 @@
31
31
//! - [`ActionRow`] — wiersz ustawień (tytuł/podtytuł + obszar na kontrolkę).
32
32
//! - [`Toast`] — krótkie powiadomienie (nakładka) z opcjonalną akcją.
33
33
//! - [`StatusPage`] — ekran stanu (pusty/powitalny): ikona, tytuł, opis, akcja.
34
+//! - [`Spinner`] — wskaźnik zajętości (obracający się łuk).
35
+//! - [`Tooltip`] — dymek podpowiedzi nad elementem.
36
+//! - [`Banner`] — pasek komunikatu (info/ostrzeżenie/błąd) z akcjami.
34
37
//!
35
38
//! Układ (retencyjne drzewo, `measure`/`allocate`): [`layout`] ([`Node`], [`Layout`], [`Constraints`]).
36
39
//!
@@ -43,6 +46,7 @@
43
46
//! ```
44
47
45
48
pub mod action_row;
49
+pub mod banner;
46
50
pub mod bottom_sheet;
47
51
pub mod button;
48
52
pub mod card;
@@ -62,15 +66,18 @@ pub mod radio_group;
62
66
pub mod scroll_view;
63
67
pub mod search_entry;
64
68
pub mod slider;
69
+pub mod spinner;
65
70
pub mod status_page;
66
71
pub mod stepper;
67
72
pub mod switch;
68
73
pub mod tab_bar;
69
74
pub mod tab_view;
70
75
pub mod toast;
76
+pub mod tooltip;
71
77
pub mod view_switcher;
72
78
73
79
pub use action_row::ActionRow;
80
+pub use banner::{Banner, BannerKind, BannerRects};
74
81
pub use bottom_sheet::BottomSheet;
75
82
pub use button::{Button, ButtonVariant};
76
83
pub use card::Card;
@@ -89,10 +96,12 @@ pub use radio_group::RadioGroup;
89
96
pub use scroll_view::ScrollView;
90
97
pub use search_entry::SearchEntry;
91
98
pub use slider::Slider;
99
+pub use spinner::Spinner;
92
100
pub use status_page::{StatusIcon, StatusPage, StatusRects};
93
101
pub use stepper::{Stepper, StepperSide};
94
102
pub use switch::Switch;
95
103
pub use tab_bar::{Tab, TabBar};
96
104
pub use tab_view::TabView;
97
105
pub use toast::Toast;
106
+pub use tooltip::Tooltip;
98
107
pub use view_switcher::ViewSwitcher;
99
108
new file mode 100644
@@ -0,0 +1,112 @@
1
+//! `Spinner` — wskaźnik zajętości: obracający się łuk (bez animacji własnej — fazę
2
+//! wyznacza aplikacja przez [`Spinner::set_progress`], np. z licznika klatek/czasu).
3
+//!
4
+//! Widget immediate: [`Spinner::paint`] rysuje ślad i łuk w zadanym prostokącie.
5
+
6
+use std::f64::consts::TAU;
7
+
8
+use libluna_core::{Color, Point, Rect, Theme};
9
+
10
+use crate::ctx::PaintCtx;
11
+
12
+/// Liczba segmentów aproksymujących łuk.
13
+const SEGMENTS: usize = 32;
14
+
15
+/// Wskaźnik zajętości.
16
+#[derive(Debug, Clone, Copy, Default)]
17
+pub struct Spinner {
18
+ /// Faza obrotu w zakresie 0..1.
19
+ progress: f64,
20
+}
21
+
22
+impl Spinner {
23
+ pub fn new() -> Self {
24
+ Self::default()
25
+ }
26
+
27
+ /// Ustawia fazę obrotu (zawijana do 0..1).
28
+ pub fn set_progress(&mut self, progress: f64) {
29
+ self.progress = progress.rem_euclid(1.0);
30
+ }
31
+
32
+ pub fn progress(&self) -> f64 {
33
+ self.progress
34
+ }
35
+
36
+ /// Domyślny bok wskaźnika (z metryk).
37
+ pub fn size(&self, theme: &Theme) -> f64 {
38
+ theme.metrics.spinner.size
39
+ }
40
+
41
+ pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
42
+ let theme = *ctx.theme;
43
+ let m = theme.metrics.spinner;
44
+ let radius = (rect.width().min(rect.height()) - m.thickness).max(0.0) / 2.0;
45
+ let center = rect.center();
46
+ // Ślad (pełny, wyciszony) + obracający się łuk.
47
+ draw_arc(
48
+ ctx,
49
+ center,
50
+ radius,
51
+ 0.0,
52
+ 1.0,
53
+ m.thickness,
54
+ theme.colors.dim_text.scale_alpha(0.3),
55
+ );
56
+ draw_arc(
57
+ ctx,
58
+ center,
59
+ radius,
60
+ self.progress * TAU,
61
+ m.sweep,
62
+ m.thickness,
63
+ theme.colors.accent,
64
+ );
65
+ }
66
+}
67
+
68
+fn draw_arc(
69
+ ctx: &mut PaintCtx,
70
+ center: Point,
71
+ radius: f64,
72
+ start: f64,
73
+ sweep: f64,
74
+ thickness: f64,
75
+ color: Color,
76
+) {
77
+ let total = sweep.clamp(0.0, 1.0) * TAU;
78
+ let mut prev = point_on(center, radius, start);
79
+ for i in 1..=SEGMENTS {
80
+ let angle = start + total * (i as f64 / SEGMENTS as f64);
81
+ let p = point_on(center, radius, angle);
82
+ ctx.scene.stroke_line(prev, p, thickness, color);
83
+ prev = p;
84
+ }
85
+}
86
+
87
+fn point_on(center: Point, radius: f64, angle: f64) -> Point {
88
+ Point::new(
89
+ center.x + radius * angle.cos(),
90
+ center.y + radius * angle.sin(),
91
+ )
92
+}
93
+
94
+#[cfg(test)]
95
+mod tests {
96
+ use super::*;
97
+
98
+ #[test]
99
+ fn progress_wraps() {
100
+ let mut s = Spinner::new();
101
+ s.set_progress(1.25);
102
+ assert!((s.progress() - 0.25).abs() < 1e-9);
103
+ s.set_progress(-0.25);
104
+ assert!((s.progress() - 0.75).abs() < 1e-9);
105
+ }
106
+
107
+ #[test]
108
+ fn size_from_metrics() {
109
+ let theme = Theme::light();
110
+ assert_eq!(Spinner::new().size(&theme), theme.metrics.spinner.size);
111
+ }
112
+}
0
113
new file mode 100644
@@ -0,0 +1,90 @@
1
+//! `Tooltip` — dymek podpowiedzi umieszczany nad elementem (nakładka).
2
+//!
3
+//! Widget immediate: [`Tooltip::rect`] liczy prostokąt nad kotwicą, [`Tooltip::paint`] rysuje
4
+//! powierzchnię o wysokim kontraście z napisem.
5
+
6
+use libluna_core::style::{weight, Appearance};
7
+use libluna_core::{Rect, Theme};
8
+use libluna_render::TextEngine;
9
+
10
+use crate::ctx::PaintCtx;
11
+
12
+/// Dymek podpowiedzi.
13
+#[derive(Debug, Clone, Default)]
14
+pub struct Tooltip {
15
+ text: String,
16
+}
17
+
18
+impl Tooltip {
19
+ pub fn new(text: impl Into<String>) -> Self {
20
+ Self { text: text.into() }
21
+ }
22
+
23
+ pub fn set_text(&mut self, text: impl Into<String>) {
24
+ self.text = text.into();
25
+ }
26
+
27
+ pub fn text(&self) -> &str {
28
+ &self.text
29
+ }
30
+
31
+ /// Prostokąt dymka nad kotwicą `anchor` (wyśrodkowany, odsunięty o `gap`).
32
+ pub fn rect(&self, anchor: Rect, theme: &Theme, text: &TextEngine) -> Rect {
33
+ let m = theme.metrics.tooltip;
34
+ let caption = theme.typography.caption;
35
+ let width = m.pad_x * 2.0 + text.measure(&self.text, caption.size, weight::NORMAL).width;
36
+ let height = m.pad_y * 2.0 + caption.line_height;
37
+ Rect::from_xywh(
38
+ anchor.center().x - width / 2.0,
39
+ anchor.y() - m.gap - height,
40
+ width,
41
+ height,
42
+ )
43
+ }
44
+
45
+ /// Rysuje dymek (nakładka — na wierzchu sceny).
46
+ pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
47
+ let theme = *ctx.theme;
48
+ let m = theme.metrics.tooltip;
49
+ let (bg, fg) = match theme.appearance {
50
+ Appearance::Light => (theme.colors.text, theme.colors.view_bg),
51
+ Appearance::Dark => (theme.colors.card_bg, theme.colors.text),
52
+ };
53
+ ctx.scene.fill_rounded_rect(rect, m.radius, bg);
54
+ ctx.label_center(
55
+ rect.center().x,
56
+ rect.center().y,
57
+ &self.text,
58
+ theme.typography.caption.size,
59
+ weight::NORMAL,
60
+ fg,
61
+ );
62
+ }
63
+}
64
+
65
+#[cfg(test)]
66
+mod tests {
67
+ use super::*;
68
+
69
+ #[test]
70
+ fn rect_sits_above_anchor_and_is_centered() {
71
+ let theme = Theme::light();
72
+ let text = TextEngine::system();
73
+ let tooltip = Tooltip::new("Zapisz zmiany");
74
+ let anchor = Rect::from_xywh(100.0, 200.0, 40.0, 20.0);
75
+ let r = tooltip.rect(anchor, &theme, &text);
76
+ assert!((r.center().x - anchor.center().x).abs() < 1e-9);
77
+ assert!(r.bottom() <= anchor.y());
78
+ assert!((anchor.y() - r.bottom() - theme.metrics.tooltip.gap).abs() < 1e-9);
79
+ }
80
+
81
+ #[test]
82
+ fn width_grows_with_text() {
83
+ let theme = Theme::light();
84
+ let text = TextEngine::system();
85
+ let anchor = Rect::from_xywh(0.0, 100.0, 10.0, 10.0);
86
+ let short = Tooltip::new("a").rect(anchor, &theme, &text);
87
+ let long = Tooltip::new("znacznie dłuższy napis").rect(anchor, &theme, &text);
88
+ assert!(long.width() > short.width());
89
+ }
90
+}
0
91
new file mode 100644
1
92
Binary files /dev/null and b/docs/images/widgets.png differ
@@ -41,8 +41,10 @@ a na końcu odtworzył realne aplikacje środowiska Luna z makiet.
41
41
`ItemView` (pliki/katalogi: siatka
42
42
lub lista) i `ScrollView` (przewijanie), oraz `Stepper`, `CheckButton` i `RadioGroup`
43
43
(krokowiec, checkbox, wybór jednokrotny); do tego `Card` i `ActionRow` (karty i wiersze
44
- ustawień), `Toast` (powiadomienie nakładkowe z akcją) i `StatusPage` (ekran stanu: ikona,
45
- tytuł, opis, akcja — np. „pusty stan” listy); wspólny `PaintCtx`; testy hit-testingu bez okna.
44
+ ustawień), `Toast` (powiadomienie nakładkowe z akcją), `StatusPage` (ekran stanu: ikona,
45
+ tytuł, opis, akcja — np. „pusty stan” listy), `Spinner` (wskaźnik zajętości), `Tooltip`
46
+ (dymek podpowiedzi) i `Banner` (pasek komunikatu z akcjami); wspólny `PaintCtx`; testy
47
+ hit-testingu bez okna.
46
48
- `libluna-platform`: realne akcje okna z `Frame` (`close`/`minimize`/`toggle_maximize`).
47
49
- Demo używa widgetów zamiast ręcznego rysowania (pasek boczny, wybór motywu/widoku, menu `☰`,
48
50
przycisk „Opcje”, przełączniki, suwak, wybór języka, pasek tytułu, zakładki `TabBar`/`TabView`).
@@ -123,6 +125,8 @@ a na końcu odtworzył realne aplikacje środowiska Luna z makiet.
123
125
| Button (pill/suggested/flat) | `libluna-widgets` | Settings |
124
126
| Stepper / Check / Radio | `libluna-widgets` | Files (popover) |
125
127
| Toast | `libluna-widgets` | Settings, Files |
128
+| Banner | `libluna-widgets` | Files, Settings |
129
+| Spinner / Tooltip | `libluna-widgets` | obie |
126
130
| StatusBar / bottom info | `libluna-widgets` | obie |
127
131
| StatusPage (pusty stan) | `libluna-widgets` | Files |
128
132