← luna

Commit fb3c9c9

6
files
+673
added
-5
deleted
@@ -352,7 +352,8 @@ XDG_CONFIG_HOME="$PWD/examples" cargo run -p luna-demo --bin render -- --lang fr
352 352 podgląd „oko”) i `EditableLabel`; kontenery `CenterBox`, `Grid`, `Fixed`, `Paned`
353 353 (przeciągany separator), `Stack`, `ActionBar`, `PopoverMenuBar`; oraz `MenuButton`,
354 354 `SearchBar`, `Expander`, `Statusbar`, `Calendar`, `Image` (ikony wektorowe),
355 - `TextView` (pole wielolinijkowe); aliasy `ScrolledWindow` = `ScrollView`,
355 + `TextView` (pole wielolinijkowe), `Popover` (generyczny), `ColumnView` (tabela),
356 + `Assistant` (kreator kroków); aliasy `ScrolledWindow` = `ScrollView`,
356 357 `StackSwitcher` = `ViewSwitcher`, `Notebook` = `TabView`, `ListBox` = `NavList`.
357 358 - `libluna-widgets::layout`: **dwuprzebiegowy układ** (retencyjne drzewo `measure`/`allocate`) —
358 359 [`Node`] (`VBox`/`HBox`, `Padding`, `Expanded`, `Center`, `Spacer`, `Label`, `Fixed`),
359 360 new file mode 100644
@@ -0,0 +1,244 @@
1 +//! `Assistant` — kreator kroków (GTK4): nagłówek z tytułem i wskaźnikiem, treść, stopka z akcjami.
2 +//!
3 +//! Widget immediate: trzyma listę kroków i bieżący indeks. Rysuje chrome (nagłówek, kropki
4 +//! postępu, stopkę z przyciskami); treść kroku rysuje aplikacja w [`Assistant::content_rect`].
5 +
6 +use libluna_core::style::weight;
7 +use libluna_core::{Rect, Theme};
8 +use libluna_render::TextEngine;
9 +
10 +use crate::button::{Button, ButtonVariant};
11 +use crate::ctx::PaintCtx;
12 +
13 +/// Kreator kroków.
14 +#[derive(Debug, Clone, Default)]
15 +pub struct Assistant {
16 + steps: Vec<String>,
17 + current: usize,
18 +}
19 +
20 +impl Assistant {
21 + pub fn new<I, S>(steps: I) -> Self
22 + where
23 + I: IntoIterator<Item = S>,
24 + S: Into<String>,
25 + {
26 + Self {
27 + steps: steps.into_iter().map(Into::into).collect(),
28 + current: 0,
29 + }
30 + }
31 +
32 + pub fn count(&self) -> usize {
33 + self.steps.len()
34 + }
35 +
36 + pub fn current(&self) -> usize {
37 + self.current
38 + }
39 +
40 + pub fn title(&self) -> &str {
41 + self.steps
42 + .get(self.current)
43 + .map(String::as_str)
44 + .unwrap_or("")
45 + }
46 +
47 + pub fn set_current(&mut self, index: usize) {
48 + if !self.steps.is_empty() {
49 + self.current = index.min(self.steps.len() - 1);
50 + }
51 + }
52 +
53 + pub fn next(&mut self) {
54 + self.set_current(self.current + 1);
55 + }
56 +
57 + pub fn prev(&mut self) {
58 + self.current = self.current.saturating_sub(1);
59 + }
60 +
61 + pub fn is_first(&self) -> bool {
62 + self.current == 0
63 + }
64 +
65 + pub fn is_last(&self) -> bool {
66 + self.steps.is_empty() || self.current + 1 >= self.steps.len()
67 + }
68 +
69 + pub fn header_height(&self, theme: &Theme) -> f64 {
70 + theme.typography.body.line_height + theme.spacing.md * 2.0
71 + }
72 +
73 + pub fn footer_height(&self, theme: &Theme) -> f64 {
74 + theme.metrics.button.height + theme.spacing.xs * 2.0
75 + }
76 +
77 + /// Prostokąt nagłówka (górna część).
78 + pub fn header_rect(&self, rect: Rect, theme: &Theme) -> Rect {
79 + Rect::from_xywh(rect.x(), rect.y(), rect.width(), self.header_height(theme))
80 + }
81 +
82 + /// Prostokąt stopki (dolna część).
83 + pub fn footer_rect(&self, rect: Rect, theme: &Theme) -> Rect {
84 + let h = self.footer_height(theme);
85 + Rect::from_xywh(rect.x(), rect.bottom() - h, rect.width(), h)
86 + }
87 +
88 + /// Obszar treści kroku (między nagłówkiem i stopką).
89 + pub fn content_rect(&self, rect: Rect, theme: &Theme) -> Rect {
90 + let top = self.header_height(theme);
91 + let bottom = self.footer_height(theme);
92 + Rect::from_xywh(
93 + rect.x(),
94 + rect.y() + top,
95 + rect.width(),
96 + (rect.height() - top - bottom).max(0.0),
97 + )
98 + }
99 +
100 + /// Prostokąty kropek postępu (wyśrodkowane w nagłówku).
101 + pub fn indicator_rects(&self, rect: Rect, theme: &Theme) -> Vec<Rect> {
102 + let w = self.header_rect(rect, theme);
103 + let d = 8.0;
104 + let gap = theme.spacing.sm;
105 + let n = self.steps.len();
106 + if n == 0 {
107 + return Vec::new();
108 + }
109 + let total = n as f64 * d + (n as f64 - 1.0) * gap;
110 + let mut x = w.center().x - total / 2.0;
111 + (0..n)
112 + .map(|_| {
113 + let r = Rect::from_xywh(x, w.bottom() - theme.spacing.sm - d, d, d);
114 + x += d + gap;
115 + r
116 + })
117 + .collect()
118 + }
119 +
120 + /// Prostokąty `[anuluj, dalej/zakończ]` w stopce (wg szerokości przycisków).
121 + pub fn buttons(
122 + &self,
123 + rect: Rect,
124 + text: &TextEngine,
125 + theme: &Theme,
126 + cancel_label: &str,
127 + next_label: &str,
128 + ) -> [Rect; 2] {
129 + let f = self.footer_rect(rect, theme);
130 + let cy = f.center().y;
131 + let h = theme.metrics.button.height;
132 + let pad = theme.spacing.md;
133 + let next_w = Button::new(next_label).width(text, theme);
134 + let cancel_w = Button::new(cancel_label).width(text, theme);
135 + let next = Rect::from_xywh(f.right() - pad - next_w, cy - h / 2.0, next_w, h);
136 + let cancel = Rect::from_xywh(
137 + next.x() - theme.spacing.sm - cancel_w,
138 + cy - h / 2.0,
139 + cancel_w,
140 + h,
141 + );
142 + [cancel, next]
143 + }
144 +
145 + /// Rysuje chrome kreatora i przyciski stopki.
146 + pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect, cancel_label: &str, next_label: &str) {
147 + let theme = *ctx.theme;
148 + let header = self.header_rect(rect, &theme);
149 +
150 + // Tytuł kroku.
151 + ctx.label_center(
152 + header.center().x,
153 + header.y() + theme.spacing.md + theme.typography.body.line_height / 2.0,
154 + self.title(),
155 + theme.typography.body.size,
156 + weight::MEDIUM,
157 + theme.colors.text,
158 + );
159 +
160 + // Kropki postępu.
161 + for (i, dot) in self.indicator_rects(rect, &theme).iter().enumerate() {
162 + let color = if i == self.current {
163 + theme.colors.accent
164 + } else {
165 + theme.colors.border
166 + };
167 + ctx.scene.fill_rounded_rect(*dot, dot.width() / 2.0, color);
168 + }
169 +
170 + // Stopka.
171 + let footer = self.footer_rect(rect, &theme);
172 + ctx.scene.fill_rect(footer, theme.colors.window_bg);
173 + ctx.scene.fill_rect(
174 + Rect::from_xywh(footer.x(), footer.y(), footer.width(), 1.0),
175 + theme.colors.border,
176 + );
177 +
178 + let text = ctx.text;
179 + let [cancel, next] = self.buttons(rect, text, &theme, cancel_label, next_label);
180 + Button::new(cancel_label)
181 + .variant(ButtonVariant::Flat)
182 + .paint(ctx, cancel);
183 + Button::new(next_label)
184 + .variant(ButtonVariant::Suggested)
185 + .paint(ctx, next);
186 + }
187 +}
188 +
189 +#[cfg(test)]
190 +mod tests {
191 + use super::*;
192 +
193 + fn rect() -> Rect {
194 + Rect::from_xywh(0.0, 0.0, 400.0, 300.0)
195 + }
196 +
197 + #[test]
198 + fn navigation_is_clamped() {
199 + let mut a = Assistant::new(["Jeden", "Dwa", "Trzy"]);
200 + assert!(a.is_first());
201 + assert_eq!(a.title(), "Jeden");
202 + a.prev();
203 + assert_eq!(a.current(), 0);
204 + a.next();
205 + a.next();
206 + assert!(a.is_last());
207 + assert_eq!(a.title(), "Trzy");
208 + a.next();
209 + assert_eq!(a.current(), 2);
210 + }
211 +
212 + #[test]
213 + fn regions_stack_without_overlap() {
214 + let theme = Theme::light();
215 + let a = Assistant::new(["A", "B"]);
216 + let header = a.header_rect(rect(), &theme);
217 + let content = a.content_rect(rect(), &theme);
218 + let footer = a.footer_rect(rect(), &theme);
219 + assert_eq!(content.y(), header.bottom());
220 + assert_eq!(content.bottom(), footer.y());
221 + assert!((footer.bottom() - 300.0).abs() < 1e-9);
222 + }
223 +
224 + #[test]
225 + fn indicator_has_one_dot_per_step_centered() {
226 + let theme = Theme::light();
227 + let a = Assistant::new(["A", "B", "C"]);
228 + let dots = a.indicator_rects(rect(), &theme);
229 + assert_eq!(dots.len(), 3);
230 + let center = (dots[0].center().x + dots[2].center().x) / 2.0;
231 + assert!((center - 200.0).abs() < 1e-9);
232 + }
233 +
234 + #[test]
235 + fn buttons_sit_at_footer_right() {
236 + let theme = Theme::light();
237 + let text = TextEngine::system();
238 + let a = Assistant::new(["A"]);
239 + let [cancel, next] = a.buttons(rect(), &text, &theme, "Anuluj", "Dalej");
240 + assert!(next.right() <= 400.0);
241 + assert!(cancel.right() < next.x());
242 + assert_eq!(next.height(), theme.metrics.button.height);
243 + }
244 +}
0 245 new file mode 100644
@@ -0,0 +1,280 @@
1 +//! `ColumnView` — tabela z kolumnami i wierszami (GTK4).
2 +//!
3 +//! Widget immediate: trzyma definicje kolumn, liczbę wierszy i wybór. Rysuje nagłówek
4 +//! i siatkę; **treść komórek** rysuje aplikacja w prostokątach z [`ColumnView::cell_rect`].
5 +
6 +use libluna_core::style::weight;
7 +use libluna_core::{Point, Rect, Theme};
8 +
9 +use crate::ctx::PaintCtx;
10 +
11 +/// Definicja kolumny: tytuł i waga szerokości (udziały normalizowane do szerokości tabeli).
12 +#[derive(Debug, Clone, PartialEq)]
13 +pub struct Column {
14 + pub title: String,
15 + pub weight: f64,
16 +}
17 +
18 +impl Column {
19 + pub fn new(title: impl Into<String>) -> Self {
20 + Self {
21 + title: title.into(),
22 + weight: 1.0,
23 + }
24 + }
25 +
26 + /// Ustawia wagę szerokości kolumny.
27 + pub fn weight(mut self, weight: f64) -> Self {
28 + self.weight = weight.max(0.0);
29 + self
30 + }
31 +}
32 +
33 +/// Tabela (widok kolumnowy).
34 +#[derive(Debug, Clone, Default)]
35 +pub struct ColumnView {
36 + columns: Vec<Column>,
37 + rows: usize,
38 + selected: Option<usize>,
39 + hovered: Option<usize>,
40 +}
41 +
42 +impl ColumnView {
43 + pub fn new(columns: Vec<Column>) -> Self {
44 + Self {
45 + columns,
46 + ..Self::default()
47 + }
48 + }
49 +
50 + pub fn columns(&self) -> &[Column] {
51 + &self.columns
52 + }
53 +
54 + pub fn rows(&self) -> usize {
55 + self.rows
56 + }
57 +
58 + pub fn set_rows(&mut self, rows: usize) {
59 + self.rows = rows;
60 + if let Some(s) = self.selected {
61 + if s >= rows {
62 + self.selected = None;
63 + }
64 + }
65 + }
66 +
67 + pub fn selected(&self) -> Option<usize> {
68 + self.selected
69 + }
70 +
71 + pub fn set_selected(&mut self, index: Option<usize>) {
72 + self.selected = index.filter(|i| *i < self.rows);
73 + }
74 +
75 + pub fn set_hovered(&mut self, hovered: Option<usize>) {
76 + self.hovered = hovered.filter(|i| *i < self.rows);
77 + }
78 +
79 + pub fn header_height(&self, theme: &Theme) -> f64 {
80 + theme.metrics.button.height
81 + }
82 +
83 + pub fn row_height(&self, theme: &Theme) -> f64 {
84 + theme.typography.body.line_height + theme.spacing.md
85 + }
86 +
87 + /// Granice kolumn (współrzędna `x` i szerokość) rozłożone wg wag.
88 + fn spans(&self, rect: Rect) -> Vec<(f64, f64)> {
89 + let total: f64 = self.columns.iter().map(|c| c.weight).sum();
90 + if total <= 0.0 || self.columns.is_empty() {
91 + return vec![(rect.x(), 0.0); self.columns.len()];
92 + }
93 + let mut x = rect.x();
94 + self.columns
95 + .iter()
96 + .map(|c| {
97 + let w = rect.width() * (c.weight / total);
98 + let span = (x, w);
99 + x += w;
100 + span
101 + })
102 + .collect()
103 + }
104 +
105 + /// Prostokąty nagłówków kolumn.
106 + pub fn header_cells(&self, rect: Rect, theme: &Theme) -> Vec<Rect> {
107 + let h = self.header_height(theme);
108 + self.spans(rect)
109 + .into_iter()
110 + .map(|(x, w)| Rect::from_xywh(x, rect.y(), w, h))
111 + .collect()
112 + }
113 +
114 + /// Obszar wierszy (pod nagłówkiem).
115 + pub fn rows_rect(&self, rect: Rect, theme: &Theme) -> Rect {
116 + let h = self.header_height(theme);
117 + Rect::from_xywh(
118 + rect.x(),
119 + rect.y() + h,
120 + rect.width(),
121 + (rect.height() - h).max(0.0),
122 + )
123 + }
124 +
125 + /// Prostokąt wiersza `index`.
126 + pub fn row_rect(&self, rect: Rect, theme: &Theme, index: usize) -> Rect {
127 + let area = self.rows_rect(rect, theme);
128 + let rh = self.row_height(theme);
129 + Rect::from_xywh(area.x(), area.y() + index as f64 * rh, area.width(), rh)
130 + }
131 +
132 + /// Prostokąt komórki `(row, col)`.
133 + pub fn cell_rect(&self, rect: Rect, theme: &Theme, row: usize, col: usize) -> Rect {
134 + let r = self.row_rect(rect, theme, row);
135 + match self.spans(rect).get(col) {
136 + Some(&(x, w)) => Rect::from_xywh(x, r.y(), w, r.height()),
137 + None => Rect::from_xywh(rect.x(), r.y(), 0.0, r.height()),
138 + }
139 + }
140 +
141 + /// Indeks wiersza pod punktem `p` (tylko w obszarze wierszy).
142 + pub fn row_at(&self, rect: Rect, theme: &Theme, p: Point) -> Option<usize> {
143 + let area = self.rows_rect(rect, theme);
144 + if !area.contains(p) {
145 + return None;
146 + }
147 + let rh = self.row_height(theme);
148 + if rh <= 0.0 {
149 + return None;
150 + }
151 + let idx = ((p.y - area.y()) / rh) as usize;
152 + if idx < self.rows {
153 + Some(idx)
154 + } else {
155 + None
156 + }
157 + }
158 +
159 + /// Rysuje nagłówek (tło, tytuły, separator dolny i pionowe) oraz zaznaczony wiersz.
160 + pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
161 + let theme = *ctx.theme;
162 + let header = self.header_cells(rect, &theme);
163 + let hh = self.header_height(&theme);
164 + let header_bg = Rect::from_xywh(rect.x(), rect.y(), rect.width(), hh);
165 + ctx.scene.fill_rect(header_bg, theme.colors.shade);
166 +
167 + let body = theme.typography.body.size;
168 + for (i, cell) in header.iter().enumerate() {
169 + ctx.label_left(
170 + cell.x() + theme.spacing.md,
171 + cell.center().y,
172 + &self.columns[i].title,
173 + body,
174 + weight::MEDIUM,
175 + theme.colors.dim_text,
176 + );
177 + if i > 0 {
178 + let line = Rect::from_xywh(cell.x(), cell.y(), 1.0, cell.height());
179 + ctx.scene.fill_rect(line, theme.colors.border);
180 + }
181 + }
182 + // Dolna krawędź nagłówka.
183 + ctx.scene.fill_rect(
184 + Rect::from_xywh(rect.x(), rect.y() + hh - 1.0, rect.width(), 1.0),
185 + theme.colors.border,
186 + );
187 +
188 + // Zaznaczony / podświetlony wiersz.
189 + let area = self.rows_rect(rect, &theme);
190 + for i in 0..self.rows {
191 + let row = self.row_rect(rect, &theme, i);
192 + if row.y() >= area.bottom() {
193 + break;
194 + }
195 + if self.selected == Some(i) {
196 + ctx.scene
197 + .fill_rect(row, theme.colors.accent.scale_alpha(0.18));
198 + } else if self.hovered == Some(i) {
199 + ctx.scene.fill_rect(row, theme.colors.shade);
200 + }
201 + // Separator między wierszami.
202 + ctx.scene.fill_rect(
203 + Rect::from_xywh(row.x(), row.bottom() - 1.0, row.width(), 1.0),
204 + theme.colors.border.scale_alpha(0.5),
205 + );
206 + }
207 + }
208 +}
209 +
210 +#[cfg(test)]
211 +mod tests {
212 + use super::*;
213 +
214 + fn view() -> ColumnView {
215 + ColumnView::new(vec![
216 + Column::new("Nazwa").weight(2.0),
217 + Column::new("Rozmiar"),
218 + Column::new("Data"),
219 + ])
220 + }
221 +
222 + fn rect() -> Rect {
223 + Rect::from_xywh(0.0, 0.0, 400.0, 300.0)
224 + }
225 +
226 + #[test]
227 + fn columns_split_by_weight() {
228 + let theme = Theme::light();
229 + let v = view();
230 + let cells = v.header_cells(rect(), &theme);
231 + assert_eq!(cells.len(), 3);
232 + // Waga 2 z sumy 4 → połowa szerokości.
233 + assert!((cells[0].width() - 200.0).abs() < 1e-9);
234 + assert!((cells[1].width() - 100.0).abs() < 1e-9);
235 + assert!((cells[2].right() - 400.0).abs() < 1e-9);
236 + }
237 +
238 + #[test]
239 + fn rows_start_below_header() {
240 + let theme = Theme::light();
241 + let v = view();
242 + let area = v.rows_rect(rect(), &theme);
243 + assert_eq!(area.y(), v.header_height(&theme));
244 + assert_eq!(area.height(), 300.0 - v.header_height(&theme));
245 + }
246 +
247 + #[test]
248 + fn row_at_maps_points_and_ignores_header() {
249 + let theme = Theme::light();
250 + let mut v = view();
251 + v.set_rows(5);
252 + let r1 = v.row_rect(rect(), &theme, 2);
253 + assert_eq!(v.row_at(rect(), &theme, r1.center()), Some(2));
254 + // W nagłówku nie ma wiersza.
255 + assert_eq!(v.row_at(rect(), &theme, Point::new(10.0, 5.0)), None);
256 + }
257 +
258 + #[test]
259 + fn selection_is_clamped_to_rows() {
260 + let mut v = view();
261 + v.set_rows(3);
262 + v.set_selected(Some(2));
263 + assert_eq!(v.selected(), Some(2));
264 + v.set_rows(1);
265 + assert_eq!(v.selected(), None);
266 + v.set_selected(Some(9));
267 + assert_eq!(v.selected(), None);
268 + }
269 +
270 + #[test]
271 + fn cell_rect_matches_row_and_column() {
272 + let theme = Theme::light();
273 + let v = view();
274 + let cell = v.cell_rect(rect(), &theme, 1, 2);
275 + let row = v.row_rect(rect(), &theme, 1);
276 + assert_eq!(cell.y(), row.y());
277 + assert_eq!(cell.height(), row.height());
278 + assert!(cell.right() <= 400.0 + 1e-9);
279 + }
280 +}
@@ -51,6 +51,7 @@
51 51 //! kontenery GTK4 (`Box` = stos drzewa układu: [`Node::hbox`]/[`Node::vbox`]).
52 52 //! - [`MenuButton`], [`SearchBar`], [`Expander`], [`Statusbar`], [`Calendar`] — kolejne widgety GTK4.
53 53 //! - [`Image`], [`TextView`] — obrazek (ikony wektorowe) i pole wielolinijkowe (GTK4).
54 +//! - [`Popover`], [`ColumnView`], [`Assistant`] — generyczny popover, tabela i kreator kroków (GTK4).
54 55 //! - Aliasy w duchu GTK4: [`Carousel`] = [`Pager`], [`ScrolledWindow`] = [`ScrollView`],
55 56 //! [`StackSwitcher`] = [`ViewSwitcher`], [`Notebook`] = [`TabView`], [`ListBox`] = [`NavList`].
56 57 //! - [`Carousel`] (alias [`Pager`]), [`ToolbarView`], [`WindowTitle`], [`Clamp`], [`WrapBox`],
@@ -67,6 +68,7 @@
67 68 //! ```
68 69
69 70 pub mod action_row;
71 +pub mod assistant;
70 72 pub mod avatar;
71 73 pub mod banner;
72 74 pub mod bin;
@@ -76,6 +78,7 @@ pub mod buttons;
76 78 pub mod calendar;
77 79 pub mod card;
78 80 pub mod check_button;
81 +pub mod column_view;
79 82 pub mod containers;
80 83 pub mod ctx;
81 84 pub mod dialogs;
@@ -96,6 +99,7 @@ pub mod nav_list;
96 99 pub mod navigation;
97 100 pub mod pager;
98 101 pub mod path_bar;
102 +pub mod popover;
99 103 pub mod preferences;
100 104 pub mod radio_group;
101 105 pub mod rows;
@@ -119,6 +123,7 @@ pub mod tooltip;
119 123 pub mod view_switcher;
120 124
121 125 pub use action_row::ActionRow;
126 +pub use assistant::Assistant;
122 127 pub use avatar::Avatar;
123 128 pub use banner::{Banner, BannerKind, BannerRects};
124 129 pub use bin::{Bin, BinAlign};
@@ -128,6 +133,7 @@ pub use buttons::{ColorButton, LinkButton, ToggleButton};
128 133 pub use calendar::Calendar;
129 134 pub use card::Card;
130 135 pub use check_button::CheckButton;
136 +pub use column_view::{Column, ColumnView};
131 137 pub use containers::{ActionBar, CenterBox, Fixed, Grid, Paned, PopoverMenuBar, Stack};
132 138 pub use ctx::PaintCtx;
133 139 pub use dialogs::{
@@ -159,6 +165,7 @@ pub use pager::Pager;
159 165 /// `Carousel` — nazwa [`Pager`] w duchu libadwaita.
160 166 pub use pager::Pager as Carousel;
161 167 pub use path_bar::PathBar;
168 +pub use popover::{Popover, PopoverPlacement};
162 169 pub use preferences::{PreferencesGroup, PreferencesPage};
163 170 pub use radio_group::RadioGroup;
164 171 pub use rows::{Row, RowRects, RowSuffix};
165 172 new file mode 100644
@@ -0,0 +1,136 @@
1 +//! `Popover` — generyczny panel unoszący się nad kotwicą (GTK4).
2 +//!
3 +//! W odróżnieniu od [`Menu`](crate::Menu) (gotowy popover z listą pozycji), `Popover` daje
4 +//! samo **umiejscowienie i chrome**: cztery kierunki (`Below`/`Above`/`Right`/`Left`),
5 +//! przycinanie do widoku i zamykanie po kliknięciu poza. Treść rysuje aplikacja.
6 +
7 +use libluna_core::{Point, Rect, Size, Theme};
8 +
9 +use crate::ctx::PaintCtx;
10 +
11 +/// Kierunek osadzenia popovera względem kotwicy.
12 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13 +pub enum PopoverPlacement {
14 + #[default]
15 + Below,
16 + Above,
17 + Right,
18 + Left,
19 +}
20 +
21 +/// Panel unoszący się nad kotwicą.
22 +#[derive(Debug, Clone, Copy, Default)]
23 +pub struct Popover {
24 + placement: PopoverPlacement,
25 + open: bool,
26 +}
27 +
28 +impl Popover {
29 + pub fn new(placement: PopoverPlacement) -> Self {
30 + Self {
31 + placement,
32 + open: false,
33 + }
34 + }
35 +
36 + pub fn placement(&self) -> PopoverPlacement {
37 + self.placement
38 + }
39 +
40 + pub fn is_open(&self) -> bool {
41 + self.open
42 + }
43 +
44 + pub fn set_open(&mut self, open: bool) {
45 + self.open = open;
46 + }
47 +
48 + /// Przełącza stan; zwraca nowy stan.
49 + pub fn toggle(&mut self) -> bool {
50 + self.open = !self.open;
51 + self.open
52 + }
53 +
54 + pub fn close(&mut self) {
55 + self.open = false;
56 + }
57 +
58 + /// Umieszcza panel o rozmiarze `size` przy `anchor`, przycinając do `viewport`.
59 + pub fn place(&self, anchor: Rect, size: Size, viewport: Rect, theme: &Theme) -> Rect {
60 + let margin = theme.spacing.sm;
61 + let gap = theme.spacing.xs.max(6.0);
62 + let (mut x, mut y) = match self.placement {
63 + PopoverPlacement::Below => (anchor.x(), anchor.bottom() + gap),
64 + PopoverPlacement::Above => (anchor.x(), anchor.y() - size.height - gap),
65 + PopoverPlacement::Right => (anchor.right() + gap, anchor.y()),
66 + PopoverPlacement::Left => (anchor.x() - size.width - gap, anchor.y()),
67 + };
68 + let max_x = (viewport.right() - margin - size.width).max(viewport.x() + margin);
69 + let max_y = (viewport.bottom() - margin - size.height).max(viewport.y() + margin);
70 + x = x.clamp(viewport.x() + margin, max_x);
71 + y = y.clamp(viewport.y() + margin, max_y);
72 + Rect::from_xywh(x, y, size.width, size.height)
73 + }
74 +
75 + /// Czy klik w `p` powinien zamknąć popover (poza panelem i poza kotwicą).
76 + pub fn dismiss_at(&self, panel: Rect, anchor: Rect, p: Point) -> bool {
77 + !panel.contains(p) && !anchor.contains(p)
78 + }
79 +
80 + /// Rysuje chrome panelu (tło karty + obramowanie).
81 + pub fn paint(&self, ctx: &mut PaintCtx, panel: Rect) {
82 + let theme = *ctx.theme;
83 + ctx.scene
84 + .fill_rounded_rect(panel, theme.radius.medium, theme.colors.card_bg);
85 + ctx.scene
86 + .stroke_rounded_rect(panel, theme.radius.medium, 1.0, theme.colors.border);
87 + }
88 +}
89 +
90 +#[cfg(test)]
91 +mod tests {
92 + use super::*;
93 +
94 + fn viewport() -> Rect {
95 + Rect::from_xywh(0.0, 0.0, 400.0, 300.0)
96 + }
97 +
98 + #[test]
99 + fn places_below_anchor_by_default() {
100 + let theme = Theme::light();
101 + let p = Popover::new(PopoverPlacement::Below);
102 + let anchor = Rect::from_xywh(50.0, 40.0, 30.0, 20.0);
103 + let panel = p.place(anchor, Size::new(120.0, 80.0), viewport(), &theme);
104 + assert_eq!(panel.x(), 50.0);
105 + assert!(panel.y() >= anchor.bottom());
106 + }
107 +
108 + #[test]
109 + fn flips_and_clamps_near_edges() {
110 + let theme = Theme::light();
111 + let p = Popover::new(PopoverPlacement::Below);
112 + let anchor = Rect::from_xywh(380.0, 290.0, 20.0, 20.0);
113 + let panel = p.place(anchor, Size::new(200.0, 120.0), viewport(), &theme);
114 + assert!(panel.right() <= viewport().right() - theme.spacing.sm + 1e-6);
115 + assert!(panel.bottom() <= viewport().bottom() - theme.spacing.sm + 1e-6);
116 + assert!(panel.x() >= viewport().x() + theme.spacing.sm);
117 + }
118 +
119 + #[test]
120 + fn dismiss_outside_panel_and_anchor() {
121 + let p = Popover::new(PopoverPlacement::Below);
122 + let panel = Rect::from_xywh(50.0, 70.0, 100.0, 80.0);
123 + let anchor = Rect::from_xywh(50.0, 40.0, 30.0, 20.0);
124 + assert!(!p.dismiss_at(panel, anchor, panel.center()));
125 + assert!(!p.dismiss_at(panel, anchor, anchor.center()));
126 + assert!(p.dismiss_at(panel, anchor, Point::new(390.0, 10.0)));
127 + }
128 +
129 + #[test]
130 + fn toggle_flips_open_state() {
131 + let mut p = Popover::new(PopoverPlacement::Above);
132 + assert!(!p.is_open());
133 + assert!(p.toggle());
134 + assert!(!p.toggle());
135 + }
136 +}
@@ -50,14 +50,14 @@ a na końcu odtworzył realne aplikacje środowiska Luna z makiet.
50 50 `PreferencesPage`; `Dialog` + `AlertDialog`/`AboutDialog`/`ShortcutsDialog`/`PreferencesDialog`;
51 51 `NavigationView`/`NavigationSplitView`/`OverlaySplitView`/`Sidebar` (`Carousel` = `Pager`);
52 52 `ToolbarView`, `WindowTitle`, `Clamp`, `WrapBox`, `BreakpointBin`, `ButtonContent`.
53 -- **Galeria GTK4 pokrywana partiami** (partia 5–8): prezentacja `Label`, `Separator`,
53 +- **Galeria GTK4 pokrywana partiami** (partia 5–9): prezentacja `Label`, `Separator`,
54 54 `ProgressBar`, `LevelBar`, `Frame`, `Revealer`, `Overlay`, `AspectFrame`; przyciski
55 55 `ToggleButton`, `LinkButton`, `ColorButton`; pola specjalne `PasswordEntry` (maskowanie +
56 56 „oko”) i `EditableLabel`; kontenery `CenterBox`, `Grid`, `Fixed`, `Paned`, `Stack`,
57 57 `ActionBar`, `PopoverMenuBar`; dalej `MenuButton`, `SearchBar`, `Expander`, `Statusbar`,
58 - `Calendar`, `Image`, `TextView`; aliasy GTK4 `ScrolledWindow` = `ScrollView`,
59 - `StackSwitcher` = `ViewSwitcher`, `Notebook` = `TabView`, `ListBox` = `NavList`.
60 - Tokeny metryk kontenerów: `Theme::metrics.container`.
58 + `Calendar`, `Image`, `TextView`, `Popover`, `ColumnView`, `Assistant`; aliasy GTK4
59 + `ScrolledWindow` = `ScrollView`, `StackSwitcher` = `ViewSwitcher`, `Notebook` = `TabView`,
60 + `ListBox` = `NavList`. Tokeny metryk kontenerów: `Theme::metrics.container`.
61 61 - `libluna-platform`: realne akcje okna z `Frame` (`close`/`minimize`/`toggle_maximize`).
62 62 - Demo używa widgetów zamiast ręcznego rysowania (pasek boczny, wybór motywu/widoku, menu `☰`,
63 63 przycisk „Opcje”, przełączniki, suwak, wybór języka, pasek tytułu, zakładki `TabBar`/`TabView`).