Commit 8a5ecd4
7
files
+865
added
-0
deleted
@@ -0,0 +1,109 @@
1
+//! Podgląd nowych widgetów libLuny: `InfoBar`, `Picture`, `MediaControls`.
2
+//!
3
+//! Uruchom: `cargo run -p luna-demo --bin new-widgets`
4
+//! Efekt: `docs/images/new-widgets.png`
5
+
6
+use std::error::Error;
7
+use std::sync::Arc;
8
+
9
+use libluna_core::style::weight;
10
+use libluna_core::{Rect, Size, Theme};
11
+use libluna_render::{ImageData, Scene, SoftwareRenderer, TextEngine};
12
+use libluna_widgets::{ContentFit, InfoBar, InfoBarKind, MediaControls, PaintCtx, Picture};
13
+
14
+const W: u32 = 900;
15
+const H: u32 = 560;
16
+
17
+fn main() -> Result<(), Box<dyn Error>> {
18
+ let theme = Theme::dark();
19
+ let text = TextEngine::system();
20
+ let size = Size::new(W as f64, H as f64);
21
+
22
+ let mut scene = Scene::new();
23
+ let mut paint = PaintCtx::new(&mut scene, &theme, &text);
24
+ paint
25
+ .scene
26
+ .fill_rect(Rect::from_size(size), theme.colors.window_bg);
27
+
28
+ caption(&mut paint, 24.0, 20.0, "InfoBar (GTK4) — 4 rodzaje");
29
+
30
+ let y = 44.0;
31
+ let bars = [
32
+ (InfoBarKind::Info, "Nowa wersja jest dostępna", true),
33
+ (InfoBarKind::Question, "Zsynchronizować ustawienia?", true),
34
+ (InfoBarKind::Warning, "Mało miejsca na dysku", true),
35
+ (InfoBarKind::Error, "Nie udało się zapisać pliku", false),
36
+ ];
37
+ for (i, (kind, msg, with_action)) in bars.iter().enumerate() {
38
+ let mut bar = InfoBar::new(*msg).kind(*kind);
39
+ if *with_action {
40
+ bar = bar.action("OK");
41
+ }
42
+ if i == 3 {
43
+ bar = bar.action("Ponów");
44
+ }
45
+ let rect = Rect::from_xywh(24.0, y + i as f64 * 64.0, 520.0, 56.0);
46
+ bar.paint(&mut paint, rect);
47
+ }
48
+
49
+ // ── Picture (dopasowanie) ──
50
+ caption(&mut paint, 580.0, 20.0, "Picture — Contain / Cover / Fill");
51
+ let image = Arc::new(gradient(160, 100));
52
+ for (i, fit) in [ContentFit::Contain, ContentFit::Cover, ContentFit::Fill]
53
+ .into_iter()
54
+ .enumerate()
55
+ {
56
+ let mut pic = Picture::with_image(image.clone());
57
+ pic.set_fit(fit);
58
+ let rect = Rect::from_xywh(580.0, 44.0 + i as f64 * 92.0, 296.0, 80.0);
59
+ paint.scene.fill_rect(rect, theme.colors.view_bg);
60
+ pic.paint(&mut paint, rect);
61
+ paint
62
+ .scene
63
+ .stroke_rounded_rect(rect, 0.0, 1.0, theme.colors.border);
64
+ }
65
+
66
+ // ── MediaControls ──
67
+ caption(&mut paint, 24.0, 320.0, "MediaControls (GTK4)");
68
+ let mut media = MediaControls::new("Aurora", "Luna Ensemble");
69
+ media.set_times("1:42", "4:07");
70
+ media.set_position(0.41);
71
+ media.paint(&mut paint, Rect::from_xywh(24.0, 344.0, 520.0, 64.0));
72
+
73
+ let mut paused = MediaControls::new("Nokturn", "Selene");
74
+ paused.set_playing(false);
75
+ paused.set_times("0:15", "3:20");
76
+ paused.set_position(0.08);
77
+ paused.paint(&mut paint, Rect::from_xywh(24.0, 420.0, 520.0, 64.0));
78
+
79
+ let mut renderer = SoftwareRenderer::new(W, H);
80
+ renderer.draw(&scene, 1.0, theme.colors.window_bg);
81
+ let png = renderer.encode_png().ok_or("failed to encode PNG")?;
82
+ std::fs::write("docs/images/new-widgets.png", png)?;
83
+ println!("saved docs/images/new-widgets.png ({W}x{H})");
84
+ Ok(())
85
+}
86
+
87
+fn caption(paint: &mut PaintCtx, x: f64, y: f64, text: &str) {
88
+ paint.label_left(
89
+ x,
90
+ y + 8.0,
91
+ text,
92
+ paint.theme.typography.caption.size,
93
+ weight::BOLD,
94
+ paint.theme.colors.dim_text,
95
+ );
96
+}
97
+
98
+/// Prosty obraz gradientowy (RGB) do demonstracji `Picture`.
99
+fn gradient(w: u32, h: u32) -> ImageData {
100
+ let mut rgba = Vec::with_capacity((w * h * 4) as usize);
101
+ for y in 0..h {
102
+ for x in 0..w {
103
+ let r = (x * 255 / w.max(1)) as u8;
104
+ let g = (y * 255 / h.max(1)) as u8;
105
+ rgba.extend_from_slice(&[r, g, 200, 255]);
106
+ }
107
+ }
108
+ ImageData::new(w, h, rgba)
109
+}
0
110
new file mode 100644
@@ -0,0 +1,284 @@
1
+//! `InfoBar` — pasek informacyjny (odpowiednik GTK4 `GtkInfoBar`).
2
+//!
3
+//! W odróżnieniu od libadwaitowej karty [`Banner`](crate::Banner), `InfoBar` ma cztery
4
+//! rodzaje (info/pytanie/ostrzeżenie/błąd), opcjonalny przycisk zamknięcia oraz dowolną
5
+//! liczbę przycisków akcji — jak w GTK4.
6
+
7
+use libluna_core::style::weight;
8
+use libluna_core::{Color, Insets, Point, Rect, Theme};
9
+use libluna_render::TextEngine;
10
+
11
+use crate::ctx::PaintCtx;
12
+
13
+/// Rodzaj paska (kolor ikony i paska po lewej).
14
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15
+pub enum InfoBarKind {
16
+ #[default]
17
+ Info,
18
+ Question,
19
+ Warning,
20
+ Error,
21
+}
22
+
23
+/// Pasek informacyjny.
24
+#[derive(Debug, Clone, Default)]
25
+pub struct InfoBar {
26
+ kind: InfoBarKind,
27
+ message: String,
28
+ actions: Vec<String>,
29
+ show_close: bool,
30
+}
31
+
32
+/// Rozmieszczenie elementów paska.
33
+#[derive(Debug, Clone, PartialEq)]
34
+pub struct InfoBarRects {
35
+ pub icon: Rect,
36
+ pub message: Rect,
37
+ pub actions: Vec<Rect>,
38
+ pub close: Option<Rect>,
39
+}
40
+
41
+impl InfoBar {
42
+ pub fn new(message: impl Into<String>) -> Self {
43
+ Self {
44
+ kind: InfoBarKind::Info,
45
+ message: message.into(),
46
+ actions: Vec::new(),
47
+ show_close: true,
48
+ }
49
+ }
50
+
51
+ pub fn kind(mut self, kind: InfoBarKind) -> Self {
52
+ self.kind = kind;
53
+ self
54
+ }
55
+
56
+ pub fn action(mut self, label: impl Into<String>) -> Self {
57
+ self.actions.push(label.into());
58
+ self
59
+ }
60
+
61
+ pub fn show_close(mut self, show: bool) -> Self {
62
+ self.show_close = show;
63
+ self
64
+ }
65
+
66
+ /// Kolor rodzaju.
67
+ pub fn color(&self, theme: &Theme) -> Color {
68
+ match self.kind {
69
+ InfoBarKind::Info => theme.colors.accent,
70
+ InfoBarKind::Question => theme.colors.success,
71
+ InfoBarKind::Warning => theme.colors.warning,
72
+ InfoBarKind::Error => theme.colors.error,
73
+ }
74
+ }
75
+
76
+ /// Symbol rodzaju rysowany fontem interfejsu.
77
+ pub fn glyph(&self) -> &'static str {
78
+ match self.kind {
79
+ InfoBarKind::Info => "●",
80
+ InfoBarKind::Question => "?",
81
+ InfoBarKind::Warning => "▲",
82
+ InfoBarKind::Error => "✗",
83
+ }
84
+ }
85
+
86
+ pub fn height(&self, theme: &Theme) -> f64 {
87
+ theme.metrics.banner.min_height
88
+ }
89
+
90
+ pub fn rects(&self, area: Rect, theme: &Theme, text: &TextEngine) -> InfoBarRects {
91
+ let m = theme.metrics.banner;
92
+ let body = theme.typography.body.size;
93
+ let inner = area.deflate(Insets::splat(m.pad));
94
+ let cy = inner.center().y;
95
+ let icon = Rect::from_xywh(inner.x(), cy - m.icon / 2.0, m.icon, m.icon);
96
+
97
+ // Zamknięcie po prawej.
98
+ let mut right = inner.right();
99
+ let close = if self.show_close {
100
+ let r = Rect::from_xywh(
101
+ right - m.button_h,
102
+ cy - m.button_h / 2.0,
103
+ m.button_h,
104
+ m.button_h,
105
+ );
106
+ right = r.x() - m.gap;
107
+ Some(r)
108
+ } else {
109
+ None
110
+ };
111
+
112
+ // Akcje wyrównane do prawej (przed zamknięciem).
113
+ let mut actions: Vec<Rect> = Vec::new();
114
+ for label in self.actions.iter().rev() {
115
+ let w = 2.0 * theme.spacing.md + text.measure(label, body, weight::MEDIUM).width;
116
+ actions.push(Rect::from_xywh(
117
+ right - w,
118
+ cy - m.button_h / 2.0,
119
+ w,
120
+ m.button_h,
121
+ ));
122
+ right -= w + m.gap;
123
+ }
124
+ actions.reverse();
125
+
126
+ let text_left = icon.right() + m.gap;
127
+ let width = (right - text_left).max(0.0);
128
+ let message = Rect::from_xywh(text_left, cy - body / 2.0 - 2.0, width, body + 4.0);
129
+
130
+ InfoBarRects {
131
+ icon,
132
+ message,
133
+ actions,
134
+ close,
135
+ }
136
+ }
137
+
138
+ /// Indeks akcji pod punktem `p`.
139
+ pub fn action_at(
140
+ &self,
141
+ area: Rect,
142
+ theme: &Theme,
143
+ text: &TextEngine,
144
+ p: Point,
145
+ ) -> Option<usize> {
146
+ self.rects(area, theme, text)
147
+ .actions
148
+ .iter()
149
+ .position(|r| r.contains(p))
150
+ }
151
+
152
+ /// Czy kliknięto przycisk zamknięcia.
153
+ pub fn close_at(&self, area: Rect, theme: &Theme, text: &TextEngine, p: Point) -> bool {
154
+ self.rects(area, theme, text)
155
+ .close
156
+ .map(|r| r.contains(p))
157
+ .unwrap_or(false)
158
+ }
159
+
160
+ pub fn paint(&self, ctx: &mut PaintCtx, area: Rect) {
161
+ let theme = *ctx.theme;
162
+ let accent = self.color(&theme);
163
+ let r = self.rects(area, &theme, ctx.text);
164
+
165
+ ctx.scene
166
+ .fill_rounded_rect(area, theme.radius.medium, accent.scale_alpha(0.14));
167
+ ctx.scene
168
+ .stroke_rounded_rect(area, theme.radius.medium, 1.0, accent.scale_alpha(0.35));
169
+
170
+ // Ikona rodzaju.
171
+ ctx.label_center(
172
+ r.icon.center().x,
173
+ r.icon.center().y,
174
+ self.glyph(),
175
+ theme.typography.body.size,
176
+ weight::BOLD,
177
+ accent,
178
+ );
179
+
180
+ ctx.label_left(
181
+ r.message.x(),
182
+ r.message.center().y,
183
+ &self.message,
184
+ theme.typography.body.size,
185
+ weight::MEDIUM,
186
+ theme.colors.text,
187
+ );
188
+
189
+ for (label, rect) in self.actions.iter().zip(&r.actions) {
190
+ ctx.scene
191
+ .fill_rounded_rect(*rect, theme.radius.small, accent);
192
+ ctx.label_center(
193
+ rect.center().x,
194
+ rect.center().y,
195
+ label,
196
+ theme.typography.body.size,
197
+ weight::MEDIUM,
198
+ theme.colors.accent_fg,
199
+ );
200
+ }
201
+
202
+ if let Some(close) = r.close {
203
+ ctx.scene
204
+ .fill_rounded_rect(close, theme.radius.small, theme.colors.shade);
205
+ ctx.label_center(
206
+ close.center().x,
207
+ close.center().y,
208
+ "✗",
209
+ theme.typography.caption.size,
210
+ weight::MEDIUM,
211
+ theme.colors.text,
212
+ );
213
+ }
214
+ }
215
+}
216
+
217
+#[cfg(test)]
218
+mod tests {
219
+ use super::*;
220
+ use libluna_render::{Scene, TextEngine};
221
+
222
+ fn area() -> Rect {
223
+ Rect::from_xywh(0.0, 0.0, 600.0, 56.0)
224
+ }
225
+
226
+ #[test]
227
+ fn layout_icon_left_actions_and_close_right() {
228
+ let theme = Theme::light();
229
+ let text = TextEngine::none();
230
+ let bar = InfoBar::new("Zapisano").action("Cofnij");
231
+ let r = bar.rects(area(), &theme, &text);
232
+ assert!(r.icon.x() < r.message.x());
233
+ assert!(r.close.is_some());
234
+ assert_eq!(r.actions.len(), 1);
235
+ assert!(r.actions[0].right() <= r.close.unwrap().x());
236
+ assert!(r.message.right() <= r.actions[0].x());
237
+ }
238
+
239
+ #[test]
240
+ fn hit_tests_for_action_and_close() {
241
+ let theme = Theme::light();
242
+ let text = TextEngine::none();
243
+ let bar = InfoBar::new("Pytanie?")
244
+ .kind(InfoBarKind::Question)
245
+ .action("Tak");
246
+ let r = bar.rects(area(), &theme, &text);
247
+ assert_eq!(
248
+ bar.action_at(area(), &theme, &text, r.actions[0].center()),
249
+ Some(0)
250
+ );
251
+ assert!(bar.close_at(area(), &theme, &text, r.close.unwrap().center()));
252
+ assert!(!bar.close_at(area(), &theme, &text, Point::new(2.0, 2.0)));
253
+ }
254
+
255
+ #[test]
256
+ fn close_can_be_hidden() {
257
+ let theme = Theme::light();
258
+ let text = TextEngine::none();
259
+ let bar = InfoBar::new("Info").show_close(false);
260
+ assert!(bar.rects(area(), &theme, &text).close.is_none());
261
+ }
262
+
263
+ #[test]
264
+ fn colors_per_kind() {
265
+ let theme = Theme::light();
266
+ assert_eq!(InfoBar::new("x").color(&theme), theme.colors.accent);
267
+ assert_eq!(
268
+ InfoBar::new("x").kind(InfoBarKind::Error).color(&theme),
269
+ theme.colors.error
270
+ );
271
+ }
272
+
273
+ #[test]
274
+ fn paints_without_window() {
275
+ let theme = Theme::dark();
276
+ let text = TextEngine::none();
277
+ let mut scene = Scene::new();
278
+ let mut ctx = PaintCtx::new(&mut scene, &theme, &text);
279
+ InfoBar::new("Zapisano zmiany")
280
+ .action("Cofnij")
281
+ .paint(&mut ctx, area());
282
+ assert!(scene.len() > 5);
283
+ }
284
+}
@@ -99,8 +99,10 @@ pub mod header_bar;
99
99
pub mod helpers;
100
100
pub mod icon;
101
101
pub mod image;
102
+pub mod infobar;
102
103
pub mod item_view;
103
104
pub mod layout;
105
+pub mod media_controls;
104
106
pub mod menu;
105
107
pub mod menu_button;
106
108
pub mod nav_list;
@@ -108,6 +110,7 @@ pub mod navigation;
108
110
pub mod navigation_rail;
109
111
pub mod pager;
110
112
pub mod path_bar;
113
+pub mod picture;
111
114
pub mod popover;
112
115
pub mod preferences;
113
116
pub mod radio_group;
@@ -170,8 +173,10 @@ pub use helpers::{
170
173
WindowTitleRects, WrapBox,
171
174
};
172
175
pub use image::{Image, ImageIcon};
176
+pub use infobar::{InfoBar, InfoBarKind, InfoBarRects};
173
177
pub use item_view::{Item, ItemKind, ItemView, ViewMode};
174
178
pub use layout::{Constraints, Layout, LayoutCtx, Node};
179
+pub use media_controls::{MediaControls, MediaRects};
175
180
pub use menu::{Menu, MenuEntry, MenuRow};
176
181
pub use menu_button::MenuButton;
177
182
/// `ListBox` — nazwa [`NavList`] w duchu GTK4.
@@ -183,6 +188,7 @@ pub use pager::Pager;
183
188
/// `Carousel` — nazwa [`Pager`] w duchu libadwaita.
184
189
pub use pager::Pager as Carousel;
185
190
pub use path_bar::PathBar;
191
+pub use picture::{ContentFit, Picture};
186
192
pub use popover::{Popover, PopoverPlacement};
187
193
pub use preferences::{PreferencesGroup, PreferencesPage};
188
194
pub use radio_group::RadioGroup;
189
195
new file mode 100644
@@ -0,0 +1,292 @@
1
+//! `MediaControls` — kontrolki odtwarzania (odpowiednik GTK4 `GtkMediaControls`).
2
+//!
3
+//! Widget immediate: [`MediaControls::rects`] liczy rozmieszczenie (przycisk play/pauza,
4
+//! tytuł/artysta, pasek postępu z uchwytem, czasy), [`MediaControls::seek_at`] tłumaczy
5
+//! klik na pozycję 0..1, a [`MediaControls::paint`] rysuje całość.
6
+
7
+use libluna_core::style::weight;
8
+use libluna_core::{Insets, Point, Rect, Theme};
9
+use libluna_render::TextEngine;
10
+
11
+use crate::ctx::PaintCtx;
12
+
13
+/// Szerokość paska postępu.
14
+const SEEK_W: f64 = 180.0;
15
+
16
+/// Kontrolki odtwarzania.
17
+#[derive(Debug, Clone, Default)]
18
+pub struct MediaControls {
19
+ playing: bool,
20
+ position: f64,
21
+ title: String,
22
+ artist: String,
23
+ elapsed: String,
24
+ total: String,
25
+}
26
+
27
+/// Rozmieszczenie elementów.
28
+#[derive(Debug, Clone, PartialEq)]
29
+pub struct MediaRects {
30
+ pub play: Rect,
31
+ pub title: Rect,
32
+ pub artist: Rect,
33
+ pub seek: Rect,
34
+ pub knob: Rect,
35
+ pub elapsed: Rect,
36
+ pub total: Rect,
37
+}
38
+
39
+impl MediaControls {
40
+ pub fn new(title: impl Into<String>, artist: impl Into<String>) -> Self {
41
+ Self {
42
+ playing: true,
43
+ position: 0.0,
44
+ title: title.into(),
45
+ artist: artist.into(),
46
+ elapsed: "0:00".to_owned(),
47
+ total: "0:00".to_owned(),
48
+ }
49
+ }
50
+
51
+ pub fn playing(&self) -> bool {
52
+ self.playing
53
+ }
54
+
55
+ pub fn set_playing(&mut self, playing: bool) {
56
+ self.playing = playing;
57
+ }
58
+
59
+ pub fn toggle(&mut self) {
60
+ self.playing = !self.playing;
61
+ }
62
+
63
+ /// Pozycja 0..1.
64
+ pub fn position(&self) -> f64 {
65
+ self.position
66
+ }
67
+
68
+ pub fn set_position(&mut self, position: f64) {
69
+ self.position = position.clamp(0.0, 1.0);
70
+ }
71
+
72
+ pub fn set_times(&mut self, elapsed: impl Into<String>, total: impl Into<String>) {
73
+ self.elapsed = elapsed.into();
74
+ self.total = total.into();
75
+ }
76
+
77
+ pub fn height(&self, theme: &Theme) -> f64 {
78
+ theme.metrics.banner.min_height
79
+ }
80
+
81
+ fn glyph(&self) -> &'static str {
82
+ if self.playing {
83
+ "▮▮"
84
+ } else {
85
+ "▶"
86
+ }
87
+ }
88
+
89
+ pub fn rects(&self, area: Rect, theme: &Theme, text: &TextEngine) -> MediaRects {
90
+ let m = theme.metrics.banner;
91
+ let body = theme.typography.body;
92
+ let caption = theme.typography.caption.size;
93
+ let inner = area.deflate(Insets::splat(m.pad));
94
+ let cy = inner.center().y;
95
+
96
+ let play = Rect::from_xywh(inner.x(), cy - m.button_h / 2.0, m.button_h, m.button_h);
97
+
98
+ // Prawa strona: total | seek | elapsed.
99
+ let total_w = text
100
+ .measure(&self.total, caption, weight::NORMAL)
101
+ .width
102
+ .max(28.0);
103
+ let total = Rect::from_xywh(
104
+ inner.right() - total_w,
105
+ cy - caption,
106
+ total_w,
107
+ caption * 2.0,
108
+ );
109
+ let seek = Rect::from_xywh(total.x() - m.gap - SEEK_W, cy - 3.0, SEEK_W, 6.0);
110
+ let elapsed_w = text
111
+ .measure(&self.elapsed, caption, weight::NORMAL)
112
+ .width
113
+ .max(28.0);
114
+ let elapsed = Rect::from_xywh(
115
+ seek.x() - m.gap - elapsed_w,
116
+ cy - caption,
117
+ elapsed_w,
118
+ caption * 2.0,
119
+ );
120
+
121
+ let tx = play.right() + m.gap;
122
+ let tw = (elapsed.x() - m.gap - tx).max(0.0);
123
+ let title = Rect::from_xywh(tx, cy - body.line_height, tw, body.line_height);
124
+ let artist = Rect::from_xywh(tx, cy, tw, body.line_height);
125
+
126
+ let knob = Rect::from_xywh(
127
+ seek.x() + seek.width() * self.position - 5.0,
128
+ cy - 5.0,
129
+ 10.0,
130
+ 10.0,
131
+ );
132
+
133
+ MediaRects {
134
+ play,
135
+ title,
136
+ artist,
137
+ seek,
138
+ knob,
139
+ elapsed,
140
+ total,
141
+ }
142
+ }
143
+
144
+ /// Czy kliknięto przycisk play/pauza.
145
+ pub fn play_at(&self, area: Rect, theme: &Theme, text: &TextEngine, p: Point) -> bool {
146
+ self.rects(area, theme, text).play.contains(p)
147
+ }
148
+
149
+ /// Pozycja 0..1 odpowiadająca kliknięciu na pasku postępu (jeśli trafiono).
150
+ pub fn seek_at(&self, area: Rect, theme: &Theme, text: &TextEngine, p: Point) -> Option<f64> {
151
+ let r = self.rects(area, theme, text);
152
+ // Powiększamy obszar pionowo dla wygody klikania.
153
+ let hit = Rect::from_xywh(r.seek.x(), r.seek.center().y - 12.0, r.seek.width(), 24.0);
154
+ if !hit.contains(p) {
155
+ return None;
156
+ }
157
+ Some(((p.x - r.seek.x()) / r.seek.width()).clamp(0.0, 1.0))
158
+ }
159
+
160
+ pub fn paint(&self, ctx: &mut PaintCtx, area: Rect) {
161
+ let theme = *ctx.theme;
162
+ let r = self.rects(area, &theme, ctx.text);
163
+
164
+ // Tło pigułki.
165
+ ctx.scene
166
+ .fill_rounded_rect(area, theme.radius.medium, theme.colors.card_bg);
167
+ ctx.scene
168
+ .stroke_rounded_rect(area, theme.radius.medium, 1.0, theme.colors.border);
169
+
170
+ // Przycisk play/pauza.
171
+ ctx.scene
172
+ .fill_rounded_rect(r.play, r.play.width() / 2.0, theme.colors.accent);
173
+ ctx.label_center(
174
+ r.play.center().x,
175
+ r.play.center().y,
176
+ self.glyph(),
177
+ theme.typography.caption.size,
178
+ weight::BOLD,
179
+ theme.colors.accent_fg,
180
+ );
181
+
182
+ // Tytuł / artysta.
183
+ ctx.label_left(
184
+ r.title.x(),
185
+ r.title.center().y,
186
+ &self.title,
187
+ theme.typography.body.size,
188
+ weight::MEDIUM,
189
+ theme.colors.text,
190
+ );
191
+ ctx.label_left(
192
+ r.artist.x(),
193
+ r.artist.center().y,
194
+ &self.artist,
195
+ theme.typography.caption.size,
196
+ weight::NORMAL,
197
+ theme.colors.dim_text,
198
+ );
199
+
200
+ // Czasy.
201
+ ctx.label_left(
202
+ r.elapsed.x(),
203
+ r.elapsed.center().y,
204
+ &self.elapsed,
205
+ theme.typography.caption.size,
206
+ weight::NORMAL,
207
+ theme.colors.dim_text,
208
+ );
209
+ ctx.label_right(
210
+ r.total.right(),
211
+ r.total.center().y,
212
+ &self.total,
213
+ theme.typography.caption.size,
214
+ weight::NORMAL,
215
+ theme.colors.dim_text,
216
+ );
217
+
218
+ // Pasek postępu + uchwyt.
219
+ ctx.scene.fill_rounded_rect(r.seek, 3.0, theme.colors.shade);
220
+ let fill = Rect::from_xywh(
221
+ r.seek.x(),
222
+ r.seek.y(),
223
+ r.seek.width() * self.position,
224
+ r.seek.height(),
225
+ );
226
+ ctx.scene.fill_rounded_rect(fill, 3.0, theme.colors.accent);
227
+ ctx.scene
228
+ .fill_rounded_rect(r.knob, 5.0, theme.colors.accent);
229
+ }
230
+}
231
+
232
+#[cfg(test)]
233
+mod tests {
234
+ use super::*;
235
+ use libluna_render::{Scene, TextEngine};
236
+
237
+ fn area() -> Rect {
238
+ Rect::from_xywh(0.0, 0.0, 520.0, 56.0)
239
+ }
240
+
241
+ #[test]
242
+ fn layout_puts_play_left_and_times_right() {
243
+ let theme = Theme::light();
244
+ let text = TextEngine::none();
245
+ let mc = MediaControls::new("Utwór", "Wykonawca");
246
+ let r = mc.rects(area(), &theme, &text);
247
+ assert!(r.play.x() < r.title.x());
248
+ assert!(r.title.x() < r.seek.x());
249
+ assert!(r.elapsed.right() <= r.seek.x());
250
+ assert!(r.total.right() <= area().right());
251
+ }
252
+
253
+ #[test]
254
+ fn seek_hit_maps_to_position() {
255
+ let theme = Theme::light();
256
+ let text = TextEngine::none();
257
+ let mc = MediaControls::new("T", "A");
258
+ let r = mc.rects(area(), &theme, &text);
259
+ let mid = Point::new(r.seek.center().x, r.seek.center().y);
260
+ let p = mc.seek_at(area(), &theme, &text, mid).unwrap();
261
+ assert!((p - 0.5).abs() < 0.02);
262
+ assert!(mc
263
+ .seek_at(
264
+ area(),
265
+ &theme,
266
+ &text,
267
+ Point::new(r.play.center().x, r.play.center().y)
268
+ )
269
+ .is_none());
270
+ }
271
+
272
+ #[test]
273
+ fn play_hit_and_toggle() {
274
+ let theme = Theme::light();
275
+ let text = TextEngine::none();
276
+ let mut mc = MediaControls::new("T", "A");
277
+ let r = mc.rects(area(), &theme, &text);
278
+ assert!(mc.play_at(area(), &theme, &text, r.play.center()));
279
+ mc.toggle();
280
+ assert!(!mc.playing());
281
+ }
282
+
283
+ #[test]
284
+ fn paints_without_window() {
285
+ let theme = Theme::dark();
286
+ let text = TextEngine::none();
287
+ let mut scene = Scene::new();
288
+ let mut ctx = PaintCtx::new(&mut scene, &theme, &text);
289
+ MediaControls::new("Utwór", "Wykonawca").paint(&mut ctx, area());
290
+ assert!(scene.len() > 8);
291
+ }
292
+}
0
293
new file mode 100644
@@ -0,0 +1,171 @@
1
+//! `Picture` — obraz z dopasowaniem treści (odpowiednik GTK4 `GtkPicture`).
2
+//!
3
+//! Widget immediate: [`Picture::dest_rect`] liczy prostokąt docelowy z zachowaniem
4
+//! proporcji wg [`ContentFit`], a [`Picture::paint`] rysuje obraz (przy `Cover` przycina
5
+//! do obszaru).
6
+
7
+use std::sync::Arc;
8
+
9
+use libluna_core::Rect;
10
+use libluna_render::ImageData;
11
+
12
+use crate::ctx::PaintCtx;
13
+
14
+/// Sposób dopasowania obrazu do obszaru.
15
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16
+pub enum ContentFit {
17
+ /// Rozciągnij (ignoruje proporcje).
18
+ Fill,
19
+ /// Zmieść cały obraz (zachowuje proporcje, może zostawić pasy).
20
+ #[default]
21
+ Contain,
22
+ /// Wypełnij obszar (zachowuje proporcje, przycina nadmiar).
23
+ Cover,
24
+ /// Dopasuj szerokość.
25
+ FitWidth,
26
+ /// Dopasuj wysokość.
27
+ FitHeight,
28
+}
29
+
30
+/// Obraz z dopasowaniem.
31
+#[derive(Debug, Clone, Default)]
32
+pub struct Picture {
33
+ image: Option<Arc<ImageData>>,
34
+ fit: ContentFit,
35
+}
36
+
37
+impl Picture {
38
+ pub fn new() -> Self {
39
+ Self::default()
40
+ }
41
+
42
+ /// Obraz z domyślnym dopasowaniem `Contain`.
43
+ pub fn with_image(image: Arc<ImageData>) -> Self {
44
+ Self {
45
+ image: Some(image),
46
+ fit: ContentFit::Contain,
47
+ }
48
+ }
49
+
50
+ pub fn set_image(&mut self, image: Arc<ImageData>) {
51
+ self.image = Some(image);
52
+ }
53
+
54
+ pub fn set_fit(&mut self, fit: ContentFit) {
55
+ self.fit = fit;
56
+ }
57
+
58
+ pub fn image(&self) -> Option<&Arc<ImageData>> {
59
+ self.image.as_ref()
60
+ }
61
+
62
+ pub fn fit(&self) -> ContentFit {
63
+ self.fit
64
+ }
65
+
66
+ /// Prostokąt, w którym obraz zostanie narysowany (z zachowaniem proporcji wg `fit`).
67
+ pub fn dest_rect(&self, area: Rect) -> Rect {
68
+ let Some(img) = &self.image else {
69
+ return area;
70
+ };
71
+ let (iw, ih) = (img.width as f64, img.height as f64);
72
+ if iw <= 0.0 || ih <= 0.0 {
73
+ return area;
74
+ }
75
+ let (aw, ah) = (area.width(), area.height());
76
+ let c = area.center();
77
+ match self.fit {
78
+ ContentFit::Fill => area,
79
+ ContentFit::Contain | ContentFit::Cover => {
80
+ let scale = if self.fit == ContentFit::Contain {
81
+ (aw / iw).min(ah / ih)
82
+ } else {
83
+ (aw / iw).max(ah / ih)
84
+ };
85
+ let (w, h) = (iw * scale, ih * scale);
86
+ Rect::from_xywh(c.x - w / 2.0, c.y - h / 2.0, w, h)
87
+ }
88
+ ContentFit::FitWidth => {
89
+ let h = ih * (aw / iw);
90
+ Rect::from_xywh(area.x(), c.y - h / 2.0, aw, h)
91
+ }
92
+ ContentFit::FitHeight => {
93
+ let w = iw * (ah / ih);
94
+ Rect::from_xywh(c.x - w / 2.0, area.y(), w, ah)
95
+ }
96
+ }
97
+ }
98
+
99
+ /// Rysuje obraz w obszarze `area`.
100
+ pub fn paint(&self, ctx: &mut PaintCtx, area: Rect) {
101
+ let Some(img) = &self.image else {
102
+ return;
103
+ };
104
+ let r = self.dest_rect(area);
105
+ let clip = self.fit == ContentFit::Cover;
106
+ if clip {
107
+ ctx.scene.push_clip(area);
108
+ }
109
+ ctx.scene.image(r, img.clone());
110
+ if clip {
111
+ ctx.scene.pop_clip();
112
+ }
113
+ }
114
+}
115
+
116
+#[cfg(test)]
117
+mod tests {
118
+ use super::*;
119
+ use libluna_core::Theme;
120
+ use libluna_render::{Scene, TextEngine};
121
+
122
+ fn img(w: u32, h: u32) -> Arc<ImageData> {
123
+ Arc::new(ImageData::empty(w, h))
124
+ }
125
+
126
+ #[test]
127
+ fn fill_uses_whole_area() {
128
+ let p = Picture {
129
+ image: Some(img(100, 50)),
130
+ fit: ContentFit::Fill,
131
+ };
132
+ assert_eq!(
133
+ p.dest_rect(Rect::from_xywh(0.0, 0.0, 200.0, 200.0)).width(),
134
+ 200.0
135
+ );
136
+ }
137
+
138
+ #[test]
139
+ fn contain_preserves_aspect_and_fits_inside() {
140
+ let p = Picture {
141
+ image: Some(img(200, 100)), // 2:1
142
+ fit: ContentFit::Contain,
143
+ };
144
+ let area = Rect::from_xywh(0.0, 0.0, 200.0, 200.0);
145
+ let r = p.dest_rect(area);
146
+ assert!((r.width() / r.height() - 2.0).abs() < 1e-6);
147
+ assert!(r.width() <= area.width() + 1e-6 && r.height() <= area.height() + 1e-6);
148
+ }
149
+
150
+ #[test]
151
+ fn fit_width_matches_area_width() {
152
+ let p = Picture {
153
+ image: Some(img(200, 100)),
154
+ fit: ContentFit::FitWidth,
155
+ };
156
+ let area = Rect::from_xywh(0.0, 0.0, 300.0, 100.0);
157
+ let r = p.dest_rect(area);
158
+ assert!((r.width() - 300.0).abs() < 1e-6);
159
+ assert!((r.height() - 150.0).abs() < 1e-6);
160
+ }
161
+
162
+ #[test]
163
+ fn paints_without_window() {
164
+ let theme = Theme::light();
165
+ let text = TextEngine::none();
166
+ let mut scene = Scene::new();
167
+ let mut ctx = PaintCtx::new(&mut scene, &theme, &text);
168
+ Picture::with_image(img(32, 32)).paint(&mut ctx, Rect::from_xywh(0.0, 0.0, 64.0, 64.0));
169
+ assert_eq!(scene.len(), 1);
170
+ }
171
+}
0
172
new file mode 100644
1
173
Binary files /dev/null and b/docs/images/new-widgets.png differ
@@ -59,6 +59,9 @@ a na końcu odtworzył realne aplikacje środowiska Luna z makiet.
59
59
`Adjustment`, `ColorChooser`, `FlowBox`, `SpinButton`, `EmojiChooser`, `NavigationRail`,
60
60
`Viewport`, `WindowHandle`; aliasy GTK4 `ScrolledWindow` = `ScrollView`,
61
61
`StackSwitcher` = `ViewSwitcher`, `Notebook` = `TabView`, `ListBox` = `NavList`.
62
+ Doszły odpowiedniki GTK4: `InfoBar` (4 rodzaje + zamknięcie), `Picture` (`ContentFit`:
63
+ contain/cover/fill/fit-width/fit-height) i `MediaControls` (play/pauza + pasek postępu +
64
+ czasy); podgląd: `cargo run -p luna-demo --bin new-widgets` → `docs/images/new-widgets.png`.
62
65
Tokeny metryk kontenerów: `Theme::metrics.container`. Animacje: `libluna-core` `motion`
63
66
(`Easing`, `lerp`, `Animation`), użyte w `Stack` (przejścia `StackTransition`), `Revealer`,
64
67
`Toast`, `BottomSheet` i `Pager` (sterowane `advance(dt, …)`).