← luna

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