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