Commit e063a40
10
files
+1316
added
-15
deleted
@@ -346,6 +346,12 @@ XDG_CONFIG_HOME="$PWD/examples" cargo run -p luna-demo --bin render -- --lang fr
346
346
`NavigationView`, `NavigationSplitView`, `OverlaySplitView`, `Sidebar` (`Carousel` = alias
347
347
`Pager`); pomocniki `ToolbarView`, `WindowTitle`, `Clamp`, `WrapBox`, `BreakpointBin`,
348
348
`ButtonContent`.
349
+- `libluna-widgets` — **galeria GTK4** (kolejny zestaw): prezentacja `Label`, `Separator`,
350
+ `ProgressBar`, `LevelBar`, `Frame`, `Revealer`, `Overlay`, `AspectFrame`; przyciski
351
+ `ToggleButton`, `LinkButton`, `ColorButton`; pola specjalne `PasswordEntry` (maskowanie +
352
+ podgląd „oko”) i `EditableLabel`; kontenery `CenterBox`, `Grid`, `Paned` (przeciągany
353
+ separator), `Stack`, `ActionBar`, `PopoverMenuBar`; aliasy `ScrolledWindow` = `ScrollView`
354
+ i `StackSwitcher` = `ViewSwitcher`.
349
355
- `libluna-widgets::layout`: **dwuprzebiegowy układ** (retencyjne drzewo `measure`/`allocate`) —
350
356
[`Node`] (`VBox`/`HBox`, `Padding`, `Expanded`, `Center`, `Spacer`, `Label`, `Fixed`),
351
357
[`Constraints`](min/max) i [`Layout`](`desired_size`/`fit`). Liście pochłaniają nadmiar
@@ -28,10 +28,10 @@ pub use geometry::{Insets, Point, Rect, Size};
28
28
pub use scale::Scale;
29
29
pub use style::{
30
30
ActionRowMetrics, AvatarMetrics, BannerMetrics, BarMetrics, BottomSheetMetrics, ButtonMetrics,
31
- CardMetrics, CheckMetrics, DropDownMetrics, EntryMetrics, GroupMetrics, HeaderBarMetrics,
32
- ItemViewMetrics, MenuMetrics, Metrics, NavMetrics, PagerMetrics, PathBarMetrics, RadioMetrics,
33
- Radius, ScrollViewMetrics, SegmentedMetrics, ShortcutMetrics, SliderMetrics, Spacing,
34
- SpinnerMetrics, SplitMetrics, StatusPageMetrics, StepperMetrics, SwitchMetrics, TabsMetrics,
35
- Theme, ToastMetrics, TooltipMetrics, Typography,
31
+ CardMetrics, CheckMetrics, ContainerMetrics, DropDownMetrics, EntryMetrics, GroupMetrics,
32
+ HeaderBarMetrics, ItemViewMetrics, MenuMetrics, Metrics, NavMetrics, PagerMetrics,
33
+ PathBarMetrics, RadioMetrics, Radius, ScrollViewMetrics, SegmentedMetrics, ShortcutMetrics,
34
+ SliderMetrics, Spacing, SpinnerMetrics, SplitMetrics, StatusPageMetrics, StepperMetrics,
35
+ SwitchMetrics, TabsMetrics, Theme, ToastMetrics, TooltipMetrics, Typography,
36
36
};
37
37
pub use theme_file::{available as available_themes, StyledTheme, ThemePatch};
@@ -561,6 +561,17 @@ pub struct BarMetrics {
561
561
pub radius: f64,
562
562
}
563
563
564
+/// Geometria kontenerów (Box, Grid, Paned, CenterBox…).
565
+#[derive(Debug, Clone, Copy, PartialEq)]
566
+pub struct ContainerMetrics {
567
+ /// Domyślny odstęp między dziećmi (Box, Grid).
568
+ pub gap: f64,
569
+ /// Grubość separatora paneli (Paned).
570
+ pub separator: f64,
571
+ /// Dodatkowa strefa trafienia wokół separatora (po każdej stronie).
572
+ pub hit_pad: f64,
573
+}
574
+
564
575
/// Geometria kontrolek („component metrics”) — rozmiary i paddingi w pikselach logicznych.
565
576
///
566
577
/// Widgety nie mają już własnych stałych rozmiarów: czytają je z motywu, dzięki czemu
@@ -598,6 +609,7 @@ pub struct Metrics {
598
609
pub split: SplitMetrics,
599
610
pub group: GroupMetrics,
600
611
pub bar: BarMetrics,
612
+ pub container: ContainerMetrics,
601
613
}
602
614
603
615
impl Metrics {
@@ -764,6 +776,11 @@ impl Metrics {
764
776
thickness: 8.0,
765
777
radius: 4.0,
766
778
},
779
+ container: ContainerMetrics {
780
+ gap: 8.0,
781
+ separator: 1.0,
782
+ hit_pad: 4.0,
783
+ },
767
784
}
768
785
}
769
786
}
770
787
new file mode 100644
@@ -0,0 +1,188 @@
1
+//! Przyciski GTK4: `ToggleButton`, `LinkButton`, `ColorButton`.
2
+
3
+use libluna_core::style::weight;
4
+use libluna_core::{Color, Point, Rect, Theme};
5
+
6
+use crate::ctx::PaintCtx;
7
+
8
+/// Przycisk przełączany (pozostaje „wciśnięty”).
9
+#[derive(Debug, Clone, Default)]
10
+pub struct ToggleButton {
11
+ label: String,
12
+ active: bool,
13
+ hovered: bool,
14
+}
15
+
16
+impl ToggleButton {
17
+ pub fn new(label: impl Into<String>) -> Self {
18
+ Self {
19
+ label: label.into(),
20
+ active: false,
21
+ hovered: false,
22
+ }
23
+ }
24
+
25
+ pub fn active(mut self, active: bool) -> Self {
26
+ self.active = active;
27
+ self
28
+ }
29
+
30
+ pub fn is_active(&self) -> bool {
31
+ self.active
32
+ }
33
+
34
+ pub fn set_active(&mut self, active: bool) {
35
+ self.active = active;
36
+ }
37
+
38
+ pub fn set_hovered(&mut self, hovered: bool) {
39
+ self.hovered = hovered;
40
+ }
41
+
42
+ /// Przełącza stan; zwraca nowy stan.
43
+ pub fn click_at(&mut self, rect: Rect, p: Point) -> bool {
44
+ if rect.contains(p) {
45
+ self.active = !self.active;
46
+ }
47
+ self.active
48
+ }
49
+
50
+ pub fn height(&self, theme: &Theme) -> f64 {
51
+ theme.metrics.button.height
52
+ }
53
+
54
+ pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
55
+ let theme = *ctx.theme;
56
+ let colors = theme.colors;
57
+ let bg = if self.active {
58
+ colors.accent
59
+ } else if self.hovered {
60
+ colors.shade.scale_alpha(1.8)
61
+ } else {
62
+ colors.shade
63
+ };
64
+ ctx.scene.fill_rounded_rect(rect, theme.radius.small, bg);
65
+ let fg = if self.active {
66
+ colors.accent_fg
67
+ } else {
68
+ colors.text
69
+ };
70
+ ctx.label_center(
71
+ rect.center().x,
72
+ rect.center().y,
73
+ &self.label,
74
+ theme.typography.body.size,
75
+ weight::MEDIUM,
76
+ fg,
77
+ );
78
+ }
79
+}
80
+
81
+/// Przycisk z odnośnikiem (kolor akcentu + podkreślenie).
82
+#[derive(Debug, Clone, Default)]
83
+pub struct LinkButton {
84
+ label: String,
85
+}
86
+
87
+impl LinkButton {
88
+ pub fn new(label: impl Into<String>) -> Self {
89
+ Self {
90
+ label: label.into(),
91
+ }
92
+ }
93
+
94
+ pub fn label(&self) -> &str {
95
+ &self.label
96
+ }
97
+
98
+ pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
99
+ let theme = *ctx.theme;
100
+ let size = theme.typography.body.size;
101
+ let text_w = ctx.measure(&self.label, size, weight::NORMAL);
102
+ let x = rect.center().x - text_w / 2.0;
103
+ ctx.label_left(
104
+ x,
105
+ rect.center().y,
106
+ &self.label,
107
+ size,
108
+ weight::NORMAL,
109
+ theme.colors.accent,
110
+ );
111
+ let underline_y = rect.center().y + size * 0.55;
112
+ ctx.scene.stroke_line(
113
+ Point::new(x, underline_y),
114
+ Point::new(x + text_w, underline_y),
115
+ 1.0,
116
+ theme.colors.accent,
117
+ );
118
+ }
119
+}
120
+
121
+/// Przycisk koloru (próbka koloru); otwiera wybór koloru po stronie aplikacji.
122
+#[derive(Debug, Clone, Copy)]
123
+pub struct ColorButton {
124
+ color: Color,
125
+}
126
+
127
+impl Default for ColorButton {
128
+ fn default() -> Self {
129
+ Self {
130
+ color: Color::from_rgba8(0x35, 0x84, 0xe4, 0xff),
131
+ }
132
+ }
133
+}
134
+
135
+impl ColorButton {
136
+ pub fn new(color: Color) -> Self {
137
+ Self { color }
138
+ }
139
+
140
+ pub fn color(&self) -> Color {
141
+ self.color
142
+ }
143
+
144
+ pub fn set_color(&mut self, color: Color) {
145
+ self.color = color;
146
+ }
147
+
148
+ pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
149
+ let theme = *ctx.theme;
150
+ let swatch = rect.inflate(libluna_core::Insets::splat(-2.0));
151
+ ctx.scene
152
+ .fill_rounded_rect(swatch, theme.radius.small - 2.0, theme.colors.shade);
153
+ ctx.scene.fill_rounded_rect(
154
+ rect.deflate(libluna_core::Insets::splat(2.0)),
155
+ theme.radius.small - 2.0,
156
+ self.color,
157
+ );
158
+ ctx.scene
159
+ .stroke_rounded_rect(rect, theme.radius.small, 1.0, theme.colors.border);
160
+ }
161
+}
162
+
163
+#[cfg(test)]
164
+mod tests {
165
+ use super::*;
166
+
167
+ #[test]
168
+ fn toggle_button_flips() {
169
+ let mut b = ToggleButton::new("Bold");
170
+ let rect = Rect::from_xywh(0.0, 0.0, 80.0, 34.0);
171
+ assert!(!b.is_active());
172
+ assert!(b.click_at(rect, rect.center()));
173
+ assert!(!b.click_at(rect, Rect::ZERO.center()));
174
+ }
175
+
176
+ #[test]
177
+ fn color_button_roundtrip() {
178
+ let mut b = ColorButton::default();
179
+ let c = Color::from_rgba8(0x10, 0x20, 0x30, 0xff);
180
+ b.set_color(c);
181
+ assert_eq!(b.color(), c);
182
+ }
183
+
184
+ #[test]
185
+ fn link_button_label() {
186
+ assert_eq!(LinkButton::new("Docs").label(), "Docs");
187
+ }
188
+}
0
189
new file mode 100644
@@ -0,0 +1,621 @@
1
+//! Kontenery GTK4: `CenterBox`, `Grid`, `Paned`, `Stack`, `ActionBar`, `PopoverMenuBar`.
2
+//!
3
+//! `Box` w modelu libLuny to stos drzewa układu ([`crate::layout::Node::hbox`] /
4
+//! [`crate::layout::Node::vbox`]); tutaj żyją pozostałe kontenery. Te, które są
5
+//! wyłącznie geometrią (CenterBox, Grid), mają statyczne funkcje liczące prostokąty;
6
+//! te ze stanem (Paned, Stack, PopoverMenuBar) rysują się i obsługują trafienia.
7
+
8
+use libluna_core::style::weight;
9
+use libluna_core::{Point, Rect, Theme};
10
+use libluna_render::TextEngine;
11
+
12
+use crate::ctx::PaintCtx;
13
+use crate::display::Orientation;
14
+
15
+/// Trzyczęściowy układ: początek (lewo), środek (na środku), koniec (prawo).
16
+#[derive(Debug, Clone, Copy, Default)]
17
+pub struct CenterBox;
18
+
19
+impl CenterBox {
20
+ /// Dzieli `rect` na trzy części; środek jest wyśrodkowany względem całego prostokąta.
21
+ pub fn parts(rect: Rect, start_w: f64, center_w: f64, end_w: f64) -> [Rect; 3] {
22
+ let max = rect.width().max(0.0);
23
+ let start_w = start_w.clamp(0.0, max);
24
+ let end_w = end_w.clamp(0.0, max);
25
+ let center_w = center_w.clamp(0.0, max);
26
+ let start = Rect::from_xywh(rect.x(), rect.y(), start_w, rect.height());
27
+ let end = Rect::from_xywh(rect.right() - end_w, rect.y(), end_w, rect.height());
28
+ let center = Rect::from_xywh(
29
+ rect.center().x - center_w / 2.0,
30
+ rect.y(),
31
+ center_w,
32
+ rect.height(),
33
+ );
34
+ [start, center, end]
35
+ }
36
+}
37
+
38
+/// Siatka `rows × cols` z równymi komórkami i odstępem z motywu.
39
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40
+pub struct Grid {
41
+ rows: usize,
42
+ cols: usize,
43
+}
44
+
45
+impl Grid {
46
+ pub fn new(rows: usize, cols: usize) -> Self {
47
+ Self {
48
+ rows: rows.max(1),
49
+ cols: cols.max(1),
50
+ }
51
+ }
52
+
53
+ pub fn rows(&self) -> usize {
54
+ self.rows
55
+ }
56
+
57
+ pub fn cols(&self) -> usize {
58
+ self.cols
59
+ }
60
+
61
+ /// Wszystkie komórki w kolejności wierszami (row-major).
62
+ pub fn cells(&self, rect: Rect, theme: &Theme) -> Vec<Rect> {
63
+ let mut out = Vec::with_capacity(self.rows * self.cols);
64
+ for r in 0..self.rows {
65
+ for c in 0..self.cols {
66
+ out.push(self.cell(rect, theme, r, c));
67
+ }
68
+ }
69
+ out
70
+ }
71
+
72
+ /// Prostokąt komórki `(row, col)`.
73
+ pub fn cell(&self, rect: Rect, theme: &Theme, row: usize, col: usize) -> Rect {
74
+ let gap = theme.metrics.container.gap;
75
+ let cw = ((rect.width() - gap * (self.cols as f64 - 1.0)) / self.cols as f64).max(0.0);
76
+ let ch = ((rect.height() - gap * (self.rows as f64 - 1.0)) / self.rows as f64).max(0.0);
77
+ Rect::from_xywh(
78
+ rect.x() + col as f64 * (cw + gap),
79
+ rect.y() + row as f64 * (ch + gap),
80
+ cw,
81
+ ch,
82
+ )
83
+ }
84
+
85
+ /// Komórka pod punktem `p` (lub `None`).
86
+ pub fn cell_at(&self, rect: Rect, theme: &Theme, p: Point) -> Option<(usize, usize)> {
87
+ if !rect.contains(p) {
88
+ return None;
89
+ }
90
+ let gap = theme.metrics.container.gap;
91
+ let cw = ((rect.width() - gap * (self.cols as f64 - 1.0)) / self.cols as f64).max(0.0);
92
+ let ch = ((rect.height() - gap * (self.rows as f64 - 1.0)) / self.rows as f64).max(0.0);
93
+ let col = ((p.x - rect.x()) / (cw + gap)) as usize;
94
+ let row = ((p.y - rect.y()) / (ch + gap)) as usize;
95
+ Some((row.min(self.rows - 1), col.min(self.cols - 1)))
96
+ }
97
+}
98
+
99
+/// Panel dzielony (dwa dzieci + przesuwany separator).
100
+#[derive(Debug, Clone, Copy)]
101
+pub struct Paned {
102
+ orientation: Orientation,
103
+ position: f64,
104
+ min_first: f64,
105
+ min_second: f64,
106
+ hovered: bool,
107
+ dragging: bool,
108
+}
109
+
110
+impl Default for Paned {
111
+ fn default() -> Self {
112
+ Self {
113
+ orientation: Orientation::Horizontal,
114
+ position: 0.5,
115
+ min_first: 60.0,
116
+ min_second: 60.0,
117
+ hovered: false,
118
+ dragging: false,
119
+ }
120
+ }
121
+}
122
+
123
+impl Paned {
124
+ pub fn new(orientation: Orientation) -> Self {
125
+ Self {
126
+ orientation,
127
+ ..Self::default()
128
+ }
129
+ }
130
+
131
+ pub fn orientation(&self) -> Orientation {
132
+ self.orientation
133
+ }
134
+
135
+ /// Położenie separatora jako ułamek długości głównej osi (0..1).
136
+ pub fn position(&self) -> f64 {
137
+ self.position
138
+ }
139
+
140
+ pub fn set_position(&mut self, fraction: f64) {
141
+ self.position = fraction.clamp(0.0, 1.0);
142
+ }
143
+
144
+ pub fn min_sizes(mut self, first: f64, second: f64) -> Self {
145
+ self.min_first = first.max(0.0);
146
+ self.min_second = second.max(0.0);
147
+ self
148
+ }
149
+
150
+ pub fn set_hovered(&mut self, hovered: bool) {
151
+ self.hovered = hovered;
152
+ }
153
+
154
+ pub fn is_dragging(&self) -> bool {
155
+ self.dragging
156
+ }
157
+
158
+ fn clamped_fraction(&self, avail: f64) -> f64 {
159
+ if avail <= 0.0 {
160
+ return 0.0;
161
+ }
162
+ let min_f = (self.min_first / avail).clamp(0.0, 1.0);
163
+ let max_f = (1.0 - self.min_second / avail).clamp(0.0, 1.0);
164
+ self.position.clamp(min_f.min(max_f), max_f.max(min_f))
165
+ }
166
+
167
+ /// Trzy prostokąty: pierwszy panel, separator, drugi panel.
168
+ pub fn split(&self, rect: Rect, theme: &Theme) -> (Rect, Rect, Rect) {
169
+ let thick = theme.metrics.container.separator;
170
+ match self.orientation {
171
+ Orientation::Horizontal => {
172
+ let avail = (rect.width() - thick).max(0.0);
173
+ let fw = avail * self.clamped_fraction(avail);
174
+ let first = Rect::from_xywh(rect.x(), rect.y(), fw, rect.height());
175
+ let sep = Rect::from_xywh(rect.x() + fw, rect.y(), thick, rect.height());
176
+ let second = Rect::from_xywh(
177
+ rect.x() + fw + thick,
178
+ rect.y(),
179
+ (avail - fw).max(0.0),
180
+ rect.height(),
181
+ );
182
+ (first, sep, second)
183
+ }
184
+ Orientation::Vertical => {
185
+ let avail = (rect.height() - thick).max(0.0);
186
+ let fh = avail * self.clamped_fraction(avail);
187
+ let first = Rect::from_xywh(rect.x(), rect.y(), rect.width(), fh);
188
+ let sep = Rect::from_xywh(rect.x(), rect.y() + fh, rect.width(), thick);
189
+ let second = Rect::from_xywh(
190
+ rect.x(),
191
+ rect.y() + fh + thick,
192
+ rect.width(),
193
+ (avail - fh).max(0.0),
194
+ );
195
+ (first, sep, second)
196
+ }
197
+ }
198
+ }
199
+
200
+ pub fn panes(&self, rect: Rect, theme: &Theme) -> (Rect, Rect) {
201
+ let (a, _, b) = self.split(rect, theme);
202
+ (a, b)
203
+ }
204
+
205
+ pub fn separator_rect(&self, rect: Rect, theme: &Theme) -> Rect {
206
+ self.split(rect, theme).1
207
+ }
208
+
209
+ /// Czy punkt trafia w separator (z tolerancją `hit_pad`).
210
+ pub fn separator_hit(&self, rect: Rect, theme: &Theme, p: Point) -> bool {
211
+ let sep = self.separator_rect(rect, theme);
212
+ let pad = theme.metrics.container.hit_pad;
213
+ match self.orientation {
214
+ Orientation::Horizontal => Rect::from_xywh(
215
+ sep.x() - pad,
216
+ sep.y(),
217
+ sep.width() + 2.0 * pad,
218
+ sep.height(),
219
+ )
220
+ .contains(p),
221
+ Orientation::Vertical => Rect::from_xywh(
222
+ sep.x(),
223
+ sep.y() - pad,
224
+ sep.width(),
225
+ sep.height() + 2.0 * pad,
226
+ )
227
+ .contains(p),
228
+ }
229
+ }
230
+
231
+ /// Rozpoczyna przeciąganie separatora (jeśli punkt w niego trafia).
232
+ pub fn begin_drag(&mut self, rect: Rect, theme: &Theme, p: Point) -> bool {
233
+ if self.separator_hit(rect, theme, p) {
234
+ self.dragging = true;
235
+ true
236
+ } else {
237
+ false
238
+ }
239
+ }
240
+
241
+ /// Aktualizuje położenie separatora w trakcie przeciągania.
242
+ pub fn drag_to(&mut self, rect: Rect, theme: &Theme, p: Point) {
243
+ if !self.dragging {
244
+ return;
245
+ }
246
+ let thick = theme.metrics.container.separator;
247
+ match self.orientation {
248
+ Orientation::Horizontal => {
249
+ let avail = (rect.width() - thick).max(0.0);
250
+ if avail > 0.0 {
251
+ self.set_position((p.x - rect.x()) / avail);
252
+ }
253
+ }
254
+ Orientation::Vertical => {
255
+ let avail = (rect.height() - thick).max(0.0);
256
+ if avail > 0.0 {
257
+ self.set_position((p.y - rect.y()) / avail);
258
+ }
259
+ }
260
+ }
261
+ }
262
+
263
+ pub fn end_drag(&mut self) {
264
+ self.dragging = false;
265
+ }
266
+
267
+ pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
268
+ let theme = *ctx.theme;
269
+ let sep = self.separator_rect(rect, &theme);
270
+ let active = self.dragging || self.hovered;
271
+ let color = if active {
272
+ theme.colors.accent
273
+ } else {
274
+ theme.colors.border
275
+ };
276
+ ctx.scene.fill_rect(sep, color);
277
+ if active {
278
+ // Uchwyt: trzy kropki na środku separatora.
279
+ let c = sep.center();
280
+ for i in -1..=1 {
281
+ let dot = Rect::from_xywh(c.x - 1.5, c.y + i as f64 * 6.0 - 1.5, 3.0, 3.0);
282
+ ctx.scene
283
+ .fill_rounded_rect(dot, 1.5, theme.colors.accent_fg);
284
+ }
285
+ }
286
+ }
287
+}
288
+
289
+/// Stos stron: wiele dzieci, jedno widoczne (bez zakładek).
290
+///
291
+/// W odróżnieniu od [`TabView`](crate::TabView) nie ma paska zakładek — przełączaniem
292
+/// steruje aplikacja (np. przez [`ViewSwitcher`](crate::ViewSwitcher)).
293
+#[derive(Debug, Clone, Default)]
294
+pub struct Stack {
295
+ count: usize,
296
+ visible: usize,
297
+}
298
+
299
+impl Stack {
300
+ pub fn new() -> Self {
301
+ Self::default()
302
+ }
303
+
304
+ pub fn with_count(count: usize) -> Self {
305
+ Self { count, visible: 0 }
306
+ }
307
+
308
+ pub fn count(&self) -> usize {
309
+ self.count
310
+ }
311
+
312
+ pub fn visible_index(&self) -> usize {
313
+ self.visible
314
+ }
315
+
316
+ pub fn set_visible_index(&mut self, index: usize) {
317
+ if self.count > 0 {
318
+ self.visible = index.min(self.count - 1);
319
+ }
320
+ }
321
+
322
+ pub fn is_visible(&self, index: usize) -> bool {
323
+ index == self.visible
324
+ }
325
+
326
+ pub fn push(&mut self) -> usize {
327
+ self.count += 1;
328
+ self.count - 1
329
+ }
330
+
331
+ /// Usuwa stronę (przycina wybór, jeśli trzeba).
332
+ pub fn remove(&mut self, index: usize) {
333
+ if index < self.count {
334
+ self.count -= 1;
335
+ if self.visible >= self.count && self.count > 0 {
336
+ self.visible = self.count - 1;
337
+ }
338
+ }
339
+ }
340
+
341
+ pub fn next(&mut self) {
342
+ if self.count > 0 {
343
+ self.visible = (self.visible + 1) % self.count;
344
+ }
345
+ }
346
+
347
+ pub fn prev(&mut self) {
348
+ if self.count > 0 {
349
+ self.visible = (self.visible + self.count - 1) % self.count;
350
+ }
351
+ }
352
+}
353
+
354
+/// Pasek akcji (`ActionBar`): pasek u dołu/góry z akcjami po lewej i prawej.
355
+#[derive(Debug, Clone, Copy, Default)]
356
+pub struct ActionBar;
357
+
358
+impl ActionBar {
359
+ pub fn height(theme: &Theme) -> f64 {
360
+ theme.metrics.button.height + theme.spacing.xs * 2.0
361
+ }
362
+
363
+ /// Prostokąty: `[akcje-początku, akcje-końca]` wewnątrz `rect`.
364
+ pub fn parts(rect: Rect, theme: &Theme, start_w: f64, end_w: f64) -> [Rect; 2] {
365
+ let pad = theme.spacing.md;
366
+ let h = rect.height();
367
+ let inner_w = (rect.width() - 2.0 * pad).max(0.0);
368
+ let start_w = start_w.clamp(0.0, inner_w);
369
+ let end_w = end_w.clamp(0.0, inner_w);
370
+ [
371
+ Rect::from_xywh(rect.x() + pad, rect.y(), start_w, h),
372
+ Rect::from_xywh(rect.right() - pad - end_w, rect.y(), end_w, h),
373
+ ]
374
+ }
375
+
376
+ pub fn hit(rect: Rect, p: Point) -> bool {
377
+ rect.contains(p)
378
+ }
379
+
380
+ pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
381
+ let theme = *ctx.theme;
382
+ ctx.scene.fill_rect(rect, theme.colors.window_bg);
383
+ let line = Rect::from_xywh(rect.x(), rect.top(), rect.width(), 1.0);
384
+ ctx.scene.fill_rect(line, theme.colors.border);
385
+ }
386
+}
387
+
388
+/// Pasek menu (`PopoverMenuBar`): rząd przycisków otwierających popovery.
389
+#[derive(Debug, Clone, Default)]
390
+pub struct PopoverMenuBar {
391
+ labels: Vec<String>,
392
+ open: Option<usize>,
393
+ hovered: Option<usize>,
394
+}
395
+
396
+impl PopoverMenuBar {
397
+ pub fn new<I, S>(labels: I) -> Self
398
+ where
399
+ I: IntoIterator<Item = S>,
400
+ S: Into<String>,
401
+ {
402
+ Self {
403
+ labels: labels.into_iter().map(Into::into).collect(),
404
+ open: None,
405
+ hovered: None,
406
+ }
407
+ }
408
+
409
+ pub fn labels(&self) -> &[String] {
410
+ &self.labels
411
+ }
412
+
413
+ pub fn open_index(&self) -> Option<usize> {
414
+ self.open
415
+ }
416
+
417
+ pub fn is_open(&self) -> bool {
418
+ self.open.is_some()
419
+ }
420
+
421
+ pub fn set_hovered(&mut self, hovered: Option<usize>) {
422
+ self.hovered = hovered;
423
+ }
424
+
425
+ pub fn close(&mut self) {
426
+ self.open = None;
427
+ }
428
+
429
+ /// Prostokąty przycisków, jeden za drugim, o szerokości dopasowanej do etykiety.
430
+ pub fn buttons(&self, rect: Rect, text: &TextEngine, theme: &Theme) -> Vec<Rect> {
431
+ let m = theme.metrics.button;
432
+ let size = theme.typography.body.size;
433
+ let mut x = rect.x();
434
+ let mut out = Vec::with_capacity(self.labels.len());
435
+ for label in &self.labels {
436
+ let w = text.measure(label, size, weight::NORMAL).width + m.pad_x * 2.0;
437
+ out.push(Rect::from_xywh(x, rect.y(), w, rect.height()));
438
+ x += w;
439
+ }
440
+ out
441
+ }
442
+
443
+ pub fn index_at(
444
+ &self,
445
+ rect: Rect,
446
+ text: &TextEngine,
447
+ theme: &Theme,
448
+ p: Point,
449
+ ) -> Option<usize> {
450
+ self.buttons(rect, text, theme)
451
+ .iter()
452
+ .position(|b| b.contains(p))
453
+ }
454
+
455
+ /// Obsługuje klik: otwiera popover danego przycisku (lub zamyka, gdy ten sam).
456
+ pub fn click_at(
457
+ &mut self,
458
+ rect: Rect,
459
+ text: &TextEngine,
460
+ theme: &Theme,
461
+ p: Point,
462
+ ) -> Option<usize> {
463
+ match self.index_at(rect, text, theme, p) {
464
+ Some(i) => {
465
+ self.open = if self.open == Some(i) { None } else { Some(i) };
466
+ self.open
467
+ }
468
+ None => None,
469
+ }
470
+ }
471
+
472
+ pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
473
+ let theme = *ctx.theme;
474
+ let size = theme.typography.body.size;
475
+ ctx.scene.fill_rect(rect, theme.colors.headerbar_bg);
476
+ let text = ctx.text;
477
+ for (i, b) in self.buttons(rect, text, &theme).iter().enumerate() {
478
+ if self.open == Some(i) || self.hovered == Some(i) {
479
+ let bg = if self.open == Some(i) {
480
+ theme.colors.shade.scale_alpha(1.8)
481
+ } else {
482
+ theme.colors.shade
483
+ };
484
+ ctx.scene.fill_rounded_rect(*b, theme.radius.small, bg);
485
+ }
486
+ ctx.label_center(
487
+ b.center().x,
488
+ b.center().y,
489
+ &self.labels[i],
490
+ size,
491
+ weight::NORMAL,
492
+ theme.colors.text,
493
+ );
494
+ }
495
+ }
496
+}
497
+
498
+#[cfg(test)]
499
+mod tests {
500
+ use super::*;
501
+ use libluna_render::TextEngine;
502
+
503
+ fn theme() -> Theme {
504
+ Theme::light()
505
+ }
506
+
507
+ #[test]
508
+ fn center_box_places_three_parts() {
509
+ let rect = Rect::from_xywh(0.0, 0.0, 300.0, 40.0);
510
+ let [s, c, e] = CenterBox::parts(rect, 60.0, 100.0, 60.0);
511
+ assert_eq!(s, Rect::from_xywh(0.0, 0.0, 60.0, 40.0));
512
+ assert_eq!(e, Rect::from_xywh(240.0, 0.0, 60.0, 40.0));
513
+ assert_eq!(c.center().x, 150.0);
514
+ assert_eq!(c.width(), 100.0);
515
+ }
516
+
517
+ #[test]
518
+ fn grid_cells_partition_with_gap() {
519
+ let theme = theme();
520
+ let rect = Rect::from_xywh(0.0, 0.0, 208.0, 108.0);
521
+ let g = Grid::new(2, 3);
522
+ let cells = g.cells(rect, &theme);
523
+ assert_eq!(cells.len(), 6);
524
+ let gap = theme.metrics.container.gap; // 8
525
+ assert!((cells[0].width() - (208.0 - 2.0 * gap) / 3.0).abs() < 1e-9);
526
+ assert_eq!(cells[0].x(), 0.0);
527
+ assert_eq!(cells[1].x(), cells[0].right() + gap);
528
+ // Ostatnia komórka dotyka prawego brzegu.
529
+ assert!((cells[2].right() - 208.0).abs() < 1e-9);
530
+ }
531
+
532
+ #[test]
533
+ fn grid_cell_at_maps_points() {
534
+ let theme = theme();
535
+ let rect = Rect::from_xywh(0.0, 0.0, 208.0, 108.0);
536
+ let g = Grid::new(2, 3);
537
+ assert_eq!(g.cell_at(rect, &theme, Point::new(5.0, 5.0)), Some((0, 0)));
538
+ let c = g.cell(rect, &theme, 1, 2);
539
+ assert_eq!(g.cell_at(rect, &theme, c.center()), Some((1, 2)));
540
+ assert_eq!(g.cell_at(rect, &theme, Point::new(999.0, 5.0)), None);
541
+ }
542
+
543
+ #[test]
544
+ fn paned_splits_and_drags() {
545
+ let theme = theme();
546
+ let rect = Rect::from_xywh(0.0, 0.0, 300.0, 100.0);
547
+ let mut p = Paned::new(Orientation::Horizontal);
548
+ let (a, b) = p.panes(rect, &theme);
549
+ assert!((a.width() - b.width()).abs() <= theme.metrics.container.separator);
550
+ let sep = p.separator_rect(rect, &theme);
551
+ assert!(p.begin_drag(rect, &theme, sep.center()));
552
+ assert!(p.is_dragging());
553
+ p.drag_to(rect, &theme, Point::new(60.0, 50.0));
554
+ p.end_drag();
555
+ assert!(!p.is_dragging());
556
+ assert!(p.position() > 0.0 && p.position() < 1.0);
557
+ // Minimalne rozmiary ograniczają przeciągnięcie do krawędzi.
558
+ p.set_position(0.0);
559
+ let (a2, _) = p.panes(rect, &theme);
560
+ assert!(a2.width() >= theme.metrics.container.separator);
561
+ }
562
+
563
+ #[test]
564
+ fn paned_vertical_hit_zone() {
565
+ let theme = theme();
566
+ let rect = Rect::from_xywh(0.0, 0.0, 100.0, 300.0);
567
+ let mut p = Paned::new(Orientation::Vertical);
568
+ p.set_position(0.5);
569
+ let sep = p.separator_rect(rect, &theme);
570
+ assert!(p.separator_hit(rect, &theme, sep.center()));
571
+ assert!(!p.separator_hit(rect, &theme, Point::new(10.0, 10.0)));
572
+ }
573
+
574
+ #[test]
575
+ fn stack_cycles_and_clamps() {
576
+ let mut s = Stack::with_count(3);
577
+ assert_eq!(s.visible_index(), 0);
578
+ s.next();
579
+ assert_eq!(s.visible_index(), 1);
580
+ s.prev();
581
+ s.prev();
582
+ assert_eq!(s.visible_index(), 2);
583
+ s.set_visible_index(99);
584
+ assert_eq!(s.visible_index(), 2);
585
+ s.remove(2);
586
+ assert_eq!(s.count(), 2);
587
+ assert_eq!(s.visible_index(), 1);
588
+ }
589
+
590
+ #[test]
591
+ fn action_bar_parts_and_height() {
592
+ let theme = theme();
593
+ let rect = Rect::from_xywh(0.0, 0.0, 400.0, ActionBar::height(&theme));
594
+ let [s, e] = ActionBar::parts(rect, &theme, 80.0, 120.0);
595
+ assert_eq!(s.x(), theme.spacing.md);
596
+ assert!((e.right() - (400.0 - theme.spacing.md)).abs() < 1e-9);
597
+ assert!(ActionBar::hit(rect, rect.center()));
598
+ }
599
+
600
+ #[test]
601
+ fn menu_bar_opens_and_toggles() {
602
+ let theme = theme();
603
+ let text = TextEngine::system();
604
+ let rect = Rect::from_xywh(0.0, 0.0, 600.0, 34.0);
605
+ let mut bar = PopoverMenuBar::new(["Plik", "Edycja", "Widok"]);
606
+ let buttons = bar.buttons(rect, &text, &theme);
607
+ assert_eq!(buttons.len(), 3);
608
+ assert_eq!(
609
+ bar.index_at(rect, &text, &theme, buttons[1].center()),
610
+ Some(1)
611
+ );
612
+ assert_eq!(
613
+ bar.click_at(rect, &text, &theme, buttons[1].center()),
614
+ Some(1)
615
+ );
616
+ assert_eq!(bar.open_index(), Some(1));
617
+ // Klik w ten sam przycisk zamyka.
618
+ assert_eq!(bar.click_at(rect, &text, &theme, buttons[1].center()), None);
619
+ assert!(!bar.is_open());
620
+ }
621
+}
0
622
new file mode 100644
@@ -0,0 +1,359 @@
1
+//! Pola specjalne GTK4: `PasswordEntry` i `EditableLabel`.
2
+//!
3
+//! Oba są cienkimi nakładkami na [`Entry`]: cała logika edycji (kursor, wstawianie,
4
+//! usuwanie) żyje w polu bazowym, a tutaj dodajemy tylko cechy charakterystyczne.
5
+
6
+use libluna_core::style::weight;
7
+use libluna_core::{Point, Rect, Theme};
8
+
9
+use crate::ctx::PaintCtx;
10
+use crate::entry::{Entry, TextInput};
11
+use crate::icon;
12
+
13
+/// Pole hasła: [`Entry`] z maskowaniem (`•`) i przyciskiem podglądu („oko”).
14
+#[derive(Debug, Clone, Default)]
15
+pub struct PasswordEntry {
16
+ entry: Entry,
17
+ peek_hover: bool,
18
+}
19
+
20
+impl PasswordEntry {
21
+ pub fn new(placeholder: impl Into<String>) -> Self {
22
+ Self {
23
+ entry: Entry::new(placeholder).masked('•'),
24
+ peek_hover: false,
25
+ }
26
+ }
27
+
28
+ pub fn text(&self) -> &str {
29
+ self.entry.text()
30
+ }
31
+
32
+ pub fn is_empty(&self) -> bool {
33
+ self.entry.is_empty()
34
+ }
35
+
36
+ pub fn cursor(&self) -> usize {
37
+ self.entry.cursor()
38
+ }
39
+
40
+ pub fn is_focused(&self) -> bool {
41
+ self.entry.is_focused()
42
+ }
43
+
44
+ /// Czy hasło jest chwilowo widoczne (podgląd).
45
+ pub fn is_peek(&self) -> bool {
46
+ self.entry.is_peek()
47
+ }
48
+
49
+ pub fn set_peek(&mut self, peek: bool) {
50
+ self.entry.set_peek(peek);
51
+ }
52
+
53
+ /// Przełącza podgląd; zwraca nowy stan.
54
+ pub fn toggle_peek(&mut self) -> bool {
55
+ let v = !self.entry.is_peek();
56
+ self.entry.set_peek(v);
57
+ v
58
+ }
59
+
60
+ pub fn set_text(&mut self, text: impl Into<String>) {
61
+ self.entry.set_text(text);
62
+ }
63
+
64
+ pub fn set_focused(&mut self, focused: bool) {
65
+ self.entry.set_focused(focused);
66
+ }
67
+
68
+ pub fn set_placeholder(&mut self, placeholder: impl Into<String>) {
69
+ self.entry.set_placeholder(placeholder);
70
+ }
71
+
72
+ pub fn clear(&mut self) {
73
+ self.entry.clear();
74
+ }
75
+
76
+ pub fn insert_str(&mut self, s: &str) {
77
+ self.entry.insert_str(s);
78
+ }
79
+
80
+ pub fn insert_char(&mut self, c: char) {
81
+ self.entry.insert_char(c);
82
+ }
83
+
84
+ pub fn backspace(&mut self) {
85
+ self.entry.backspace();
86
+ }
87
+
88
+ pub fn delete_forward(&mut self) {
89
+ self.entry.delete_forward();
90
+ }
91
+
92
+ pub fn move_left(&mut self) {
93
+ self.entry.move_left();
94
+ }
95
+
96
+ pub fn move_right(&mut self) {
97
+ self.entry.move_right();
98
+ }
99
+
100
+ pub fn home(&mut self) {
101
+ self.entry.home();
102
+ }
103
+
104
+ pub fn end(&mut self) {
105
+ self.entry.end();
106
+ }
107
+
108
+ pub fn height(&self, theme: &Theme) -> f64 {
109
+ self.entry.height(theme)
110
+ }
111
+
112
+ pub fn contains(&self, rect: Rect, p: Point) -> bool {
113
+ self.entry.contains(rect, p)
114
+ }
115
+
116
+ /// Prostokąt przycisku podglądu („oko”) po prawej stronie pola.
117
+ pub fn peek_rect(&self, rect: Rect, theme: &Theme) -> Rect {
118
+ let m = theme.metrics.entry;
119
+ Rect::from_xywh(
120
+ rect.right() - m.pad_x - m.icon,
121
+ rect.center().y - m.icon / 2.0,
122
+ m.icon,
123
+ m.icon,
124
+ )
125
+ }
126
+
127
+ pub fn peek_at(&self, rect: Rect, theme: &Theme, p: Point) -> bool {
128
+ self.peek_rect(rect, theme).contains(p)
129
+ }
130
+
131
+ pub fn set_peek_hover(&mut self, hovered: bool) {
132
+ self.peek_hover = hovered;
133
+ }
134
+
135
+ /// Obsługuje klik: trafienie w „oko” przełącza podgląd. Zwraca `true`, gdy zmieniono stan.
136
+ pub fn click_at(&mut self, rect: Rect, theme: &Theme, p: Point) -> bool {
137
+ if self.peek_at(rect, theme, p) {
138
+ self.toggle_peek();
139
+ true
140
+ } else {
141
+ false
142
+ }
143
+ }
144
+
145
+ pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
146
+ let theme = *ctx.theme;
147
+ let m = theme.metrics.entry;
148
+ // Zarezerwuj miejsce na „oko”, by tekst nie wchodził pod ikonę.
149
+ let mut entry = self.entry.clone();
150
+ entry.set_trailing_space(m.icon + 8.0);
151
+ entry.paint(ctx, rect);
152
+
153
+ let eye = self.peek_rect(rect, &theme);
154
+ if self.peek_hover {
155
+ ctx.scene.fill_rounded_rect(
156
+ eye,
157
+ eye.width() / 2.0,
158
+ theme.colors.shade.scale_alpha(1.8),
159
+ );
160
+ }
161
+ let color = if self.entry.is_peek() {
162
+ theme.colors.accent
163
+ } else {
164
+ theme.colors.dim_text
165
+ };
166
+ if self.entry.is_peek() {
167
+ icon::eye(ctx, color, eye.center(), m.icon * 0.42);
168
+ } else {
169
+ icon::eye_off(ctx, color, eye.center(), m.icon * 0.42);
170
+ }
171
+ }
172
+}
173
+
174
+impl TextInput for PasswordEntry {
175
+ fn insert_str(&mut self, s: &str) {
176
+ PasswordEntry::insert_str(self, s);
177
+ }
178
+ fn insert_char(&mut self, c: char) {
179
+ PasswordEntry::insert_char(self, c);
180
+ }
181
+ fn backspace(&mut self) {
182
+ PasswordEntry::backspace(self);
183
+ }
184
+ fn delete_forward(&mut self) {
185
+ PasswordEntry::delete_forward(self);
186
+ }
187
+ fn move_left(&mut self) {
188
+ PasswordEntry::move_left(self);
189
+ }
190
+ fn move_right(&mut self) {
191
+ PasswordEntry::move_right(self);
192
+ }
193
+ fn home(&mut self) {
194
+ PasswordEntry::home(self);
195
+ }
196
+ fn end(&mut self) {
197
+ PasswordEntry::end(self);
198
+ }
199
+}
200
+
201
+/// Etykieta edytowalna: pokazuje tekst, a po wejściu w edycję zamienia się w [`Entry`].
202
+#[derive(Debug, Clone, Default)]
203
+pub struct EditableLabel {
204
+ text: String,
205
+ editing: bool,
206
+ entry: Entry,
207
+}
208
+
209
+impl EditableLabel {
210
+ pub fn new(text: impl Into<String>) -> Self {
211
+ let text = text.into();
212
+ let mut entry = Entry::new("");
213
+ entry.set_text(text.clone());
214
+ Self {
215
+ text,
216
+ editing: false,
217
+ entry,
218
+ }
219
+ }
220
+
221
+ pub fn text(&self) -> &str {
222
+ &self.text
223
+ }
224
+
225
+ pub fn set_text(&mut self, text: impl Into<String>) {
226
+ self.text = text.into();
227
+ self.entry.set_text(self.text.clone());
228
+ }
229
+
230
+ pub fn is_editing(&self) -> bool {
231
+ self.editing
232
+ }
233
+
234
+ /// Wchodzi w tryb edycji (kursor na końcu tekstu).
235
+ pub fn start_editing(&mut self) {
236
+ self.editing = true;
237
+ self.entry.set_text(self.text.clone());
238
+ self.entry.set_focused(true);
239
+ }
240
+
241
+ /// Zatwierdza edycję: tekst pola staje się tekstem etykiety.
242
+ pub fn commit(&mut self) {
243
+ if self.editing {
244
+ self.text = self.entry.text().to_string();
245
+ self.editing = false;
246
+ self.entry.set_focused(false);
247
+ }
248
+ }
249
+
250
+ /// Anuluje edycję (przywraca poprzedni tekst).
251
+ pub fn cancel(&mut self) {
252
+ if self.editing {
253
+ self.editing = false;
254
+ self.entry.set_text(self.text.clone());
255
+ self.entry.set_focused(false);
256
+ }
257
+ }
258
+
259
+ pub fn height(&self, theme: &Theme) -> f64 {
260
+ theme.metrics.entry.height
261
+ }
262
+
263
+ /// Deleguje edycję do pola bazowego (gdy trwa edycja).
264
+ pub fn entry_mut(&mut self) -> &mut Entry {
265
+ &mut self.entry
266
+ }
267
+
268
+ pub fn insert_str(&mut self, s: &str) {
269
+ if self.editing {
270
+ self.entry.insert_str(s);
271
+ }
272
+ }
273
+
274
+ pub fn backspace(&mut self) {
275
+ if self.editing {
276
+ self.entry.backspace();
277
+ }
278
+ }
279
+
280
+ pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
281
+ let theme = *ctx.theme;
282
+ if self.editing {
283
+ let mut entry = self.entry.clone();
284
+ entry.set_focused(true);
285
+ entry.paint(ctx, rect);
286
+ } else {
287
+ ctx.label_left(
288
+ rect.x(),
289
+ rect.center().y,
290
+ &self.text,
291
+ theme.typography.body.size,
292
+ weight::NORMAL,
293
+ theme.colors.text,
294
+ );
295
+ }
296
+ }
297
+}
298
+
299
+#[cfg(test)]
300
+mod tests {
301
+ use super::*;
302
+
303
+ fn rect() -> Rect {
304
+ Rect::from_xywh(0.0, 0.0, 200.0, 34.0)
305
+ }
306
+
307
+ #[test]
308
+ fn password_masks_but_keeps_text() {
309
+ let theme = Theme::light();
310
+ let mut p = PasswordEntry::new("Hasło");
311
+ p.insert_str("abc123");
312
+ assert_eq!(p.text(), "abc123");
313
+ assert!(p.entry.is_masked());
314
+ assert!(!p.is_peek());
315
+ // Klik w „oko” włącza podgląd.
316
+ let eye = p.peek_rect(rect(), &theme).center();
317
+ assert!(p.click_at(rect(), &theme, eye));
318
+ assert!(p.is_peek());
319
+ assert!(p.click_at(rect(), &theme, eye));
320
+ assert!(!p.is_peek());
321
+ // Klik poza „okiem” nic nie zmienia.
322
+ assert!(!p.click_at(rect(), &theme, Point::new(5.0, 5.0)));
323
+ }
324
+
325
+ #[test]
326
+ fn password_editing_delegates() {
327
+ let mut p = PasswordEntry::new("");
328
+ p.insert_str("ząb");
329
+ assert_eq!(p.text(), "ząb");
330
+ p.backspace();
331
+ assert_eq!(p.text(), "zą");
332
+ }
333
+
334
+ #[test]
335
+ fn editable_label_commit_and_cancel() {
336
+ let mut l = EditableLabel::new("Stara");
337
+ assert_eq!(l.text(), "Stara");
338
+ assert!(!l.is_editing());
339
+
340
+ l.start_editing();
341
+ assert!(l.is_editing());
342
+ l.entry_mut().set_text("Nowa");
343
+ l.commit();
344
+ assert!(!l.is_editing());
345
+ assert_eq!(l.text(), "Nowa");
346
+
347
+ l.start_editing();
348
+ l.entry_mut().set_text("Porzucona");
349
+ l.cancel();
350
+ assert_eq!(l.text(), "Nowa");
351
+ }
352
+
353
+ #[test]
354
+ fn editable_label_ignores_typing_when_not_editing() {
355
+ let mut l = EditableLabel::new("X");
356
+ l.insert_str("Y");
357
+ assert_eq!(l.text(), "X");
358
+ }
359
+}
@@ -30,6 +30,12 @@ pub struct Entry {
30
30
clearable: bool,
31
31
focused: bool,
32
32
hover_clear: bool,
33
+ /// Znak maskujący (np. `•`); `None` gdy tekst widoczny.
34
+ mask: Option<char>,
35
+ /// Podgląd hasła („oko”): pokazuje jawny tekst mimo maski.
36
+ peek: bool,
37
+ /// Miejsce zarezerwowane po prawej (np. na ikonę „oka”).
38
+ trailing: f64,
33
39
}
34
40
35
41
impl Entry {
@@ -52,6 +58,38 @@ impl Entry {
52
58
self
53
59
}
54
60
61
+ /// Maskuje tekst znakiem `mask` (np. `•`) — pola haseł.
62
+ pub fn masked(mut self, mask: char) -> Self {
63
+ self.mask = Some(mask);
64
+ self
65
+ }
66
+
67
+ pub fn set_masked(&mut self, mask: Option<char>) {
68
+ self.mask = mask;
69
+ }
70
+
71
+ pub fn is_masked(&self) -> bool {
72
+ self.mask.is_some()
73
+ }
74
+
75
+ /// Pokazuje jawny tekst mimo maski (podgląd hasła).
76
+ pub fn set_peek(&mut self, peek: bool) {
77
+ self.peek = peek;
78
+ }
79
+
80
+ pub fn is_peek(&self) -> bool {
81
+ self.peek
82
+ }
83
+
84
+ /// Rezerwuje miejsce po prawej stronie (np. na ikonę „oka”).
85
+ pub fn set_trailing_space(&mut self, space: f64) {
86
+ self.trailing = space.max(0.0);
87
+ }
88
+
89
+ pub fn trailing_space(&self) -> f64 {
90
+ self.trailing
91
+ }
92
+
55
93
pub fn text(&self) -> &str {
56
94
&self.text
57
95
}
@@ -146,7 +184,7 @@ impl Entry {
146
184
pub fn clear_rect(&self, rect: Rect, theme: &Theme) -> Rect {
147
185
let m = theme.metrics.entry;
148
186
Rect::from_xywh(
149
- rect.right() - m.pad_x - m.icon,
187
+ rect.right() - m.pad_x - m.icon - self.trailing,
150
188
rect.center().y - m.icon / 2.0,
151
189
m.icon,
152
190
m.icon,
@@ -158,6 +196,23 @@ impl Entry {
158
196
self.clearable && !self.text.is_empty() && self.clear_rect(rect, theme).contains(p)
159
197
}
160
198
199
+ /// Tekst do wyświetlenia (z uwzględnieniem maski hasła i podglądu).
200
+ fn display_text(&self) -> String {
201
+ match (self.mask, self.peek) {
202
+ (Some(ch), false) => ch.to_string().repeat(self.text.chars().count()),
203
+ _ => self.text.clone(),
204
+ }
205
+ }
206
+
207
+ /// Prefiks tekstu do kursora w wersji wyświetlanej (do wyliczenia pozycji kursora).
208
+ fn display_prefix(&self) -> String {
209
+ let upto = &self.text[..self.cursor];
210
+ match (self.mask, self.peek) {
211
+ (Some(ch), false) => ch.to_string().repeat(upto.chars().count()),
212
+ _ => upto.to_string(),
213
+ }
214
+ }
215
+
161
216
/// Pozycja (x), od której zaczyna się tekst (za ikoną wiodącą, jeśli jest).
162
217
fn text_x(&self, rect: Rect, theme: &Theme) -> f64 {
163
218
let m = theme.metrics.entry;
@@ -205,28 +260,25 @@ impl Entry {
205
260
);
206
261
} else {
207
262
let clear_space = if self.clearable { m.icon + 8.0 } else { 0.0 };
208
- let text_right = rect.right() - m.pad_x - clear_space;
263
+ let text_right = rect.right() - m.pad_x - clear_space - self.trailing;
209
264
ctx.scene.push_clip(Rect::from_xywh(
210
265
text_x,
211
266
rect.y(),
212
267
(text_right - text_x).max(0.0),
213
268
rect.height(),
214
269
));
270
+ let display = self.display_text();
215
271
ctx.label_left(
216
272
text_x,
217
273
cy,
218
- &self.text,
274
+ &display,
219
275
theme.typography.body.size,
220
276
weight::NORMAL,
221
277
colors.text,
222
278
);
223
279
if self.focused {
224
- let cx = text_x
225
- + ctx.measure(
226
- &self.text[..self.cursor],
227
- theme.typography.body.size,
228
- weight::NORMAL,
229
- );
280
+ let prefix = self.display_prefix();
281
+ let cx = text_x + ctx.measure(&prefix, theme.typography.body.size, weight::NORMAL);
230
282
ctx.scene.fill_rect(
231
283
Rect::from_xywh(cx, rect.y() + 6.0, 1.5, (rect.height() - 12.0).max(0.0)),
232
284
colors.text,
@@ -149,3 +149,41 @@ pub fn maximize(paint: &mut PaintCtx, color: Color, box_: Rect, half: f64) {
149
149
color,
150
150
);
151
151
}
152
+
153
+/// Oko (podgląd hasła); środek w `center`, półszerokość `size`.
154
+pub fn eye(paint: &mut PaintCtx, color: Color, center: Point, size: f64) {
155
+ use std::f64::consts::PI;
156
+ let w = size;
157
+ let h = size * 0.62;
158
+ let n = 12;
159
+ let mut prev = Point::new(center.x - w, center.y);
160
+ for i in 1..=n {
161
+ let t = i as f64 / n as f64;
162
+ let p = Point::new(center.x - w + 2.0 * w * t, center.y - h * (PI * t).sin());
163
+ paint.scene.stroke_line(prev, p, 1.6, color);
164
+ prev = p;
165
+ }
166
+ for i in 1..=n {
167
+ let t = i as f64 / n as f64;
168
+ let p = Point::new(center.x - w + 2.0 * w * t, center.y + h * (PI * t).sin());
169
+ paint.scene.stroke_line(prev, p, 1.6, color);
170
+ prev = p;
171
+ }
172
+ let r = size * 0.22;
173
+ paint.scene.fill_rounded_rect(
174
+ Rect::from_xywh(center.x - r, center.y - r, r * 2.0, r * 2.0),
175
+ r,
176
+ color,
177
+ );
178
+}
179
+
180
+/// Oko przekreślone (hasło ukryte).
181
+pub fn eye_off(paint: &mut PaintCtx, color: Color, center: Point, size: f64) {
182
+ eye(paint, color, center, size);
183
+ paint.scene.stroke_line(
184
+ Point::new(center.x - size * 0.9, center.y + size * 0.9),
185
+ Point::new(center.x + size * 0.9, center.y - size * 0.9),
186
+ 1.6,
187
+ color,
188
+ );
189
+}
@@ -45,6 +45,10 @@
45
45
//! - [`NavigationView`] / [`NavigationSplitView`] / [`OverlaySplitView`] / [`Sidebar`] — nawigacja.
46
46
//! - [`Label`], [`Separator`], [`ProgressBar`], [`LevelBar`], [`Frame`], [`Revealer`], [`Overlay`],
47
47
//! [`AspectFrame`] — widgety prezentacji (GTK4 Display).
48
+//! - [`ToggleButton`], [`LinkButton`], [`ColorButton`] — pozostałe przyciski (GTK4).
49
+//! - [`PasswordEntry`], [`EditableLabel`] — pola specjalne (GTK4).
50
+//! - [`CenterBox`], [`Grid`], [`Paned`], [`Stack`], [`ActionBar`], [`PopoverMenuBar`] — kontenery GTK4
51
+//! (`Box` = stos drzewa układu: [`Node::hbox`]/[`Node::vbox`]).
48
52
//! - [`Carousel`] (alias [`Pager`]), [`ToolbarView`], [`WindowTitle`], [`Clamp`], [`WrapBox`],
49
53
//! [`BreakpointBin`], [`ButtonContent`] — pomocniki.
50
54
//!
@@ -64,12 +68,15 @@ pub mod banner;
64
68
pub mod bin;
65
69
pub mod bottom_sheet;
66
70
pub mod button;
71
+pub mod buttons;
67
72
pub mod card;
68
73
pub mod check_button;
74
+pub mod containers;
69
75
pub mod ctx;
70
76
pub mod dialogs;
71
77
pub mod display;
72
78
pub mod dropdown;
79
+pub mod entries;
73
80
pub mod entry;
74
81
pub mod header_bar;
75
82
pub mod helpers;
@@ -106,8 +113,10 @@ pub use banner::{Banner, BannerKind, BannerRects};
106
113
pub use bin::{Bin, BinAlign};
107
114
pub use bottom_sheet::BottomSheet;
108
115
pub use button::{Button, ButtonVariant};
116
+pub use buttons::{ColorButton, LinkButton, ToggleButton};
109
117
pub use card::Card;
110
118
pub use check_button::CheckButton;
119
+pub use containers::{ActionBar, CenterBox, Grid, Paned, PopoverMenuBar, Stack};
111
120
pub use ctx::PaintCtx;
112
121
pub use dialogs::{
113
122
AboutDialog, AlertDialog, AlertRects, Dialog, DialogRects, PreferencesDialog, ShortcutsDialog,
@@ -117,6 +126,7 @@ pub use display::{
117
126
Separator,
118
127
};
119
128
pub use dropdown::DropDown;
129
+pub use entries::{EditableLabel, PasswordEntry};
120
130
pub use entry::{Entry, EntryIcon, TextInput};
121
131
pub use header_bar::{ControlsLayout, ControlsSide, HeaderBar, TitleAlign, WindowControl};
122
132
pub use helpers::{
@@ -124,7 +134,7 @@ pub use helpers::{
124
134
WrapBox,
125
135
};
126
136
pub use item_view::{Item, ItemKind, ItemView, ViewMode};
127
-pub use layout::{Constraints, Layout, LayoutCtx, Node, Stack};
137
+pub use layout::{Constraints, Layout, LayoutCtx, Node};
128
138
pub use menu::{Menu, MenuEntry, MenuRow};
129
139
pub use nav_list::{NavEntry, NavList, NavRow};
130
140
pub use navigation::{NavigationSplitView, NavigationView, OverlaySplitView, Sidebar};
@@ -136,6 +146,8 @@ pub use preferences::{PreferencesGroup, PreferencesPage};
136
146
pub use radio_group::RadioGroup;
137
147
pub use rows::{Row, RowRects, RowSuffix};
138
148
pub use scroll_view::ScrollView;
149
+/// `ScrolledWindow` — nazwa [`ScrollView`] w duchu GTK4.
150
+pub use scroll_view::ScrollView as ScrolledWindow;
139
151
pub use search_entry::SearchEntry;
140
152
pub use shortcut_label::ShortcutLabel;
141
153
pub use slider::Slider;
@@ -149,4 +161,6 @@ pub use tab_view::TabView;
149
161
pub use toast::Toast;
150
162
pub use toggle_group::ToggleGroup;
151
163
pub use tooltip::Tooltip;
164
+/// `StackSwitcher` — nazwa [`ViewSwitcher`] jako przełącznik stron stosu (GTK4).
165
+pub use view_switcher::ViewSwitcher as StackSwitcher;
152
166
pub use view_switcher::ViewSwitcher;
@@ -50,6 +50,12 @@ 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–6): prezentacja `Label`, `Separator`,
54
+ `ProgressBar`, `LevelBar`, `Frame`, `Revealer`, `Overlay`, `AspectFrame`; przyciski
55
+ `ToggleButton`, `LinkButton`, `ColorButton`; pola specjalne `PasswordEntry` (maskowanie +
56
+ „oko”) i `EditableLabel`; kontenery `CenterBox`, `Grid`, `Paned`, `Stack`, `ActionBar`,
57
+ `PopoverMenuBar`; aliasy GTK4 `ScrolledWindow` = `ScrollView`, `StackSwitcher` = `ViewSwitcher`.
58
+ Tokeny metryk kontenerów: `Theme::metrics.container`.
53
59
- `libluna-platform`: realne akcje okna z `Frame` (`close`/`minimize`/`toggle_maximize`).
54
60
- Demo używa widgetów zamiast ręcznego rysowania (pasek boczny, wybór motywu/widoku, menu `☰`,
55
61
przycisk „Opcje”, przełączniki, suwak, wybór języka, pasek tytułu, zakładki `TabBar`/`TabView`).