← luna

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 ![Podgląd stylów](docs/images/styles.png)
392 394
395 +Nowe widgety — `Banner`, `Spinner`, `Tooltip` (`cargo run -p luna-demo --bin widgets`):
396 +
397 +![Banner, Spinner, Tooltip](docs/images/widgets.png)
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