← luna

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, …)`).