← luna

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 `☰`,