Commit 42e2645
5
files
+472
added
-4
deleted
@@ -351,8 +351,9 @@ XDG_CONFIG_HOME="$PWD/examples" cargo run -p luna-demo --bin render -- --lang fr
351
351
`ToggleButton`, `LinkButton`, `ColorButton`; pola specjalne `PasswordEntry` (maskowanie +
352
352
podgląd „oko”) i `EditableLabel`; kontenery `CenterBox`, `Grid`, `Fixed`, `Paned`
353
353
(przeciągany separator), `Stack`, `ActionBar`, `PopoverMenuBar`; oraz `MenuButton`,
354
- `SearchBar`, `Expander`, `Statusbar`, `Calendar`; aliasy `ScrolledWindow` = `ScrollView`
355
- i `StackSwitcher` = `ViewSwitcher`.
354
+ `SearchBar`, `Expander`, `Statusbar`, `Calendar`, `Image` (ikony wektorowe),
355
+ `TextView` (pole wielolinijkowe); aliasy `ScrolledWindow` = `ScrollView`,
356
+ `StackSwitcher` = `ViewSwitcher`, `Notebook` = `TabView`, `ListBox` = `NavList`.
356
357
- `libluna-widgets::layout`: **dwuprzebiegowy układ** (retencyjne drzewo `measure`/`allocate`) —
357
358
[`Node`] (`VBox`/`HBox`, `Padding`, `Expanded`, `Center`, `Spacer`, `Label`, `Fixed`),
358
359
[`Constraints`](min/max) i [`Layout`](`desired_size`/`fit`). Liście pochłaniają nadmiar
359
360
new file mode 100644
@@ -0,0 +1,123 @@
1
+//! `Image` — obrazek (GTK4) oparty na wbudowanych ikonach wektorowych.
2
+//!
3
+//! libLuna nie dekoduje plików graficznych (świadomie — bez balastu kodeków); `Image`
4
+//! pokazuje natomiast zestaw ikon rysowanych wektorowo (folder, plik, lupka, krzyżyk,
5
+//! hamburger), skalowanych do zadanego rozmiaru.
6
+
7
+use libluna_core::{Rect, Theme};
8
+
9
+use crate::ctx::PaintCtx;
10
+use crate::icon;
11
+
12
+/// Zestaw wbudowanych ikon [`Image`].
13
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14
+pub enum ImageIcon {
15
+ #[default]
16
+ Folder,
17
+ File,
18
+ Search,
19
+ Close,
20
+ Burger,
21
+}
22
+
23
+/// Obrazek z wbudowanej ikony wektorowej.
24
+#[derive(Debug, Clone, Copy, PartialEq)]
25
+pub struct Image {
26
+ icon: ImageIcon,
27
+ size: f64,
28
+}
29
+
30
+impl Default for Image {
31
+ fn default() -> Self {
32
+ Self {
33
+ icon: ImageIcon::Folder,
34
+ size: 48.0,
35
+ }
36
+ }
37
+}
38
+
39
+impl Image {
40
+ pub fn new(icon: ImageIcon) -> Self {
41
+ Self {
42
+ icon,
43
+ ..Self::default()
44
+ }
45
+ }
46
+
47
+ pub fn icon(&self) -> ImageIcon {
48
+ self.icon
49
+ }
50
+
51
+ pub fn set_icon(&mut self, icon: ImageIcon) {
52
+ self.icon = icon;
53
+ }
54
+
55
+ pub fn size(mut self, size: f64) -> Self {
56
+ self.size = size.max(0.0);
57
+ self
58
+ }
59
+
60
+ pub fn width(&self) -> f64 {
61
+ self.size
62
+ }
63
+
64
+ pub fn height(&self) -> f64 {
65
+ self.size
66
+ }
67
+
68
+ /// Kwadrat obrazka wyśrodkowany w `rect` (o boku `min(rect, size)`).
69
+ pub fn image_rect(&self, rect: Rect) -> Rect {
70
+ let side = self.size.min(rect.width()).min(rect.height()).max(0.0);
71
+ let c = rect.center();
72
+ Rect::from_xywh(c.x - side / 2.0, c.y - side / 2.0, side, side)
73
+ }
74
+
75
+ pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
76
+ let theme = *ctx.theme;
77
+ let box_ = self.image_rect(rect);
78
+ let color = theme.colors.text;
79
+ match self.icon {
80
+ ImageIcon::Folder => icon::folder(ctx, color, box_),
81
+ ImageIcon::File => icon::file(ctx, color, box_),
82
+ ImageIcon::Search => icon::search(ctx, color, box_.center(), box_.width() * 0.9),
83
+ ImageIcon::Close => icon::close(ctx, color, box_, box_.width() * 0.22),
84
+ ImageIcon::Burger => icon::burger(ctx, color, box_.center(), box_.width() * 0.34, 1.8),
85
+ }
86
+ }
87
+}
88
+
89
+/// Rozmiar obrazka domyślnie ustawiany przez motyw (bok kwadratu).
90
+pub fn default_size(theme: &Theme) -> f64 {
91
+ theme.spacing.xl * 3.0
92
+}
93
+
94
+#[cfg(test)]
95
+mod tests {
96
+ use super::*;
97
+
98
+ #[test]
99
+ fn image_rect_is_square_and_centered() {
100
+ let img = Image::new(ImageIcon::Folder).size(40.0);
101
+ let rect = Rect::from_xywh(0.0, 0.0, 100.0, 60.0);
102
+ let r = img.image_rect(rect);
103
+ assert_eq!(r.width(), 40.0);
104
+ assert_eq!(r.height(), 40.0);
105
+ assert_eq!(r.center().x, 50.0);
106
+ assert_eq!(r.center().y, 30.0);
107
+ }
108
+
109
+ #[test]
110
+ fn image_shrinks_to_fit_small_rect() {
111
+ let img = Image::new(ImageIcon::File).size(80.0);
112
+ let rect = Rect::from_xywh(0.0, 0.0, 30.0, 20.0);
113
+ let r = img.image_rect(rect);
114
+ assert_eq!(r.width(), 20.0);
115
+ assert_eq!(r.height(), 20.0);
116
+ }
117
+
118
+ #[test]
119
+ fn size_defaults_from_integer_scale() {
120
+ let theme = Theme::light();
121
+ assert!(default_size(&theme) > 0.0);
122
+ }
123
+}
@@ -50,6 +50,9 @@
50
50
//! - [`CenterBox`], [`Grid`], [`Fixed`], [`Paned`], [`Stack`], [`ActionBar`], [`PopoverMenuBar`] —
51
51
//! kontenery GTK4 (`Box` = stos drzewa układu: [`Node::hbox`]/[`Node::vbox`]).
52
52
//! - [`MenuButton`], [`SearchBar`], [`Expander`], [`Statusbar`], [`Calendar`] — kolejne widgety GTK4.
53
+//! - [`Image`], [`TextView`] — obrazek (ikony wektorowe) i pole wielolinijkowe (GTK4).
54
+//! - Aliasy w duchu GTK4: [`Carousel`] = [`Pager`], [`ScrolledWindow`] = [`ScrollView`],
55
+//! [`StackSwitcher`] = [`ViewSwitcher`], [`Notebook`] = [`TabView`], [`ListBox`] = [`NavList`].
53
56
//! - [`Carousel`] (alias [`Pager`]), [`ToolbarView`], [`WindowTitle`], [`Clamp`], [`WrapBox`],
54
57
//! [`BreakpointBin`], [`ButtonContent`] — pomocniki.
55
58
//!
@@ -84,6 +87,7 @@ pub mod expander;
84
87
pub mod header_bar;
85
88
pub mod helpers;
86
89
pub mod icon;
90
+pub mod image;
87
91
pub mod item_view;
88
92
pub mod layout;
89
93
pub mod menu;
@@ -108,6 +112,7 @@ pub mod stepper;
108
112
pub mod switch;
109
113
pub mod tab_bar;
110
114
pub mod tab_view;
115
+pub mod text_view;
111
116
pub mod toast;
112
117
pub mod toggle_group;
113
118
pub mod tooltip;
@@ -141,10 +146,13 @@ pub use helpers::{
141
146
BreakpointBin, ButtonContent, Clamp, ToolbarRects, ToolbarView, WindowTitle, WindowTitleRects,
142
147
WrapBox,
143
148
};
149
+pub use image::{Image, ImageIcon};
144
150
pub use item_view::{Item, ItemKind, ItemView, ViewMode};
145
151
pub use layout::{Constraints, Layout, LayoutCtx, Node};
146
152
pub use menu::{Menu, MenuEntry, MenuRow};
147
153
pub use menu_button::MenuButton;
154
+/// `ListBox` — nazwa [`NavList`] w duchu GTK4.
155
+pub use nav_list::NavList as ListBox;
148
156
pub use nav_list::{NavEntry, NavList, NavRow};
149
157
pub use navigation::{NavigationSplitView, NavigationView, OverlaySplitView, Sidebar};
150
158
pub use pager::Pager;
@@ -169,6 +177,9 @@ pub use stepper::{Stepper, StepperSide};
169
177
pub use switch::Switch;
170
178
pub use tab_bar::{ScrollDir, Tab, TabBar};
171
179
pub use tab_view::TabView;
180
+/// `Notebook` — nazwa [`TabView`] w duchu GTK4.
181
+pub use tab_view::TabView as Notebook;
182
+pub use text_view::TextView;
172
183
pub use toast::Toast;
173
184
pub use toggle_group::ToggleGroup;
174
185
pub use tooltip::Tooltip;
175
186
new file mode 100644
@@ -0,0 +1,332 @@
1
+//! `TextView` — wielolinijkowe pole tekstowe (GTK4).
2
+//!
3
+//! Widget immediate: trzyma tekst (z `\n`) i indeks kursora na granicy znaku. Aplikacja
4
+//! dostarcza zdarzenia klawiatury wywołaniami metod (`insert_str`, `backspace`, `move_up`…)
5
+//! i sama decyduje o fokusie. Rysowanie nie zawija wierszy (brak mięk korekty) — to warstwa
6
+//! aplikacji, gdy będzie potrzebna.
7
+//!
8
+//! [`Entry`](crate::Entry) to jednowierszowy odpowiednik.
9
+
10
+use libluna_core::style::weight;
11
+use libluna_core::{Rect, Theme};
12
+
13
+use crate::ctx::PaintCtx;
14
+
15
+/// Wielolinijkowe pole tekstowe.
16
+#[derive(Debug, Clone, Default)]
17
+pub struct TextView {
18
+ text: String,
19
+ /// Indeks bajtowy kursora (zawsze na granicy znaku).
20
+ cursor: usize,
21
+ placeholder: String,
22
+ focused: bool,
23
+}
24
+
25
+impl TextView {
26
+ pub fn new(placeholder: impl Into<String>) -> Self {
27
+ Self {
28
+ placeholder: placeholder.into(),
29
+ ..Self::default()
30
+ }
31
+ }
32
+
33
+ pub fn text(&self) -> &str {
34
+ &self.text
35
+ }
36
+
37
+ pub fn is_empty(&self) -> bool {
38
+ self.text.is_empty()
39
+ }
40
+
41
+ pub fn cursor(&self) -> usize {
42
+ self.cursor
43
+ }
44
+
45
+ pub fn is_focused(&self) -> bool {
46
+ self.focused
47
+ }
48
+
49
+ pub fn line_count(&self) -> usize {
50
+ self.line_starts().len()
51
+ }
52
+
53
+ pub fn set_text(&mut self, text: impl Into<String>) {
54
+ self.text = text.into();
55
+ self.cursor = self.text.len();
56
+ }
57
+
58
+ pub fn set_placeholder(&mut self, placeholder: impl Into<String>) {
59
+ self.placeholder = placeholder.into();
60
+ }
61
+
62
+ pub fn set_focused(&mut self, focused: bool) {
63
+ self.focused = focused;
64
+ }
65
+
66
+ pub fn clear(&mut self) {
67
+ self.text.clear();
68
+ self.cursor = 0;
69
+ }
70
+
71
+ pub fn insert_str(&mut self, s: &str) {
72
+ self.text.insert_str(self.cursor, s);
73
+ self.cursor += s.len();
74
+ }
75
+
76
+ pub fn insert_char(&mut self, c: char) {
77
+ let mut buf = [0u8; 4];
78
+ self.insert_str(c.encode_utf8(&mut buf));
79
+ }
80
+
81
+ /// Wstawia znak nowej linii w miejscu kursora.
82
+ pub fn insert_newline(&mut self) {
83
+ self.insert_char('\n');
84
+ }
85
+
86
+ pub fn backspace(&mut self) {
87
+ if self.cursor > 0 {
88
+ let prev = prev_boundary(&self.text, self.cursor);
89
+ self.text.replace_range(prev..self.cursor, "");
90
+ self.cursor = prev;
91
+ }
92
+ }
93
+
94
+ pub fn delete_forward(&mut self) {
95
+ if self.cursor < self.text.len() {
96
+ let next = next_boundary(&self.text, self.cursor);
97
+ self.text.replace_range(self.cursor..next, "");
98
+ }
99
+ }
100
+
101
+ pub fn move_left(&mut self) {
102
+ self.cursor = prev_boundary(&self.text, self.cursor);
103
+ }
104
+
105
+ pub fn move_right(&mut self) {
106
+ self.cursor = next_boundary(&self.text, self.cursor);
107
+ }
108
+
109
+ pub fn home(&mut self) {
110
+ self.cursor = self.line_start(self.cursor_line());
111
+ }
112
+
113
+ pub fn end(&mut self) {
114
+ self.cursor = self.line_end(self.cursor_line());
115
+ }
116
+
117
+ /// Przechodzi wiersz w górę, zachowując kolumnę (o ile się mieści).
118
+ pub fn move_up(&mut self) {
119
+ let line = self.cursor_line();
120
+ if line == 0 {
121
+ self.cursor = 0;
122
+ return;
123
+ }
124
+ let col = self.cursor_col(line);
125
+ let start = self.line_start(line - 1);
126
+ let end = self.line_end(line - 1);
127
+ self.cursor = start + byte_at_char(&self.text[start..end], col);
128
+ }
129
+
130
+ /// Przechodzi wiersz w dół, zachowując kolumnę (o ile się mieści).
131
+ pub fn move_down(&mut self) {
132
+ let line = self.cursor_line();
133
+ if line + 1 >= self.line_count() {
134
+ self.cursor = self.text.len();
135
+ return;
136
+ }
137
+ let col = self.cursor_col(line);
138
+ let start = self.line_start(line + 1);
139
+ let end = self.line_end(line + 1);
140
+ self.cursor = start + byte_at_char(&self.text[start..end], col);
141
+ }
142
+
143
+ /// Wysokość treści (wszystkie wiersze).
144
+ pub fn content_height(&self, theme: &Theme) -> f64 {
145
+ self.line_count() as f64 * theme.typography.body.line_height
146
+ }
147
+
148
+ pub fn height(&self, theme: &Theme) -> f64 {
149
+ theme.metrics.entry.height.max(self.content_height(theme))
150
+ }
151
+
152
+ /// Iteruje po wierszach tekstu.
153
+ pub fn lines(&self) -> impl Iterator<Item = &str> {
154
+ self.text.split('\n')
155
+ }
156
+
157
+ fn line_starts(&self) -> Vec<usize> {
158
+ let mut starts = vec![0];
159
+ for (i, b) in self.text.bytes().enumerate() {
160
+ if b == b'\n' {
161
+ starts.push(i + 1);
162
+ }
163
+ }
164
+ starts
165
+ }
166
+
167
+ fn cursor_line(&self) -> usize {
168
+ let starts = self.line_starts();
169
+ starts
170
+ .partition_point(|&s| s <= self.cursor)
171
+ .saturating_sub(1)
172
+ }
173
+
174
+ fn line_start(&self, line: usize) -> usize {
175
+ *self.line_starts().get(line).unwrap_or(&self.text.len())
176
+ }
177
+
178
+ fn line_end(&self, line: usize) -> usize {
179
+ let starts = self.line_starts();
180
+ match starts.get(line + 1) {
181
+ Some(&next) => next - 1, // bez znaku '\n'
182
+ None => self.text.len(),
183
+ }
184
+ }
185
+
186
+ /// Kolumna kursora w danym wierszu (licząc znaki).
187
+ fn cursor_col(&self, line: usize) -> usize {
188
+ let start = self.line_start(line);
189
+ self.text[start..self.cursor].chars().count()
190
+ }
191
+
192
+ pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
193
+ let theme = *ctx.theme;
194
+ let radius = theme.radius.medium;
195
+ let colors = theme.colors;
196
+ ctx.scene.fill_rounded_rect(rect, radius, colors.shade);
197
+ let (border, width) = if self.focused {
198
+ (colors.accent, 2.0)
199
+ } else {
200
+ (colors.border, 1.0)
201
+ };
202
+ ctx.scene.stroke_rounded_rect(rect, radius, width, border);
203
+
204
+ let pad = theme.spacing.sm;
205
+ let inner = Rect::from_xywh(
206
+ rect.x() + pad,
207
+ rect.y() + pad,
208
+ (rect.width() - 2.0 * pad).max(0.0),
209
+ (rect.height() - 2.0 * pad).max(0.0),
210
+ );
211
+ let body = theme.typography.body.size;
212
+ let lh = theme.typography.body.line_height;
213
+
214
+ ctx.scene.push_clip(inner);
215
+ if self.text.is_empty() {
216
+ ctx.label_left(
217
+ inner.x(),
218
+ inner.y() + lh / 2.0,
219
+ &self.placeholder,
220
+ body,
221
+ weight::NORMAL,
222
+ colors.dim_text,
223
+ );
224
+ } else {
225
+ for (i, line) in self.lines().enumerate() {
226
+ let y = inner.y() + i as f64 * lh + lh / 2.0;
227
+ if y - lh / 2.0 > inner.bottom() {
228
+ break;
229
+ }
230
+ ctx.label_left(inner.x(), y, line, body, weight::NORMAL, colors.text);
231
+ }
232
+ if self.focused {
233
+ let line = self.cursor_line();
234
+ let start = self.line_start(line);
235
+ let col_x = ctx.measure(&self.text[start..self.cursor], body, weight::NORMAL);
236
+ let cx = inner.x() + col_x;
237
+ let cy = inner.y() + line as f64 * lh;
238
+ ctx.scene.fill_rect(
239
+ Rect::from_xywh(cx, cy + 2.0, 1.5, (lh - 4.0).max(0.0)),
240
+ colors.text,
241
+ );
242
+ }
243
+ }
244
+ ctx.scene.pop_clip();
245
+ }
246
+}
247
+
248
+fn prev_boundary(s: &str, i: usize) -> usize {
249
+ s[..i]
250
+ .char_indices()
251
+ .next_back()
252
+ .map(|(j, _)| j)
253
+ .unwrap_or(0)
254
+}
255
+
256
+fn next_boundary(s: &str, i: usize) -> usize {
257
+ s[i..].chars().next().map(|c| i + c.len_utf8()).unwrap_or(i)
258
+}
259
+
260
+/// Indeks bajtowy `col`-tego znaku w napisie (lub długość, gdy za krótki).
261
+fn byte_at_char(s: &str, col: usize) -> usize {
262
+ s.char_indices().nth(col).map(|(i, _)| i).unwrap_or(s.len())
263
+}
264
+
265
+#[cfg(test)]
266
+mod tests {
267
+ use super::*;
268
+
269
+ #[test]
270
+ fn newline_and_line_count() {
271
+ let mut t = TextView::new("");
272
+ t.insert_str("ab\ncd");
273
+ assert_eq!(t.line_count(), 2);
274
+ assert_eq!(t.text(), "ab\ncd");
275
+ assert_eq!(t.cursor(), 5);
276
+ }
277
+
278
+ #[test]
279
+ fn backspace_removes_newline() {
280
+ let mut t = TextView::new("");
281
+ t.insert_str("a\nb");
282
+ t.move_left(); // przed 'b'
283
+ t.backspace(); // usuwa '\n'
284
+ assert_eq!(t.text(), "ab");
285
+ }
286
+
287
+ #[test]
288
+ fn vertical_navigation_keeps_column() {
289
+ let mut t = TextView::new("");
290
+ t.set_text("hello\nhi\nworld");
291
+ t.home(); // początek ostatniego wiersza ("world")
292
+ t.move_up(); // wiersz "hi", kolumna 0
293
+ t.move_right();
294
+ t.move_right(); // kolumna 2 w "hi" (koniec wiersza)
295
+ assert_eq!(t.cursor(), "hello\nhi".len());
296
+ t.move_down(); // wiersz "world", kolumna 2
297
+ assert_eq!(t.cursor(), "hello\nhi\nwo".len());
298
+ }
299
+
300
+ #[test]
301
+ fn home_and_end_stay_in_line() {
302
+ let mut t = TextView::new("");
303
+ t.set_text("one\ntwo\nthree");
304
+ t.end();
305
+ assert_eq!(t.cursor(), t.text().len());
306
+ t.home();
307
+ assert_eq!(t.cursor(), "one\ntwo\n".len());
308
+ t.move_up();
309
+ assert_eq!(t.cursor(), "one\n".len());
310
+ t.home();
311
+ assert_eq!(t.cursor(), "one\n".len()); // już na początku wiersza
312
+ t.move_up();
313
+ assert_eq!(t.cursor(), 0);
314
+ t.move_up(); // brak wiersza wyżej
315
+ assert_eq!(t.cursor(), 0);
316
+ t.end(); // koniec wiersza "one"
317
+ assert_eq!(t.cursor(), "one".len());
318
+ }
319
+
320
+ #[test]
321
+ fn placeholder_and_content_height() {
322
+ let theme = Theme::light();
323
+ let mut t = TextView::new("Notatka…");
324
+ assert_eq!(t.line_count(), 1);
325
+ assert!(t.content_height(&theme) > 0.0);
326
+ t.insert_str("a\nb\nc");
327
+ assert_eq!(
328
+ t.content_height(&theme),
329
+ 3.0 * theme.typography.body.line_height
330
+ );
331
+ }
332
+}
@@ -50,12 +50,13 @@ 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–7): prezentacja `Label`, `Separator`,
53
+- **Galeria GTK4 pokrywana partiami** (partia 5–8): prezentacja `Label`, `Separator`,
54
54
`ProgressBar`, `LevelBar`, `Frame`, `Revealer`, `Overlay`, `AspectFrame`; przyciski
55
55
`ToggleButton`, `LinkButton`, `ColorButton`; pola specjalne `PasswordEntry` (maskowanie +
56
56
„oko”) i `EditableLabel`; kontenery `CenterBox`, `Grid`, `Fixed`, `Paned`, `Stack`,
57
57
`ActionBar`, `PopoverMenuBar`; dalej `MenuButton`, `SearchBar`, `Expander`, `Statusbar`,
58
- `Calendar`; aliasy GTK4 `ScrolledWindow` = `ScrollView`, `StackSwitcher` = `ViewSwitcher`.
58
+ `Calendar`, `Image`, `TextView`; aliasy GTK4 `ScrolledWindow` = `ScrollView`,
59
+ `StackSwitcher` = `ViewSwitcher`, `Notebook` = `TabView`, `ListBox` = `NavList`.
59
60
Tokeny metryk kontenerów: `Theme::metrics.container`.
60
61
- `libluna-platform`: realne akcje okna z `Frame` (`close`/`minimize`/`toggle_maximize`).
61
62
- Demo używa widgetów zamiast ręcznego rysowania (pasek boczny, wybór motywu/widoku, menu `☰`,