← luna

Commit cc6e3a9

77
files
+11058
added
-0
deleted
@@ -0,0 +1,3 @@
1 +/target
2 +**/*.rs.bk
3 +*.pdb
0 4 new file mode 100644
@@ -0,0 +1,798 @@
1 +# This file is automatically @generated by Cargo.
2 +# It is not intended for manual editing.
3 +version = 3
4 +
5 +[[package]]
6 +name = "adler2"
7 +version = "2.0.1"
8 +source = "registry+https://github.com/rust-lang/crates.io-index"
9 +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
10 +
11 +[[package]]
12 +name = "allocator-api2"
13 +version = "0.2.21"
14 +source = "registry+https://github.com/rust-lang/crates.io-index"
15 +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
16 +
17 +[[package]]
18 +name = "arrayref"
19 +version = "0.3.9"
20 +source = "registry+https://github.com/rust-lang/crates.io-index"
21 +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
22 +
23 +[[package]]
24 +name = "arrayvec"
25 +version = "0.7.8"
26 +source = "registry+https://github.com/rust-lang/crates.io-index"
27 +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
28 +
29 +[[package]]
30 +name = "bitflags"
31 +version = "1.3.2"
32 +source = "registry+https://github.com/rust-lang/crates.io-index"
33 +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
34 +
35 +[[package]]
36 +name = "bitflags"
37 +version = "2.13.2"
38 +source = "registry+https://github.com/rust-lang/crates.io-index"
39 +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
40 +
41 +[[package]]
42 +name = "bytemuck"
43 +version = "1.25.2"
44 +source = "registry+https://github.com/rust-lang/crates.io-index"
45 +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
46 +dependencies = [
47 + "bytemuck_derive",
48 +]
49 +
50 +[[package]]
51 +name = "bytemuck_derive"
52 +version = "1.12.1"
53 +source = "registry+https://github.com/rust-lang/crates.io-index"
54 +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c"
55 +dependencies = [
56 + "proc-macro2",
57 + "quote",
58 + "syn 3.0.7",
59 +]
60 +
61 +[[package]]
62 +name = "calloop"
63 +version = "0.13.0"
64 +source = "registry+https://github.com/rust-lang/crates.io-index"
65 +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec"
66 +dependencies = [
67 + "bitflags 2.13.2",
68 + "log",
69 + "polling",
70 + "rustix 0.38.44",
71 + "slab",
72 + "thiserror",
73 +]
74 +
75 +[[package]]
76 +name = "calloop-wayland-source"
77 +version = "0.3.0"
78 +source = "registry+https://github.com/rust-lang/crates.io-index"
79 +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"
80 +dependencies = [
81 + "calloop",
82 + "rustix 0.38.44",
83 + "wayland-backend",
84 + "wayland-client",
85 +]
86 +
87 +[[package]]
88 +name = "cc"
89 +version = "1.7.0"
90 +source = "registry+https://github.com/rust-lang/crates.io-index"
91 +checksum = "50a649af8a827553c29fb0cb4bd4a6f1a0dd695bd3232b9bc98bd9c8a3ffbb8b"
92 +dependencies = [
93 + "find-msvc-tools",
94 + "shlex",
95 +]
96 +
97 +[[package]]
98 +name = "cfg-if"
99 +version = "1.0.5"
100 +source = "registry+https://github.com/rust-lang/crates.io-index"
101 +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
102 +
103 +[[package]]
104 +name = "concurrent-queue"
105 +version = "2.5.0"
106 +source = "registry+https://github.com/rust-lang/crates.io-index"
107 +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
108 +dependencies = [
109 + "crossbeam-utils",
110 +]
111 +
112 +[[package]]
113 +name = "core_maths"
114 +version = "0.1.1"
115 +source = "registry+https://github.com/rust-lang/crates.io-index"
116 +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
117 +dependencies = [
118 + "libm",
119 +]
120 +
121 +[[package]]
122 +name = "crc32fast"
123 +version = "1.5.2"
124 +source = "registry+https://github.com/rust-lang/crates.io-index"
125 +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
126 +dependencies = [
127 + "cfg-if",
128 +]
129 +
130 +[[package]]
131 +name = "crossbeam-utils"
132 +version = "0.8.23"
133 +source = "registry+https://github.com/rust-lang/crates.io-index"
134 +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
135 +
136 +[[package]]
137 +name = "cursor-icon"
138 +version = "1.2.0"
139 +source = "registry+https://github.com/rust-lang/crates.io-index"
140 +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
141 +
142 +[[package]]
143 +name = "downcast-rs"
144 +version = "1.2.1"
145 +source = "registry+https://github.com/rust-lang/crates.io-index"
146 +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
147 +
148 +[[package]]
149 +name = "equivalent"
150 +version = "1.0.2"
151 +source = "registry+https://github.com/rust-lang/crates.io-index"
152 +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
153 +
154 +[[package]]
155 +name = "errno"
156 +version = "0.3.14"
157 +source = "registry+https://github.com/rust-lang/crates.io-index"
158 +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
159 +dependencies = [
160 + "libc",
161 + "windows-sys 0.61.2",
162 +]
163 +
164 +[[package]]
165 +name = "fdeflate"
166 +version = "0.3.7"
167 +source = "registry+https://github.com/rust-lang/crates.io-index"
168 +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
169 +dependencies = [
170 + "simd-adler32",
171 +]
172 +
173 +[[package]]
174 +name = "find-msvc-tools"
175 +version = "0.1.14"
176 +source = "registry+https://github.com/rust-lang/crates.io-index"
177 +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
178 +
179 +[[package]]
180 +name = "flate2"
181 +version = "1.1.10"
182 +source = "registry+https://github.com/rust-lang/crates.io-index"
183 +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
184 +dependencies = [
185 + "crc32fast",
186 + "miniz_oxide 0.9.1",
187 + "zlib-rs",
188 +]
189 +
190 +[[package]]
191 +name = "foldhash"
192 +version = "0.1.5"
193 +source = "registry+https://github.com/rust-lang/crates.io-index"
194 +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
195 +
196 +[[package]]
197 +name = "fontdue"
198 +version = "0.9.4"
199 +source = "registry+https://github.com/rust-lang/crates.io-index"
200 +checksum = "7894823fa221401399e2598f8b63f81ac77ff5c63248b7656779bff1632d7d3d"
201 +dependencies = [
202 + "hashbrown",
203 + "ttf-parser",
204 +]
205 +
206 +[[package]]
207 +name = "hashbrown"
208 +version = "0.15.5"
209 +source = "registry+https://github.com/rust-lang/crates.io-index"
210 +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
211 +dependencies = [
212 + "allocator-api2",
213 + "equivalent",
214 + "foldhash",
215 +]
216 +
217 +[[package]]
218 +name = "hermit-abi"
219 +version = "0.5.3"
220 +source = "registry+https://github.com/rust-lang/crates.io-index"
221 +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
222 +
223 +[[package]]
224 +name = "libc"
225 +version = "0.2.190"
226 +source = "registry+https://github.com/rust-lang/crates.io-index"
227 +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
228 +
229 +[[package]]
230 +name = "libluna"
231 +version = "0.0.1"
232 +dependencies = [
233 + "libluna-core",
234 + "libluna-i18n",
235 + "libluna-platform",
236 + "libluna-render",
237 + "libluna-widgets",
238 +]
239 +
240 +[[package]]
241 +name = "libluna-core"
242 +version = "0.0.1"
243 +
244 +[[package]]
245 +name = "libluna-i18n"
246 +version = "0.0.1"
247 +
248 +[[package]]
249 +name = "libluna-platform"
250 +version = "0.0.1"
251 +dependencies = [
252 + "libluna-core",
253 + "libluna-render",
254 + "smithay-client-toolkit",
255 + "wayland-client",
256 +]
257 +
258 +[[package]]
259 +name = "libluna-render"
260 +version = "0.0.1"
261 +dependencies = [
262 + "fontdue",
263 + "libluna-core",
264 + "tiny-skia",
265 +]
266 +
267 +[[package]]
268 +name = "libluna-widgets"
269 +version = "0.0.1"
270 +dependencies = [
271 + "libluna-core",
272 + "libluna-render",
273 +]
274 +
275 +[[package]]
276 +name = "libm"
277 +version = "0.2.16"
278 +source = "registry+https://github.com/rust-lang/crates.io-index"
279 +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
280 +
281 +[[package]]
282 +name = "linux-raw-sys"
283 +version = "0.4.15"
284 +source = "registry+https://github.com/rust-lang/crates.io-index"
285 +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
286 +
287 +[[package]]
288 +name = "linux-raw-sys"
289 +version = "0.12.1"
290 +source = "registry+https://github.com/rust-lang/crates.io-index"
291 +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
292 +
293 +[[package]]
294 +name = "log"
295 +version = "0.4.34"
296 +source = "registry+https://github.com/rust-lang/crates.io-index"
297 +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
298 +
299 +[[package]]
300 +name = "luna-demo"
301 +version = "0.0.1"
302 +dependencies = [
303 + "libluna-core",
304 + "libluna-i18n",
305 + "libluna-platform",
306 + "libluna-render",
307 + "libluna-widgets",
308 + "tiny-skia",
309 +]
310 +
311 +[[package]]
312 +name = "memchr"
313 +version = "2.8.3"
314 +source = "registry+https://github.com/rust-lang/crates.io-index"
315 +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
316 +
317 +[[package]]
318 +name = "memmap2"
319 +version = "0.8.0"
320 +source = "registry+https://github.com/rust-lang/crates.io-index"
321 +checksum = "43a5a03cefb0d953ec0be133036f14e109412fa594edc2f77227249db66cc3ed"
322 +dependencies = [
323 + "libc",
324 +]
325 +
326 +[[package]]
327 +name = "memmap2"
328 +version = "0.9.11"
329 +source = "registry+https://github.com/rust-lang/crates.io-index"
330 +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
331 +dependencies = [
332 + "libc",
333 +]
334 +
335 +[[package]]
336 +name = "miniz_oxide"
337 +version = "0.8.9"
338 +source = "registry+https://github.com/rust-lang/crates.io-index"
339 +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
340 +dependencies = [
341 + "adler2",
342 + "simd-adler32",
343 +]
344 +
345 +[[package]]
346 +name = "miniz_oxide"
347 +version = "0.9.1"
348 +source = "registry+https://github.com/rust-lang/crates.io-index"
349 +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
350 +dependencies = [
351 + "adler2",
352 + "simd-adler32",
353 +]
354 +
355 +[[package]]
356 +name = "pin-project-lite"
357 +version = "0.2.17"
358 +source = "registry+https://github.com/rust-lang/crates.io-index"
359 +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
360 +
361 +[[package]]
362 +name = "pkg-config"
363 +version = "0.3.34"
364 +source = "registry+https://github.com/rust-lang/crates.io-index"
365 +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
366 +
367 +[[package]]
368 +name = "png"
369 +version = "0.17.16"
370 +source = "registry+https://github.com/rust-lang/crates.io-index"
371 +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
372 +dependencies = [
373 + "bitflags 1.3.2",
374 + "crc32fast",
375 + "fdeflate",
376 + "flate2",
377 + "miniz_oxide 0.8.9",
378 +]
379 +
380 +[[package]]
381 +name = "polling"
382 +version = "3.11.0"
383 +source = "registry+https://github.com/rust-lang/crates.io-index"
384 +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
385 +dependencies = [
386 + "cfg-if",
387 + "concurrent-queue",
388 + "hermit-abi",
389 + "pin-project-lite",
390 + "rustix 1.1.5",
391 + "windows-sys 0.61.2",
392 +]
393 +
394 +[[package]]
395 +name = "proc-macro2"
396 +version = "1.0.107"
397 +source = "registry+https://github.com/rust-lang/crates.io-index"
398 +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
399 +dependencies = [
400 + "unicode-ident",
401 +]
402 +
403 +[[package]]
404 +name = "quick-xml"
405 +version = "0.41.0"
406 +source = "registry+https://github.com/rust-lang/crates.io-index"
407 +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
408 +dependencies = [
409 + "memchr",
410 +]
411 +
412 +[[package]]
413 +name = "quote"
414 +version = "1.0.47"
415 +source = "registry+https://github.com/rust-lang/crates.io-index"
416 +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
417 +dependencies = [
418 + "proc-macro2",
419 +]
420 +
421 +[[package]]
422 +name = "rustix"
423 +version = "0.38.44"
424 +source = "registry+https://github.com/rust-lang/crates.io-index"
425 +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
426 +dependencies = [
427 + "bitflags 2.13.2",
428 + "errno",
429 + "libc",
430 + "linux-raw-sys 0.4.15",
431 + "windows-sys 0.59.0",
432 +]
433 +
434 +[[package]]
435 +name = "rustix"
436 +version = "1.1.5"
437 +source = "registry+https://github.com/rust-lang/crates.io-index"
438 +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
439 +dependencies = [
440 + "bitflags 2.13.2",
441 + "errno",
442 + "libc",
443 + "linux-raw-sys 0.12.1",
444 + "windows-sys 0.61.2",
445 +]
446 +
447 +[[package]]
448 +name = "shlex"
449 +version = "2.0.1"
450 +source = "registry+https://github.com/rust-lang/crates.io-index"
451 +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
452 +
453 +[[package]]
454 +name = "simd-adler32"
455 +version = "0.3.10"
456 +source = "registry+https://github.com/rust-lang/crates.io-index"
457 +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
458 +
459 +[[package]]
460 +name = "slab"
461 +version = "0.4.12"
462 +source = "registry+https://github.com/rust-lang/crates.io-index"
463 +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
464 +
465 +[[package]]
466 +name = "smallvec"
467 +version = "1.16.3"
468 +source = "registry+https://github.com/rust-lang/crates.io-index"
469 +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
470 +
471 +[[package]]
472 +name = "smithay-client-toolkit"
473 +version = "0.19.2"
474 +source = "registry+https://github.com/rust-lang/crates.io-index"
475 +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"
476 +dependencies = [
477 + "bitflags 2.13.2",
478 + "bytemuck",
479 + "calloop",
480 + "calloop-wayland-source",
481 + "cursor-icon",
482 + "libc",
483 + "log",
484 + "memmap2 0.9.11",
485 + "pkg-config",
486 + "rustix 0.38.44",
487 + "thiserror",
488 + "wayland-backend",
489 + "wayland-client",
490 + "wayland-csd-frame",
491 + "wayland-cursor",
492 + "wayland-protocols",
493 + "wayland-protocols-wlr",
494 + "wayland-scanner",
495 + "xkbcommon",
496 + "xkeysym",
497 +]
498 +
499 +[[package]]
500 +name = "strict-num"
501 +version = "0.1.1"
502 +source = "registry+https://github.com/rust-lang/crates.io-index"
503 +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
504 +
505 +[[package]]
506 +name = "syn"
507 +version = "2.0.119"
508 +source = "registry+https://github.com/rust-lang/crates.io-index"
509 +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
510 +dependencies = [
511 + "proc-macro2",
512 + "quote",
513 + "unicode-ident",
514 +]
515 +
516 +[[package]]
517 +name = "syn"
518 +version = "3.0.7"
519 +source = "registry+https://github.com/rust-lang/crates.io-index"
520 +checksum = "d62a2e0561533f2ca2561d0cf27fd9fedb640a1bf2616ff5d5c80d99017faadc"
521 +dependencies = [
522 + "proc-macro2",
523 + "quote",
524 + "unicode-ident",
525 +]
526 +
527 +[[package]]
528 +name = "thiserror"
529 +version = "1.0.69"
530 +source = "registry+https://github.com/rust-lang/crates.io-index"
531 +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
532 +dependencies = [
533 + "thiserror-impl",
534 +]
535 +
536 +[[package]]
537 +name = "thiserror-impl"
538 +version = "1.0.69"
539 +source = "registry+https://github.com/rust-lang/crates.io-index"
540 +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
541 +dependencies = [
542 + "proc-macro2",
543 + "quote",
544 + "syn 2.0.119",
545 +]
546 +
547 +[[package]]
548 +name = "tiny-skia"
549 +version = "0.11.4"
550 +source = "registry+https://github.com/rust-lang/crates.io-index"
551 +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
552 +dependencies = [
553 + "arrayref",
554 + "arrayvec",
555 + "bytemuck",
556 + "cfg-if",
557 + "log",
558 + "png",
559 + "tiny-skia-path",
560 +]
561 +
562 +[[package]]
563 +name = "tiny-skia-path"
564 +version = "0.11.4"
565 +source = "registry+https://github.com/rust-lang/crates.io-index"
566 +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
567 +dependencies = [
568 + "arrayref",
569 + "bytemuck",
570 + "strict-num",
571 +]
572 +
573 +[[package]]
574 +name = "ttf-parser"
575 +version = "0.25.1"
576 +source = "registry+https://github.com/rust-lang/crates.io-index"
577 +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
578 +dependencies = [
579 + "core_maths",
580 +]
581 +
582 +[[package]]
583 +name = "unicode-ident"
584 +version = "1.0.26"
585 +source = "registry+https://github.com/rust-lang/crates.io-index"
586 +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
587 +
588 +[[package]]
589 +name = "wayland-backend"
590 +version = "0.3.17"
591 +source = "registry+https://github.com/rust-lang/crates.io-index"
592 +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
593 +dependencies = [
594 + "cc",
595 + "downcast-rs",
596 + "rustix 1.1.5",
597 + "smallvec",
598 + "wayland-sys",
599 +]
600 +
601 +[[package]]
602 +name = "wayland-client"
603 +version = "0.31.15"
604 +source = "registry+https://github.com/rust-lang/crates.io-index"
605 +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
606 +dependencies = [
607 + "bitflags 2.13.2",
608 + "rustix 1.1.5",
609 + "wayland-backend",
610 + "wayland-scanner",
611 +]
612 +
613 +[[package]]
614 +name = "wayland-csd-frame"
615 +version = "0.3.0"
616 +source = "registry+https://github.com/rust-lang/crates.io-index"
617 +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e"
618 +dependencies = [
619 + "bitflags 2.13.2",
620 + "cursor-icon",
621 + "wayland-backend",
622 +]
623 +
624 +[[package]]
625 +name = "wayland-cursor"
626 +version = "0.31.14"
627 +source = "registry+https://github.com/rust-lang/crates.io-index"
628 +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d"
629 +dependencies = [
630 + "rustix 1.1.5",
631 + "wayland-client",
632 + "xcursor",
633 +]
634 +
635 +[[package]]
636 +name = "wayland-protocols"
637 +version = "0.32.13"
638 +source = "registry+https://github.com/rust-lang/crates.io-index"
639 +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
640 +dependencies = [
641 + "bitflags 2.13.2",
642 + "wayland-backend",
643 + "wayland-client",
644 + "wayland-scanner",
645 +]
646 +
647 +[[package]]
648 +name = "wayland-protocols-wlr"
649 +version = "0.3.12"
650 +source = "registry+https://github.com/rust-lang/crates.io-index"
651 +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
652 +dependencies = [
653 + "bitflags 2.13.2",
654 + "wayland-backend",
655 + "wayland-client",
656 + "wayland-protocols",
657 + "wayland-scanner",
658 +]
659 +
660 +[[package]]
661 +name = "wayland-scanner"
662 +version = "0.31.11"
663 +source = "registry+https://github.com/rust-lang/crates.io-index"
664 +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
665 +dependencies = [
666 + "proc-macro2",
667 + "quick-xml",
668 + "quote",
669 +]
670 +
671 +[[package]]
672 +name = "wayland-sys"
673 +version = "0.31.11"
674 +source = "registry+https://github.com/rust-lang/crates.io-index"
675 +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
676 +dependencies = [
677 + "pkg-config",
678 +]
679 +
680 +[[package]]
681 +name = "windows-link"
682 +version = "0.2.1"
683 +source = "registry+https://github.com/rust-lang/crates.io-index"
684 +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
685 +
686 +[[package]]
687 +name = "windows-sys"
688 +version = "0.59.0"
689 +source = "registry+https://github.com/rust-lang/crates.io-index"
690 +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
691 +dependencies = [
692 + "windows-targets",
693 +]
694 +
695 +[[package]]
696 +name = "windows-sys"
697 +version = "0.61.2"
698 +source = "registry+https://github.com/rust-lang/crates.io-index"
699 +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
700 +dependencies = [
701 + "windows-link",
702 +]
703 +
704 +[[package]]
705 +name = "windows-targets"
706 +version = "0.52.6"
707 +source = "registry+https://github.com/rust-lang/crates.io-index"
708 +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
709 +dependencies = [
710 + "windows_aarch64_gnullvm",
711 + "windows_aarch64_msvc",
712 + "windows_i686_gnu",
713 + "windows_i686_gnullvm",
714 + "windows_i686_msvc",
715 + "windows_x86_64_gnu",
716 + "windows_x86_64_gnullvm",
717 + "windows_x86_64_msvc",
718 +]
719 +
720 +[[package]]
721 +name = "windows_aarch64_gnullvm"
722 +version = "0.52.6"
723 +source = "registry+https://github.com/rust-lang/crates.io-index"
724 +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
725 +
726 +[[package]]
727 +name = "windows_aarch64_msvc"
728 +version = "0.52.6"
729 +source = "registry+https://github.com/rust-lang/crates.io-index"
730 +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
731 +
732 +[[package]]
733 +name = "windows_i686_gnu"
734 +version = "0.52.6"
735 +source = "registry+https://github.com/rust-lang/crates.io-index"
736 +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
737 +
738 +[[package]]
739 +name = "windows_i686_gnullvm"
740 +version = "0.52.6"
741 +source = "registry+https://github.com/rust-lang/crates.io-index"
742 +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
743 +
744 +[[package]]
745 +name = "windows_i686_msvc"
746 +version = "0.52.6"
747 +source = "registry+https://github.com/rust-lang/crates.io-index"
748 +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
749 +
750 +[[package]]
751 +name = "windows_x86_64_gnu"
752 +version = "0.52.6"
753 +source = "registry+https://github.com/rust-lang/crates.io-index"
754 +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
755 +
756 +[[package]]
757 +name = "windows_x86_64_gnullvm"
758 +version = "0.52.6"
759 +source = "registry+https://github.com/rust-lang/crates.io-index"
760 +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
761 +
762 +[[package]]
763 +name = "windows_x86_64_msvc"
764 +version = "0.52.6"
765 +source = "registry+https://github.com/rust-lang/crates.io-index"
766 +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
767 +
768 +[[package]]
769 +name = "xcursor"
770 +version = "0.3.11"
771 +source = "registry+https://github.com/rust-lang/crates.io-index"
772 +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23"
773 +
774 +[[package]]
775 +name = "xkbcommon"
776 +version = "0.7.0"
777 +source = "registry+https://github.com/rust-lang/crates.io-index"
778 +checksum = "13867d259930edc7091a6c41b4ce6eee464328c6ff9659b7e4c668ca20d4c91e"
779 +dependencies = [
780 + "libc",
781 + "memmap2 0.8.0",
782 + "xkeysym",
783 +]
784 +
785 +[[package]]
786 +name = "xkeysym"
787 +version = "0.2.1"
788 +source = "registry+https://github.com/rust-lang/crates.io-index"
789 +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
790 +dependencies = [
791 + "bytemuck",
792 +]
793 +
794 +[[package]]
795 +name = "zlib-rs"
796 +version = "0.6.8"
797 +source = "registry+https://github.com/rust-lang/crates.io-index"
798 +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
0 799 new file mode 100644
@@ -0,0 +1,29 @@
1 +[workspace]
2 +resolver = "2"
3 +members = [
4 + "crates/libluna",
5 + "crates/libluna-core",
6 + "crates/libluna-i18n",
7 + "crates/libluna-render",
8 + "crates/libluna-platform",
9 + "crates/libluna-widgets",
10 + "apps/luna-demo",
11 +]
12 +
13 +[workspace.package]
14 +version = "0.0.1"
15 +edition = "2021"
16 +license = "MPL-2.0"
17 +rust-version = "1.80"
18 +authors = ["Patryk Szkudlarek / libLuna contributors"]
19 +repository = "https://github.com/mijsys/libLuna"
20 +
21 +[workspace.dependencies]
22 +libluna-core = { path = "crates/libluna-core", version = "0.0.1" }
23 +libluna-i18n = { path = "crates/libluna-i18n", version = "0.0.1" }
24 +libluna-render = { path = "crates/libluna-render", version = "0.0.1" }
25 +libluna-platform = { path = "crates/libluna-platform", version = "0.0.1" }
26 +libluna-widgets = { path = "crates/libluna-widgets", version = "0.0.1" }
27 +libluna = { path = "crates/libluna", version = "0.0.1" }
28 +
29 +tiny-skia = "0.11"
0 30 new file mode 100644
@@ -0,0 +1,329 @@
1 +# libLuna
2 +
3 +**English** — A lightweight, pure-Rust GUI toolkit: a from-scratch alternative to **GTK4 + libadwaita**, built for the **Luna** desktop.
4 +
5 +**Polski** — Lekki zestaw narzędzi GUI w czystym **Rust**: alternatywa od zera dla **GTK4 + libadwaita**, tworzona dla środowiska **Luna**.
6 +
7 +![Ustawienia — jasny](docs/images/settings-light.png)
8 +
9 +> Status: **M0 działające** — fundament, rysowanie, okno Wayland oraz **wielojęzyczność
10 +> i przełączanie wyglądu** (tryb jasny/ciemny/auto, kolory akcentu, metryki kontrolek).
11 +> Repozytorium: <https://github.com/mijsys/libLuna>.
12 +
13 +## Why libLuna exists
14 +
15 +libLuna exists because building a desktop the way we want it — small, fast, memory-safe and
16 +coherent — is awkward with the options that already exist.
17 +
18 +- **GTK4 + libadwaita is capable but heavy.** It drags in the GObject/glib runtime, a large C
19 +dependency tree and manual reference counting. For a lightweight desktop that is more
20 +machinery than the job needs.
21 +- **The alternatives are no better fit.** Qt bindings are enormous; most Rust-native toolkits
22 +(egui, iced, …) are render- or immediate-mode oriented and do not model desktop-shell chrome —
23 +client-side-decorated header bars, sidebars, path bars, tabs, popovers — the way libadwaita does.
24 +- **We want one stack, in pure Rust.** No GObject, no glib, no GTK: just Rust, a scene graph and
25 +a CPU/Wayland backend, with predictable performance and a small binary.
26 +- **We want the looks to be swappable.** Appearance is defined by design tokens — colors *and*
27 +control geometry — so a theme (or a future installed style file) can reshape the UI without
28 +touching widget code.
29 +- **We are building a desktop, not just a widget demo.** libLuna is the UI foundation for the
30 +**Luna** environment (Wayland compositor **Selene**), recreating the GTK4/libadwaita *look and
31 +idioms* without the GTK/Qt bloat.
32 +
33 +In short: libLuna is a deliberately small toolkit that gives Luna a fast, memory-safe, cohesive
34 +UI layer it fully controls — written in the language we already build the rest of the system in.
35 +
36 +## Dlaczego powstała libLuna
37 +
38 +libLuna powstała, bo zbudowanie pulpitu tak, jak chcemy — małego, szybkiego, bezpiecznego
39 +pamięciowo i spójnego — jest niewygodne przy dostępnych obecnie opcjach.
40 +
41 +- **GTK4 + libadwaita jest możne, ale ciężkie.** Wciąga runtime GObject/glib, duże drzewo
42 +zależności w C i ręczne liczenie referencji. Dla lekkiego pulpitu to więcej maszynerii, niż trzeba.
43 +- **Alternatywy nie pasują lepiej.** Wiązania do Qt są ogromne; większość natywnych dla Rusta
44 +toolkitów (egui, iced, …) jest nastawiona na render/immediate-mode i nie odwzorowuje „chrome”
45 +pulpitu — pasków tytułu z CSD, pasków bocznych, okruszków ścieżki, zakładek, popoverów — tak jak
46 +robi to libadwaita.
47 +- **Chcemy jeden stos, w czystym Rust.** Bez GObject, bez glib, bez GTK: sam Rust, graf sceny
48 +i backend CPU/Wayland, z przewidywalną wydajnością i małym rozmiarem binarki.
49 +- **Chcemy wymienny wygląd.** Wygląd opisują tokeny — kolory *oraz* geometria kontrolek — więc
50 +motyw (a w przyszłości instalowany plik stylu) może zmienić UI bez dotykania kodu widgetów.
51 +- **Budujemy pulpit, nie tylko demo widgetów.** libLuna to fundament UI środowiska **Luna**
52 +(kompozytor Wayland **Selene**), odtwarzający *wygląd i idiomy* GTK4/libadwaita — bez balastu GTK/Qt.
53 +
54 +Krótko: libLuna to celowo mały toolkit, który daje Lunie szybką, bezpieczną pamięciowo i spójną
55 +warstwę UI, w pełni przez nas kontrolowaną — napisaną w języku, w którym budujemy resztę systemu.
56 +
57 +## Nazewnictwo
58 +
59 +Wzorem GTK / GNOME / Mutter (i Qt / KDE / KWin) rozdzielamy trzy warstwy:
60 +
61 +| Warstwa | Odpowiednik GNOME | Odpowiednik KDE | Nasza nazwa |
62 +|---|---|---|---|
63 +| Toolkit (biblioteka) | GTK | Qt | **libLuna** |
64 +| Środowisko / powłoka | GNOME | KDE Plasma | **Luna** |
65 +| Kompozytor Wayland | Mutter | KWin | **Selene** |
66 +
67 +To repozytorium to **libLuna** (toolkit). Środowisko **Luna** (powłoka, panele, aplikacje)
68 +i kompozytor **Selene** buduje się na nim.
69 +
70 +## Decyzje projektowe
71 +
72 +- **Język: czysty Rust** — bez GObject/glib i bez GTK/Qt. Stąd lekkość i bezpieczeństwo
73 + pamięci; integracja z powłoką odbywa się przez standardowy Wayland (`xdg-shell`,
74 + a dla paneli `wlr-layer-shell`).
75 +- **Rysowanie: własny stos** — graf sceny + backend CPU (tiny-skia + fontdue). Bez Cairo/Pango.
76 +- **Wielojęzyczność: własna, bez gettext** — kluczem jest angielski napis, więc brak
77 + tłumaczenia degraduje się łagodnie do angielskiego. **Każdy język to osobny plik**
78 + `i18n/<kod>.lang`; wbudowane: **pl, en, de**, a kolejne można dołożyć w runtime bez
79 + rekompilacji (katalogi `~/.config/luna/i18n`, `~/.local/share/luna/i18n`, `/usr/share/luna/i18n`).
80 +- **Kompozytor docelowy: Selene** (Wayland) — jako zwykły klient `xdg-shell`/`wlr-layer-shell`.
81 +
82 +## Struktura repozytorium
83 +
84 +```
85 +libluna/
86 + crates/
87 + libluna/ # fasada: jedno wejście (core + render + platform + widgets + i18n) + prelude
88 + libluna-core/ # geometria, kolor, skala, zdarzenia, tokeny motywu, ustawienia wyglądu
89 + libluna-i18n/ # wielojęzyczność: każdy język w pliku i18n/<kod>.lang + przełączanie w runtime
90 + libluna-render/ # graf sceny + backend software (tiny-skia + fontdue)
91 + libluna-platform/ # Wayland: xdg-shell (CSD), wl_shm, wejście, pętla zdarzeń, motyw w Frame
92 + libluna-widgets/ # widgety: ViewSwitcher, NavList, Menu, Button, Switch, Slider, DropDown, HeaderBar, TabBar/TabView, BottomSheet, Pager, PathBar, SearchEntry, ItemView
93 + apps/
94 + luna-demo/ # demo aplikacji środowiska Luna; też render offline do PNG
95 + templates/
96 + minimal-app/ # szablon startowy aplikacji
97 + tools/
98 + libluna-new.sh # scaffolder: generuje gotowy projekt aplikacji
99 + docs/ # architektura, roadmap, zrzuty ekranu
100 +```
101 +
102 +Mapowanie pojęć GTK/GDK/Gsk/libadwaita → libLuna: [`docs/architecture.md`](docs/architecture.md).
103 +Plan dalszej pracy: [`docs/roadmap.md`](docs/roadmap.md).
104 +
105 +## Budowanie i uruchamianie
106 +
107 +Wymagany stabilny toolchain Rust. Jeśli go brak:
108 +
109 +```bash
110 +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
111 +```
112 +
113 +```bash
114 +cargo build --workspace
115 +cargo test --workspace
116 +```
117 +
118 +Okno z demem (wymaga kompozytora Wayland, np. Selene):
119 +
120 +```bash
121 +cargo run -p luna-demo
122 +```
123 +
124 +Podgląd bez kompozytora — render do PNG (język, motyw i akcent do wyboru):
125 +
126 +```bash
127 +cargo run -p luna-demo --bin render -- --lang pl docs/images/settings-light.png
128 +cargo run -p luna-demo --bin render -- --no-sidebar --sidebar-width 340 /tmp/out.png
129 +cargo run -p luna-demo --bin render -- --dark --accent purple --lang de docs/images/settings-de-dark.png
130 +```
131 +
132 +### Demo interaktywne bez kompozytora (headless)
133 +
134 +Logika interakcji jest niezależna od Waylanda (bierze zdarzenia + ustawienia), więc da się
135 +ją uruchomić bez okna. „Driver” przepuszcza **prawdziwe zdarzenia** (klik w segment motywu,
136 +w kropkę akcentu, w pozycję nawigacji, w przełącznik widoku, w ikonę `◧`, przeciąganie
137 +krawędzi) przez tę samą funkcję `handle_events` i renderuje klatki:
138 +
139 +```bash
140 +cargo run -p luna-demo --bin simulate # → docs/images/demo-step-*.png + demo-montage.png
141 +cargo test -p luna-demo --test interaction # te same interakcje jako asercje
142 +```
143 +
144 +![Sekwencja interakcji](docs/images/demo-montage.png)
145 +
146 +## Budowanie własnej aplikacji na libLunie
147 +
148 +Najszybsza droga to **fasada `libluna`** — jedna zależność zamiast czterech. Trzy sposoby:
149 +
150 +```toml
151 +[dependencies]
152 +libluna = { path = "../libluna/crates/libluna" } # monorepo / sąsiedni katalog
153 +# libluna = { git = "https://github.com/mijsys/libLuna.git" } # z repozytorium
154 +# libluna = "0.0.1" # z rejestru (docelowo)
155 +```
156 +
157 +W kodzie wystarczy `prelude`:
158 +
159 +```rust
160 +use libluna::prelude::*;
161 +
162 +fn main() -> Result<(), Box<dyn std::error::Error>> {
163 + set_locale(Locale::from_env());
164 + let config = AppConfig::new("Moja apka").initial_size(800, 600);
165 + run(config, |frame: &mut Frame| {
166 + // frame.events — zdarzenia; frame.scene — graf sceny;
167 + // frame.theme — motyw; frame.measure_text(...) — pomiar tekstu
168 + })
169 +}
170 +```
171 +
172 +### Scaffolder (najszybciej)
173 +
174 +```bash
175 +./tools/libluna-new.sh ~/projekty/moja-apka
176 +cd ~/projekty/moja-apka && cargo run
177 +```
178 +
179 +Skrypt tworzy projekt z poprawną ścieżką do libLuny. Szablon: [`templates/minimal-app`](templates/minimal-app).
180 +
181 +### Działający przykład
182 +
183 +```bash
184 +cargo run -p libluna --example minimal
185 +```
186 +
187 +> Uwaga: na tym etapie (przed M2) aplikacja wypełnia **graf sceny** ręcznie. Gdy wejdą
188 +> layout i drzewo widgetów, ten sam `run(...)` przyjmie deklaratywne drzewo — kod aplikacji
189 +> stanie się jeszcze krótszy.
190 +
191 +## Sterowanie w demo
192 +
193 +- **Mysz** — kliknij ikonę **`◧`** w HeaderBarze, aby zwinąć/rozwinąć pasek boczny
194 + (ikona zmienia się w `▭`), **`☰`** aby otworzyć menu (zamyka się klikiem poza panelem),
195 + lub kontrolkę **języka** (`DropDown`). Z menu **`☰` → „O aplikacji”** otwiera się **dolny panel**
196 + (`BottomSheet` z karuzelą stron `Pager`), zamykany klikiem w tło, przyciskiem `✕` lub
197 + przeciągnięciem w dół. W karcie **Pliki** kliknij element **okruszków** (nawigacja „w górę”),
198 + **pole wyszukiwania** (fokus) lub **plik/katalog** (zaznaczenie); przełącznik `Lista | Siatka`
199 + zmienia tryb `ItemView`. Klikaj segmenty `Jasny | Ciemny | Auto`, kropki
200 + **koloru akcentu**, przełączniki OPCJA A / **Pasek dolny** / **Pasek boczny** i **przeciągaj**
201 + suwak głośności oraz krawędź paska bocznego (150–460 px). Kontrolki okna `[—][□][✕]` działają
202 + (minimalizacja/maksymalizacja/zamknięcie przez warstwę platformy).
203 +- **Klawiatura** — skróty: `l` = jasny/ciemny, `a` = następny akcent, `g` = następny język,
204 + `b` = pokaż/ukryj pasek dolny, `s` = pokaż/ukryj pasek boczny, `v` = widok,
205 + `o` = dolny panel („O aplikacji”), `Esc` = zamknij.
206 +- Zmiany wyglądu są **zapisywane** do `~/.config/luna/theme.conf` i wczytywane przy starcie.
207 +- Tryb systemowy (`Auto`) czyta zmienną `LUNA_SYSTEM_DARK=1` (docelowo portal D-Bus).
208 +
209 +## Własny wygląd (motywy)
210 +
211 +Wygląd libLuny to **tokeny**, nie kod widgetów — dlatego „odejście od Adwaty” to głównie
212 +podmiana zestawu tokenów, a nie przepisywanie komponentów. Wygląd opisują trzy warstwy:
213 +
214 +1. **Wariant** — `Ciemny / Jasny / Auto` (`ThemePreference`).
215 +2. **Kolor akcentu** (`Accent`, 9 kolorów).
216 +3. **Geometria kontrolek** (`Metrics`) — rozmiary i paddingi widgetów.
217 +
218 +Wszystkie tokeny (odstępy, promienie, typografia, **metryki kontrolek**, paleta) żyją
219 +w `Theme` w `libluna-core`. Zmiana wariantu i akcentu jest natychmiastowa, a wybór zapisuje się
220 +w `~/.config/luna/theme.conf`.
221 +
222 +### Component metrics
223 +
224 +Rozmiary i paddingi kontrolek nie są już stałymi w widgetach, lecz **tokenami**
225 +(`Theme::metrics`) podzielonymi na sekcje per widget: `headerbar`, `button`, `switch`,
226 +`slider`, `dropdown`, `menu`, `nav`, `segmented`, `tabs`, `sheet`, `pager`, `pathbar`, `entry`,
227 +`itemview`. Dzięki temu wygląd steruje **także
228 +geometrią**, nie tylko kolorami — np. `theme.metrics.switch.width` albo
229 +`theme.metrics.headerbar.height`. Widgety czytają te wartości w `paint`/`layout`, więc
230 +przyszły, instalowany styl może zmienić np. wysokość paska tytułu czy rozmiar przełącznika
231 +bez dotykania kodu.
232 +
233 +### Plan: style instalowane (jak w GNOME/KDE)
234 +
235 +Docelowo wybór wyglądu nie będzie enumem zaszytym w kodzie, lecz **stylem instalowanym jako
236 +plik** — tak jak robią to GNOME i KDE. Aplikacja ma wtedy:
237 +
238 +- wykrywać style z katalogów (`~/.local/share/luna/themes`, `/usr/share/luna/themes`),
239 +- pokazywać je na liście w Ustawieniach,
240 +- otwierać **okno podglądu** na żywo przy wyborze (miniatura budowana z realnych widgetów),
241 +- wczytywać tokeny z pliku bez rekompilacji.
242 +
243 +Do tego czasu motyw buduje się wprost z tokenów (`Theme::light()` / `Theme::dark()` +
244 +`accented(...)`), więc podmiana palety czy geometrii to kwestia kilku linii.
245 +
246 +### Co jeszcze decyduje o „looku GTK4”
247 +
248 +Tokeny zmieniają kolory i geometrię kontrolek, ale o tym, czy okno „wygląda jak z GTK4”,
249 +decyduje też **chrome okna**: umiejscowienie kontrolek (`[—][□][✕]`), wyrównanie tytułu,
250 +zaokrąglone narożniki, cień. To warstwa widgetu `HeaderBar` (opcje `ControlsSide`,
251 +`TitleAlign`, `ControlsLayout`) — dzięki temu stronę kontrolek czy wyrównanie tytułu można
252 +zmienić bez przepisywania aplikacji. Rozmiar samych kontrolek okna jest już tokenem
253 +(`theme.metrics.headerbar.control`).
254 +
255 +## Dodawanie i edycja języków
256 +
257 +Każdy język to **jeden plik** `crates/libluna-i18n/i18n/<kod>.lang`. Format:
258 +
259 +```
260 +__name__ = Français # natywna nazwa języka w UI (opcjonalne)
261 +Appearance = Apparence # klucz (angielski) = tłumaczenie
262 +# linie zaczynające się od # to komentarze
263 +```
264 +
265 +- Wbudowane pliki (`pl`, `en`, `de`) są wkompilowane przez `include_str!`.
266 +- Kolejne języki wystarczy wrzucić jako `<kod>.lang` do `~/.config/luna/i18n`,
267 + `~/.local/share/luna/i18n` lub `/usr/share/luna/i18n` — zostaną **doładowane przy starcie**
268 + (nadpisują wbudowane; brakujące klucze spadają do angielskiego).
269 +- Demo przełącza się między wszystkimi wykrytymi językami (klawisz `g` lub klik w „Język”).
270 +
271 +Przykładowy, kompletny plik francuski jest w `examples/luna/i18n/fr.lang`:
272 +
273 +```bash
274 +XDG_CONFIG_HOME="$PWD/examples" cargo run -p luna-demo --bin render -- --lang fr /tmp/fr.png
275 +```
276 +
277 +## Co już działa (M0 + i18n/wygląd)
278 +
279 +- `libluna-core`: `Point/Size/Rect/Insets`, `Color` (sRGB, hex), `Scale`, zdarzenia, tokeny
280 + motywu (`Spacing`, `Radius`, `Typography`), **metryki kontrolek** (`Metrics`) oraz
281 + **ustawienia wyglądu** (`ThemePreference`, `Accent`, `ThemeSettings` z trwałością
282 + i rozwiązywaniem `light/dark`).
283 +- `libluna-i18n`: `Locale` (z `LANG`/`LC_*`), `Catalog`, `Translations`, globalne `tr`/`set_locale`,
284 + makro `t!` z podstawianiem argumentów, **języki z osobnych plików** `i18n/<kod>.lang`
285 + (wbudowane) oraz **doładowywane w runtime** z katalogów; `languages()` i `language_name()`.
286 +- `libluna-render`: graf sceny (prostokąty, zaokrąglenia, obramowania, linie, clip, tekst)
287 + i rasteryzator CPU z antyaliasingiem; tekst przez `fontdue` (font Adwaita Sans).
288 +- `libluna-platform`: okno `xdg-shell` z CSD, bufor `wl_shm`, pętla `calloop`, **zdarzenia
289 + wejścia** (wskaźnik, klawiatura, scroll) w `Frame`, odświeżanie na żądanie, zarządzanie
290 + motywem przez `Frame::set_preference` / `set_accent` (zapis + natychmiastowe przerysowanie).
291 + `Frame::close/minimize/toggle_maximize` to **realne akcje okna** (`xdg_toplevel`).
292 +- `libluna-widgets`: **widgety wielokrotnego użycia** w modelu immediate — `ViewSwitcher`
293 + (segmentowy), `NavList` (menu nawigacji z sekcjami), `Menu` (popover `☰`), `Button`
294 + (z wariantami i chevronem), `Switch`, `Slider`, `DropDown` (z rozwinięciem przez `Menu`),
295 + `HeaderBar` (pasek tytułu: tytuł, kontrolki okna, obszar na treść), `TabBar`/`TabView`
296 + (pasek zakładek i model stron: wybór, dodawanie, zamykanie), `BottomSheet` (dolny panel
297 + modalny z „grabberem”, przeciąganiem i zamykaniem), `Pager` (karuzela stron z kropkami),
298 + `PathBar` (okruszki z separatorem `›`, wskaźnikiem `▾` i zwijaniem do `…`), `SearchEntry`
299 + (pole wyszukiwania: lupka, kursor, czyszczenie) i `ItemView` (pliki/katalogi w siatce lub
300 + liście, z zaznaczaniem i przewijaniem) oraz wspólny `PaintCtx`.
301 +- `luna-demo`: `HeaderBar` jako widget (tytuł + `[—][□][✕]` po prawej + treść: `◧`, `+`,
302 + „Opcje ▾”, `☰`); **popover `☰`**, chowany i rozciągany (drag) panel boczny (`NavList`),
303 + chowany **pasek dolny**, wybór motywu/widoku (`ViewSwitcher`), `Switch`, `Slider`, `DropDown`,
304 + **zakładki** (`TabBar` + `TabView`) oraz **dolny panel** (`BottomSheet` z karuzelą `Pager`,
305 + otwierany z menu `☰` → „O aplikacji”), a także kartę **Pliki** (`PathBar` + `SearchEntry` +
306 + `ItemView` — przełącznik **Widok: Lista | Siatka** zmienia tryb widoku elementów).
307 +- Rozmiary i paddingi kontrolek są **tokenami** (`Theme::metrics`), więc geometria jest tak samo
308 + „stylowalna” jak kolory — nie ma już stałych typów w widgetach (`HeaderBar::HEIGHT`,
309 + `Switch::WIDTH`, `Button::HEIGHT`…).
310 +
311 +## Zrzuty ekranu
312 +
313 +Jasny i ciemny motyw (Luna) oraz wersja niemiecka z fioletowym akcentem i francuska
314 +z pliku doładowanego w runtime:
315 +
316 +![Ustawienia — jasny](docs/images/settings-light.png)
317 +![Ustawienia — ciemny](docs/images/settings-dark.png)
318 +![Ustawienia — ciemny, DE, fioletowy](docs/images/settings-de-dark.png)
319 +![Ustawienia — francuski z pliku](docs/images/settings-fr.png)
320 +
321 +## Motywacja i kontekst
322 +
323 +Środowisko **Luna** (kompozytor **Selene**) ma docelowo zastąpić GTK4/libadwaita w aplikacjach
324 +i ujednolicić warstwę UI w czystym Rust. Bazą jest tu **libLuna**; dotychczasowy świat KartONDE
325 +(powłoka `karton-shell`, aplikacje `karton-settings`/`karton-files`) migruje na libLunę.
326 +
327 +## Licencja
328 +
329 +MPL-2.0 (do potwierdzenia przez właściciela projektu).
0 330 new file mode 100644
@@ -0,0 +1,16 @@
1 +[package]
2 +name = "luna-demo"
3 +version.workspace = true
4 +edition.workspace = true
5 +license.workspace = true
6 +rust-version.workspace = true
7 +authors.workspace = true
8 +description = "Demo aplikacji dla środowiska Luna zbudowane na libLunie (HeaderBar CSD, panel wyglądu, i18n)."
9 +
10 +[dependencies]
11 +libluna-core.workspace = true
12 +libluna-i18n.workspace = true
13 +libluna-render.workspace = true
14 +libluna-platform.workspace = true
15 +libluna-widgets.workspace = true
16 +tiny-skia.workspace = true
0 17 new file mode 100644
@@ -0,0 +1,110 @@
1 +//! Renderuje scenę demo do pliku PNG — pozwala obejrzeć wynik bez kompozytora.
2 +//!
3 +//! Użycie:
4 +//! `cargo run -p luna-demo --bin render -- [--dark] [--accent NAME] [--lang CODE] [wyjście.png]`
5 +//!
6 +//! Przykłady:
7 +//! `render --lang pl docs/images/settings-light.png`
8 +//! `render --dark --accent purple --lang de docs/images/settings-dark.png`
9 +
10 +use std::error::Error;
11 +
12 +use libluna_core::{Accent, Size, ThemePreference};
13 +use libluna_i18n::{set_locale, Locale};
14 +use libluna_render::{Scene, SoftwareRenderer, TextEngine};
15 +use luna_demo::ui::{self, Demo};
16 +
17 +fn main() -> Result<(), Box<dyn Error>> {
18 + let args: Vec<String> = std::env::args().skip(1).collect();
19 +
20 + let mut dark = false;
21 + let mut lang = "pl".to_owned();
22 + let mut accent = Accent::Blue;
23 + let mut no_bottom_bar = false;
24 + let mut no_sidebar = false;
25 + let mut sidebar_width: Option<f64> = None;
26 + let mut out: Option<String> = None;
27 +
28 + let mut i = 0;
29 + while i < args.len() {
30 + match args[i].as_str() {
31 + "--dark" => {
32 + dark = true;
33 + i += 1;
34 + }
35 + "--no-bottom-bar" => {
36 + no_bottom_bar = true;
37 + i += 1;
38 + }
39 + "--no-sidebar" => {
40 + no_sidebar = true;
41 + i += 1;
42 + }
43 + "--sidebar-width" => {
44 + if let Some(v) = args.get(i + 1).and_then(|s| s.parse::<f64>().ok()) {
45 + sidebar_width = Some(v);
46 + }
47 + i += 2;
48 + }
49 + "--lang" => {
50 + if let Some(v) = args.get(i + 1) {
51 + lang = v.clone();
52 + }
53 + i += 2;
54 + }
55 + "--accent" => {
56 + if let Some(v) = args.get(i + 1) {
57 + accent = Accent::from_name(v).unwrap_or(Accent::Blue);
58 + }
59 + i += 2;
60 + }
61 + other => {
62 + out = Some(other.to_owned());
63 + i += 1;
64 + }
65 + }
66 + }
67 +
68 + let out = out.unwrap_or_else(|| {
69 + if dark {
70 + "luna-demo-dark.png".to_owned()
71 + } else {
72 + "luna-demo.png".to_owned()
73 + }
74 + });
75 +
76 + set_locale(Locale::new(&lang));
77 + // Doładuj języki ze standardowych katalogów (nadpisują wbudowane).
78 + libluna_i18n::load_catalogs_from_dirs();
79 +
80 + let preference = if dark {
81 + ThemePreference::Dark
82 + } else {
83 + ThemePreference::Light
84 + };
85 + let theme = preference.resolve(false, accent);
86 +
87 + let (w, h) = (900u32, 1028u32);
88 + let size = Size::new(w as f64, h as f64);
89 +
90 + let text = TextEngine::system();
91 + let mut scene = Scene::new();
92 + let demo = Demo {
93 + show_bottom_bar: !no_bottom_bar,
94 + sidebar_visible: !no_sidebar,
95 + sidebar_width: sidebar_width.unwrap_or(luna_demo::ui::DEFAULT_SIDEBAR_W),
96 + ..Demo::default()
97 + };
98 + ui::build_scene(&mut scene, &theme, &text, size, &demo, preference, accent);
99 +
100 + let mut renderer = SoftwareRenderer::new(w, h);
101 + renderer.draw(&scene, 1.0, theme.colors.window_bg);
102 +
103 + let png = renderer.encode_png().ok_or("nie udało się zakodować PNG")?;
104 + std::fs::write(&out, png)?;
105 + println!(
106 + "zapisano {out} ({w}x{h}, lang={lang}, accent={})",
107 + accent.name()
108 + );
109 + Ok(())
110 +}
0 111 new file mode 100644
@@ -0,0 +1,221 @@
1 +//! Headlessowy „driver" demo: przepuszcza **prawdziwe zdarzenia** przez tę samą logikę
2 +//! co okno (`ui::handle_events` + `ui::build_scene`) i renderuje kolejne klatki.
3 +//!
4 +//! To dowód, że demo działa interaktywnie, bez potrzeby żywego kompozytora Wayland.
5 +//!
6 +//! Uruchom: `cargo run -p luna-demo --bin simulate`
7 +//! Efekt: `docs/images/demo-step-*.png` oraz `docs/images/demo-montage.png`.
8 +
9 +use std::error::Error;
10 +
11 +use libluna_core::{
12 + Accent, ButtonState, Event, Modifiers, Point, PointerButton, PointerEvent, Size,
13 + ThemePreference, ThemeSettings,
14 +};
15 +use libluna_i18n::{set_locale, Locale};
16 +use libluna_render::{Scene, SoftwareRenderer, TextEngine};
17 +use libluna_widgets::ViewSwitcher;
18 +use luna_demo::ui::{self, make_nav, Demo, Popover};
19 +use tiny_skia::{FilterQuality, IntSize, Pixmap, PixmapPaint, Transform};
20 +
21 +const W: u32 = 900;
22 +const H: u32 = 1028;
23 +
24 +fn press(p: Point) -> Event {
25 + Event::PointerButton(PointerEvent {
26 + position: p,
27 + button: PointerButton::Left,
28 + state: ButtonState::Pressed,
29 + modifiers: Modifiers::NONE,
30 + })
31 +}
32 +
33 +fn render_pixels(settings: &ThemeSettings, demo: &Demo, text: &TextEngine, size: Size) -> Vec<u8> {
34 + let theme = settings.resolve(false);
35 + let mut scene = Scene::new();
36 + ui::build_scene(
37 + &mut scene,
38 + &theme,
39 + text,
40 + size,
41 + demo,
42 + settings.preference(),
43 + settings.accent(),
44 + );
45 + let mut r = SoftwareRenderer::new(size.width as u32, size.height as u32);
46 + r.draw(&scene, 1.0, theme.colors.window_bg);
47 + r.data().to_vec()
48 +}
49 +
50 +fn save_png(path: &str, pixels: &[u8]) -> Result<(), Box<dyn Error>> {
51 + let pixmap = Pixmap::from_vec(pixels.to_vec(), IntSize::from_wh(W, H).unwrap()).unwrap();
52 + pixmap.save_png(path)?;
53 + Ok(())
54 +}
55 +
56 +fn main() -> Result<(), Box<dyn Error>> {
57 + set_locale(Locale::new("pl"));
58 +
59 + let size = Size::new(W as f64, H as f64);
60 + let text = TextEngine::system();
61 + let mut demo = Demo::default();
62 + let mut settings = ThemeSettings::default();
63 +
64 + let mut frames: Vec<Vec<u8>> = Vec::new();
65 + let mut labels: Vec<String> = Vec::new();
66 +
67 + // 1. Start.
68 + frames.push(render_pixels(&settings, &demo, &text, size));
69 + labels.push("1. Start (jasny, PL)".into());
70 +
71 + // Pomocniczo: aktualny layout i wysłanie kliknięcia.
72 + macro_rules! layout {
73 + () => {
74 + ui::layout(size, &settings.resolve(false), &demo)
75 + };
76 + }
77 + macro_rules! click {
78 + ($pos:expr) => {{
79 + let theme = settings.resolve(false);
80 + ui::handle_events(
81 + &mut settings,
82 + &[press($pos)],
83 + size,
84 + &theme,
85 + &text,
86 + &mut demo,
87 + );
88 + }};
89 + }
90 +
91 + // 2. Klik „Ciemny" w przełączniku motywu.
92 + let c = ViewSwitcher::new(["a", "b", "c"]).segments(layout!().theme_switcher)[1].center();
93 + click!(c);
94 + assert_eq!(
95 + settings.preference(),
96 + ThemePreference::Dark,
97 + "motyw nie przeszedł na ciemny"
98 + );
99 + frames.push(render_pixels(&settings, &demo, &text, size));
100 + labels.push("2. Motyw: Ciemny".into());
101 +
102 + // 3. Klik kropki akcentu (Purple = indeks 7).
103 + let c = layout!().accents[7].center();
104 + click!(c);
105 + assert_eq!(
106 + settings.accent(),
107 + Accent::Purple,
108 + "akcent nie ustawił się na Purple"
109 + );
110 + frames.push(render_pixels(&settings, &demo, &text, size));
111 + labels.push("3. Akcent: Purple".into());
112 +
113 + // 4. Klik pozycji „Sieć" w menu nawigacji (indeks elementu 5).
114 + let theme = settings.resolve(false);
115 + let c = make_nav().rows(layout!().sidebar, &theme)[5].rect.center();
116 + click!(c);
117 + assert_eq!(demo.nav_selected, Some(5), "nie zaznaczono pozycji Sieć");
118 + frames.push(render_pixels(&settings, &demo, &text, size));
119 + labels.push("4. Nawigacja: Sieć".into());
120 +
121 + // 5. Klik „Siatka" w przełączniku widoku.
122 + let c = ViewSwitcher::new(["a", "b"]).segments(layout!().view_switcher)[1].center();
123 + click!(c);
124 + assert_eq!(demo.view_mode, 1, "widok nie przeszedł na Siatkę");
125 + frames.push(render_pixels(&settings, &demo, &text, size));
126 + labels.push("5. Widok: Siatka".into());
127 +
128 + // 6. Przełączenie zakładki (TabView + TabBar).
129 + let theme = settings.resolve(false);
130 + let c = ui::tab_bar(&demo).segments(layout!().tabs, &text, &theme)[2].center();
131 + click!(c);
132 + assert_eq!(demo.tabs.selected(), 2, "zakładka się nie przełączyła");
133 + frames.push(render_pixels(&settings, &demo, &text, size));
134 + labels.push("6. Zakładka: O aplikacji".into());
135 +
136 + // 7. Klik ikony ◧ w HeaderBarze — zwinięcie paska bocznego.
137 + let c = layout!().sidebar_button.center();
138 + click!(c);
139 + assert!(!demo.sidebar_visible, "pasek boczny się nie zwinął");
140 + frames.push(render_pixels(&settings, &demo, &text, size));
141 + labels.push("7. Pasek boczny zwinięty ◧".into());
142 +
143 + // 8. Klik ikony „☰” — otwarcie popoveru (menu nawigacji).
144 + let c = layout!().burger.center();
145 + click!(c);
146 + assert_eq!(demo.popover, Some(Popover::Menu), "menu się nie otworzyło");
147 + // Podświetlenie drugiej pozycji, żeby pokazać hover.
148 + let theme = settings.resolve(false);
149 + if let Some((panel, menu)) = ui::popover_menu(size, &theme, &text, &layout!(), &demo) {
150 + let hover = menu.rows(panel, &theme)[1].rect.center();
151 + ui::handle_events(
152 + &mut settings,
153 + &[Event::PointerMotion {
154 + position: hover,
155 + modifiers: Modifiers::NONE,
156 + }],
157 + size,
158 + &theme,
159 + &text,
160 + &mut demo,
161 + );
162 + }
163 + frames.push(render_pixels(&settings, &demo, &text, size));
164 + labels.push("8. Menu ☰ otwarte".into());
165 +
166 + // 9. Klik „O aplikacji” w otwartym menu → dolny panel (BottomSheet + Pager).
167 + let theme = settings.resolve(false);
168 + let about = ui::popover_menu(size, &theme, &text, &layout!(), &demo)
169 + .map(|(panel, menu)| menu.rows(panel, &theme)[4].rect.center())
170 + .expect("menu otwarte");
171 + click!(about);
172 + assert!(demo.sheet.is_open(), "panel się nie otworzył");
173 + frames.push(render_pixels(&settings, &demo, &text, size));
174 + labels.push("9. Dolny panel \u{201e}O aplikacji\u{201d}".into());
175 +
176 + // 10. Zamknięcie panelu (klik w tło) i rozwinięcie listy języków (DropDown + Menu).
177 + click!(Point::new(120.0, 300.0));
178 + assert!(!demo.sheet.is_open(), "panel się nie zamknął");
179 + let c = layout!().language.center();
180 + click!(c);
181 + assert_eq!(
182 + demo.popover,
183 + Some(Popover::Language),
184 + "lista języków się nie rozwinęła"
185 + );
186 + frames.push(render_pixels(&settings, &demo, &text, size));
187 + labels.push("10. Wybór języka (DropDown)".into());
188 +
189 + // Zapis klatek.
190 + for (i, pixels) in frames.iter().enumerate() {
191 + let path = format!("docs/images/demo-step-{}.png", i + 1);
192 + save_png(&path, pixels)?;
193 + println!("zapisano {path} — {}", labels[i]);
194 + }
195 +
196 + // Montaż (siatka `cols` x `rows`) na zmienną liczbę klatek.
197 + let cols = 3usize;
198 + let rows = frames.len().div_ceil(cols);
199 + let cell_w = W / cols as u32;
200 + let cell_h = (H as f64 * cell_w as f64 / W as f64).round() as u32;
201 + let mut montage = Pixmap::new(cell_w * cols as u32, cell_h * rows as u32).unwrap();
202 + let paint = PixmapPaint {
203 + quality: FilterQuality::Bilinear,
204 + ..Default::default()
205 + };
206 + let scale = Transform::from_scale(cell_w as f32 / W as f32, cell_h as f32 / H as f32);
207 + for (i, pixels) in frames.iter().enumerate() {
208 + let src = Pixmap::from_vec(pixels.clone(), IntSize::from_wh(W, H).unwrap()).unwrap();
209 + let cx = (i % cols) as u32 * cell_w;
210 + let cy = (i / cols) as u32 * cell_h;
211 + montage.draw_pixmap(cx as i32, cy as i32, src.as_ref(), &paint, scale, None);
212 + }
213 + montage.save_png("docs/images/demo-montage.png")?;
214 + println!("zapisano docs/images/demo-montage.png");
215 +
216 + println!(
217 + "\nWszystkie interakcje przeszły pomyślnie ({} klatek).",
218 + frames.len()
219 + );
220 + Ok(())
221 +}
0 222 new file mode 100644
@@ -0,0 +1,4 @@
1 +//! Kode źródłowe demo aplikacji Luna: scena Ustawień (współdzielona przez binarkę okienną
2 +//! i narzędzie renderujące do PNG).
3 +
4 +pub mod ui;
0 5 new file mode 100644
@@ -0,0 +1,75 @@
1 +//! Demo aplikacji środowiska Luna — interaktywne okno z HeaderBar-em (CSD),
2 +//! panelem wyglądu i i18n. Zbudowane na libLunie.
3 +
4 +use std::error::Error;
5 +
6 +use libluna_core::ThemeSettings;
7 +use libluna_i18n::{set_locale, Locale};
8 +use libluna_platform::{run, AppConfig, Frame};
9 +use luna_demo::ui::{self, Demo};
10 +
11 +fn main() -> Result<(), Box<dyn Error>> {
12 + set_locale(Locale::from_env());
13 + // Doładuj języki ze standardowych katalogów (nadpisują wbudowane).
14 + libluna_i18n::load_catalogs_from_dirs();
15 +
16 + // Ustawienia wyglądu wczytane z dysku (jeśli są).
17 + let settings = ThemeSettings::load();
18 + // Tryb systemowy: prosty przełącznik przez zmienną środowiskową (docelowo portal).
19 + let system_dark = std::env::var("LUNA_SYSTEM_DARK")
20 + .map(|v| !v.is_empty() && v != "0")
21 + .unwrap_or(false);
22 +
23 + let config = AppConfig::new("Aplikacja")
24 + .app_id("eu.paganlinux.Luna.Settings")
25 + .initial_size(900, 1028)
26 + .settings(settings)
27 + .system_dark(system_dark);
28 +
29 + let mut demo = Demo::default();
30 +
31 + run(config, move |frame: &mut Frame| {
32 + // Logika interakcji jest niezależna od Waylanda: dostaje zdarzenia i ustawienia.
33 + let mut settings = frame.settings();
34 + ui::handle_events(
35 + &mut settings,
36 + frame.events,
37 + frame.size,
38 + &frame.theme,
39 + frame.text,
40 + &mut demo,
41 + );
42 + // Akcje okna zgłoszone przez HeaderBar (kontrolki).
43 + if demo.pending_close {
44 + frame.close();
45 + demo.pending_close = false;
46 + }
47 + if demo.pending_minimize {
48 + frame.minimize();
49 + demo.pending_minimize = false;
50 + }
51 + if demo.pending_maximize {
52 + frame.toggle_maximize();
53 + demo.pending_maximize = false;
54 + }
55 +
56 + if settings.preference() != frame.preference() {
57 + frame.set_preference(settings.preference());
58 + }
59 + if settings.accent() != frame.accent() {
60 + frame.set_accent(settings.accent());
61 + }
62 +
63 + // Motyw rozwiązujemy z aktualnych ustawień, aby klatka była już poprawna.
64 + let theme = settings.resolve(frame.system_dark());
65 + ui::build_scene(
66 + frame.scene,
67 + &theme,
68 + frame.text,
69 + frame.size,
70 + &demo,
71 + settings.preference(),
72 + settings.accent(),
73 + );
74 + })
75 +}
0 76 new file mode 100644
@@ -0,0 +1,1200 @@
1 +//! Scena demo Ustawień środowiska Luna — budowana z widgetów `libluna-widgets`
2 +//! (`ViewSwitcher`, `NavList`, `Menu`, `Button`, `Switch`, `Slider`, `DropDown`, `TabBar`)
3 +//! plus obsługa zdarzeń (klik, przeciąganie, skróty). Logika nie zależy od Waylanda.
4 +
5 +use libluna_core::event::ButtonState;
6 +use libluna_core::style::{weight, Appearance};
7 +use libluna_core::{
8 + Accent, Color, Event, Insets, KeyEvent, Point, PointerButton, Rect, Size, Theme,
9 + ThemePreference, ThemeSettings,
10 +};
11 +use libluna_i18n::{tr, Locale};
12 +use libluna_render::TextEngine;
13 +use libluna_widgets::{
14 + BottomSheet, Button, ControlsLayout, ControlsSide, DropDown, HeaderBar, Item, ItemView, Menu,
15 + MenuEntry, NavEntry, NavList, Pager, PaintCtx, PathBar, SearchEntry, Slider, Switch, Tab,
16 + TabBar, TabView, ViewMode, ViewSwitcher, WindowControl,
17 +};
18 +
19 +const STATUS_H: f64 = 28.0;
20 +const PAD: f64 = 12.0;
21 +/// Indeks pozycji „O aplikacji” w menu „☰” (otwiera dolny panel).
22 +const ABOUT_ENTRY: usize = 4;
23 +
24 +/// Domyślna szerokość paska bocznego.
25 +pub const DEFAULT_SIDEBAR_W: f64 = 220.0;
26 +/// Dolny/górny limit szerokości paska bocznego przy przeciąganiu.
27 +const MIN_SIDEBAR_W: f64 = 150.0;
28 +const MAX_SIDEBAR_W: f64 = 460.0;
29 +/// Szerokość strefy chwytu do przeciągania krawędzi paska bocznego.
30 +const HANDLE_W: f64 = 6.0;
31 +
32 +/// Który popover jest otwarty.
33 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34 +pub enum Popover {
35 + /// Menu „☰” pod ikoną w HeaderBarze.
36 + Menu,
37 + /// Lista wyboru języka pod kontrolką języka.
38 + Language,
39 +}
40 +
41 +/// Stan aplikacji demo, który nie należy do warstwy platformy ani do widgetów.
42 +pub struct Demo {
43 + pub option_a: bool,
44 + pub volume: f64,
45 + pub volume_dragging: bool,
46 + pub show_bottom_bar: bool,
47 + pub sidebar_visible: bool,
48 + pub sidebar_width: f64,
49 + pub dragging_sidebar: bool,
50 + /// Wybrany widok (0 = Lista, 1 = Siatka).
51 + pub view_mode: usize,
52 + /// Zaznaczona pozycja menu nawigacji (indeks elementu) oraz podświetlenia.
53 + pub nav_selected: Option<usize>,
54 + pub nav_hover: Option<usize>,
55 + pub theme_hover: Option<usize>,
56 + pub view_hover: Option<usize>,
57 + pub options_hover: bool,
58 + pub switch_hover: bool,
59 + /// Podświetlona kontrolka okna i akcje do wykonania przez platformę.
60 + pub controls_hover: Option<WindowControl>,
61 + pub pending_close: bool,
62 + pub pending_minimize: bool,
63 + pub pending_maximize: bool,
64 + /// Otwarty popover i podświetlona pozycja.
65 + pub popover: Option<Popover>,
66 + pub popover_hover: Option<usize>,
67 + /// Strony zakładek (tytuły to klucze i18n) oraz podświetlona zakładka.
68 + pub tabs: TabView,
69 + pub tabs_hover: Option<usize>,
70 + /// Dolny panel (modalny) i karuzela stron w jego zawartości.
71 + pub sheet: BottomSheet,
72 + pub pager: Pager,
73 + pub pager_dragging: bool,
74 + /// Pasek ścieżki (składniki to klucze i18n) i pole wyszukiwania.
75 + pub path: PathBar,
76 + pub path_hover: Option<usize>,
77 + pub search: SearchEntry,
78 + /// Widok elementów (pliki/katalogi) i podświetlony element.
79 + pub items: ItemView,
80 + pub items_hover: Option<usize>,
81 +}
82 +
83 +impl Default for Demo {
84 + fn default() -> Self {
85 + Self {
86 + option_a: true,
87 + volume: 0.65,
88 + volume_dragging: false,
89 + show_bottom_bar: true,
90 + sidebar_visible: true,
91 + sidebar_width: DEFAULT_SIDEBAR_W,
92 + dragging_sidebar: false,
93 + view_mode: 0,
94 + // Domyślnie zaznaczony „Wygląd" (indeks elementu w liście nawigacji).
95 + nav_selected: Some(2),
96 + nav_hover: None,
97 + theme_hover: None,
98 + view_hover: None,
99 + options_hover: false,
100 + switch_hover: false,
101 + controls_hover: None,
102 + pending_close: false,
103 + pending_minimize: false,
104 + pending_maximize: false,
105 + popover: None,
106 + popover_hover: None,
107 + tabs: {
108 + let mut tabs = TabView::new();
109 + tabs.add_titled("General");
110 + tabs.add_titled("Advanced");
111 + tabs.add_titled("About");
112 + tabs
113 + },
114 + tabs_hover: None,
115 + sheet: BottomSheet::new(),
116 + pager: Pager::new(3),
117 + pager_dragging: false,
118 + path: PathBar::new(["Home", "Documents", "Projects"]),
119 + path_hover: None,
120 + search: SearchEntry::new("Search"),
121 + items: ItemView::new(vec![
122 + Item::folder("Projects"),
123 + Item::new("Report").detail("PDF"),
124 + Item::new("Notes").detail("TXT"),
125 + Item::new("Budget").detail("XLSX"),
126 + ]),
127 + items_hover: None,
128 + }
129 + }
130 +}
131 +
132 +/// Prostokąty interaktywnych elementów — wspólne dla rysowania i hit-testingu.
133 +pub struct Layout {
134 + pub sidebar: Rect,
135 + pub sidebar_handle: Rect,
136 + pub area: Rect,
137 + pub sidebar_button: Rect,
138 + pub sidebar_visible: bool,
139 + pub options_button: Rect,
140 + /// Rozkład paska tytułu (kontrolki okna + obszar treści).
141 + pub header: ControlsLayout,
142 + pub theme_switcher: Rect,
143 + pub accents: [Rect; 9],
144 + pub language: Rect,
145 + pub view_switcher: Rect,
146 + pub option_a: Rect,
147 + pub volume_track: Rect,
148 + pub bottom_bar: Rect,
149 + pub sidebar_toggle: Rect,
150 + /// Pasek zakładek (karta „Zakładki”) i wiersz z aktywną stroną.
151 + pub tabs: Rect,
152 + pub tabs_value: Rect,
153 + /// Pasek ścieżki i pole wyszukiwania (karta „Pliki”).
154 + pub path: Rect,
155 + pub search: Rect,
156 + /// Obszar elementów (siatka/lista) w karcie „Pliki”.
157 + pub items: Rect,
158 + /// Kotwica popoveru „☰” w HeaderBarze.
159 + pub burger: Rect,
160 +}
161 +
162 +fn segment_labels() -> [&'static str; 3] {
163 + ["Light", "Dark", "Auto"]
164 +}
165 +
166 +/// Pasek tytułu z tytułem i kontrolkami okna po prawej.
167 +fn header_bar() -> HeaderBar {
168 + HeaderBar::new()
169 + .title(tr("Application"))
170 + .controls(ControlsSide::Right)
171 +}
172 +
173 +/// Lista nawigacji z aktualnie przetłumaczonymi etykietami (kolejność stała).
174 +pub fn make_nav() -> NavList {
175 + NavList::new(vec![
176 + NavEntry::Item(tr("Overview")),
177 + NavEntry::Item(tr("Account")),
178 + NavEntry::Item(tr("Appearance")),
179 + NavEntry::Item(tr("Notifications")),
180 + NavEntry::Section(tr("Advanced")),
181 + NavEntry::Item(tr("Network")),
182 + NavEntry::Item(tr("System")),
183 + ])
184 +}
185 +
186 +/// Pozycje menu „☰" (aktualnie przetłumaczone).
187 +pub fn menu_entries() -> Menu {
188 + Menu::new(vec![
189 + MenuEntry::action(tr("New Window")).shortcut("Ctrl+N"),
190 + MenuEntry::action(tr("Keyboard Shortcuts")),
191 + MenuEntry::separator(),
192 + MenuEntry::action(tr("Settings")),
193 + MenuEntry::action(tr("About")),
194 + ])
195 +}
196 +
197 +/// Pasek zakładek demo z przetłumaczonymi etykietami (tytuły w `TabView` to klucze).
198 +pub fn tab_bar(demo: &Demo) -> TabBar {
199 + let tabs: Vec<Tab> = demo
200 + .tabs
201 + .tabs()
202 + .iter()
203 + .map(|t| Tab::new(tr(&t.title)).closable(t.closable))
204 + .collect();
205 + let mut bar = TabBar::with_tabs(tabs);
206 + bar.set_selected(demo.tabs.selected());
207 + bar.set_hovered(demo.tabs_hover);
208 + bar
209 +}
210 +
211 +/// Pasek ścieżki demo z przetłumaczonymi etykietami (składniki to klucze i18n).
212 +pub fn path_bar(demo: &Demo) -> PathBar {
213 + let mut bar = PathBar::new(demo.path.components().iter().map(|k| tr(k)));
214 + bar.set_hovered(demo.path_hover);
215 + bar
216 +}
217 +
218 +/// Widok elementów demo z przetłumaczonymi etykietami (etykiety to klucze i18n).
219 +pub fn item_view(demo: &Demo) -> ItemView {
220 + let items: Vec<Item> = demo
221 + .items
222 + .items()
223 + .iter()
224 + .map(|it| {
225 + let mut item = Item::new(tr(&it.label)).kind(it.kind);
226 + if let Some(detail) = &it.detail {
227 + item = item.detail(detail.clone());
228 + }
229 + item
230 + })
231 + .collect();
232 + let mut view = ItemView::new(items);
233 + view.set_mode(demo.items.mode());
234 + view.set_selected(demo.items.selected());
235 + view.set_hovered(demo.items_hover);
236 + view
237 +}
238 +
239 +/// Kody języków i kontrolka wyboru (nazwy natywne).
240 +pub fn language_dropdown(codes: &[String]) -> DropDown {
241 + let names: Vec<String> = codes
242 + .iter()
243 + .map(|c| libluna_i18n::language_name(c).unwrap_or_else(|| c.to_uppercase()))
244 + .collect();
245 + let mut dd = DropDown::new(names);
246 + let current = libluna_i18n::locale().language().to_owned();
247 + if let Some(i) = codes.iter().position(|c| *c == current) {
248 + dd.set_selected(i);
249 + }
250 + dd
251 +}
252 +
253 +fn theme_switcher(preference: ThemePreference, hovered: Option<usize>) -> ViewSwitcher {
254 + let mut vs = ViewSwitcher::new(segment_labels().map(tr));
255 + let index = ThemePreference::ALL
256 + .iter()
257 + .position(|p| *p == preference)
258 + .unwrap_or(0);
259 + vs.set_selected(index);
260 + vs.set_hovered(hovered);
261 + vs
262 +}
263 +
264 +fn view_switcher(view_mode: usize, hovered: Option<usize>) -> ViewSwitcher {
265 + let mut vs = ViewSwitcher::new([tr("List"), tr("Grid")]);
266 + vs.set_selected(view_mode);
267 + vs.set_hovered(hovered);
268 + vs
269 +}
270 +
271 +/// Panel otwartego popoveru wraz z jego widgetem.
272 +pub fn popover_menu(
273 + size: Size,
274 + theme: &Theme,
275 + text: &TextEngine,
276 + lay: &Layout,
277 + demo: &Demo,
278 +) -> Option<(Rect, Menu)> {
279 + let viewport = Rect::from_xywh(0.0, 0.0, size.width, size.height);
280 + match demo.popover? {
281 + Popover::Menu => {
282 + let menu = menu_entries();
283 + let w = menu.width(text, theme);
284 + Some((menu.place(lay.burger, w, viewport, theme), menu))
285 + }
286 + Popover::Language => {
287 + let codes = libluna_i18n::languages();
288 + let menu = language_dropdown(&codes).menu();
289 + let w = menu.width(text, theme);
290 + Some((menu.place(lay.language, w, viewport, theme), menu))
291 + }
292 + }
293 +}
294 +
295 +// --- Układ ---
296 +
297 +pub fn layout(size: Size, theme: &Theme, demo: &Demo) -> Layout {
298 + let lg = theme.spacing.lg;
299 + let md = theme.spacing.md;
300 + let header_h = theme.metrics.headerbar.height;
301 + let control = theme.metrics.headerbar.control;
302 + let switch_m = theme.metrics.switch;
303 +
304 + let status_h = if demo.show_bottom_bar { STATUS_H } else { 0.0 };
305 + let content_h = (size.height - header_h - status_h).max(0.0);
306 +
307 + let sidebar_w = if demo.sidebar_visible {
308 + demo.sidebar_width
309 + } else {
310 + 0.0
311 + };
312 + let sidebar = Rect::from_xywh(0.0, header_h, sidebar_w, content_h);
313 + let sidebar_handle = if demo.sidebar_visible {
314 + Rect::from_xywh(sidebar_w - HANDLE_W / 2.0, header_h, HANDLE_W, content_h)
315 + } else {
316 + Rect::ZERO
317 + };
318 + let area = Rect::from_xywh(
319 + sidebar_w,
320 + header_h,
321 + (size.width - sidebar_w).max(0.0),
322 + content_h,
323 + );
324 +
325 + // Pasek tytułu jako widget: kontrolki okna + obszar na treść.
326 + let header_rect = Rect::from_xywh(0.0, 0.0, size.width, header_h);
327 + let header = header_bar().controls_layout(header_rect, theme);
328 + let hb_cy = header_rect.center().y;
329 + let content = header.content;
330 + let sidebar_button = Rect::from_xywh(content.x(), hb_cy - control / 2.0, control, control);
331 + let plus = Rect::from_xywh(
332 + sidebar_button.right() + PAD,
333 + hb_cy - control / 2.0,
334 + control,
335 + control,
336 + );
337 + let options_button = Rect::from_xywh(plus.right() + PAD, hb_cy - control / 2.0, 110.0, control);
338 + let burger = Rect::from_xywh(
339 + content.right() - control,
340 + hb_cy - control / 2.0,
341 + control,
342 + control,
343 + );
344 +
345 + // Karta 1: Wygląd (3 wiersze).
346 + let card = Rect::from_xywh(
347 + area.x() + lg,
348 + area.y() + lg,
349 + (area.width() - 2.0 * lg).max(0.0),
350 + 208.0,
351 + );
352 + let header_h = 40.0;
353 + let row_h = 56.0;
354 + let row = |i: usize| {
355 + Rect::from_xywh(
356 + card.x(),
357 + card.y() + header_h + i as f64 * row_h,
358 + card.width(),
359 + row_h,
360 + )
361 + };
362 +
363 + // Wiersz 0: tryb (jasny/ciemny/auto).
364 + let seg_total = 240.0;
365 + let r0 = row(0);
366 + let theme_switcher = Rect::from_xywh(
367 + r0.right() - md - seg_total,
368 + r0.center().y - 16.0,
369 + seg_total,
370 + 32.0,
371 + );
372 +
373 + // Wiersz 1: akcent.
374 + let dot = 22.0;
375 + let gap = 10.0;
376 + let dots_w = Accent::ALL.len() as f64 * dot + (Accent::ALL.len() - 1) as f64 * gap;
377 + let r1 = row(1);
378 + let dots_x = r1.right() - md - dots_w;
379 + let accents = std::array::from_fn(|i| {
380 + Rect::from_xywh(
381 + dots_x + i as f64 * (dot + gap),
382 + r1.center().y - dot / 2.0,
383 + dot,
384 + dot,
385 + )
386 + });
387 +
388 + // Wiersz 2: język.
389 + let r2 = row(2);
390 + let dd_h = theme.metrics.dropdown.height;
391 + let language = Rect::from_xywh(
392 + r2.right() - md - 170.0,
393 + r2.center().y - dd_h / 2.0,
394 + 170.0,
395 + dd_h,
396 + );
397 +
398 + // Karta 2: Ustawienia (5 wierszy).
399 + let card2 = Rect::from_xywh(area.x() + lg, card.bottom() + lg, card.width(), 320.0);
400 + let row2 = |i: usize| {
401 + Rect::from_xywh(
402 + card2.x(),
403 + card2.y() + header_h + i as f64 * row_h,
404 + card2.width(),
405 + row_h,
406 + )
407 + };
408 + let switch = |r: Rect| {
409 + Rect::from_xywh(
410 + r.right() - md - switch_m.width,
411 + r.center().y - switch_m.height / 2.0,
412 + switch_m.width,
413 + switch_m.height,
414 + )
415 + };
416 + let option_a = switch(row2(0));
417 + let srow = row2(1);
418 + let track_h = theme.metrics.slider.track_height;
419 + let volume_track = Rect::from_xywh(
420 + srow.right() - md - 200.0,
421 + srow.center().y - track_h / 2.0,
422 + 200.0,
423 + track_h,
424 + );
425 + let vrow = row2(2);
426 + let view_switcher = Rect::from_xywh(
427 + vrow.right() - md - 160.0,
428 + vrow.center().y - 16.0,
429 + 160.0,
430 + 32.0,
431 + );
432 + let bottom_bar = switch(row2(3));
433 + let sidebar_toggle = switch(row2(4));
434 +
435 + // Karta 3: Zakładki (pasek + wiersz z aktywną stroną).
436 + let card3 = Rect::from_xywh(area.x() + lg, card2.bottom() + lg, card.width(), 152.0);
437 + let row3 = |i: usize| {
438 + Rect::from_xywh(
439 + card3.x(),
440 + card3.y() + header_h + i as f64 * row_h,
441 + card3.width(),
442 + row_h,
443 + )
444 + };
445 + let tabs_h = theme.metrics.tabs.height;
446 + let tabs_w = 420.0;
447 + let trow = row3(0);
448 + let tabs = Rect::from_xywh(
449 + trow.right() - md - tabs_w,
450 + trow.center().y - tabs_h / 2.0,
451 + tabs_w,
452 + tabs_h,
453 + );
454 + let tabs_value = row3(1);
455 +
456 + // Karta 4: Pliki (PathBar + SearchEntry + ItemView).
457 + let card4 = Rect::from_xywh(area.x() + lg, card3.bottom() + lg, card.width(), 196.0);
458 + let row4 = Rect::from_xywh(card4.x(), card4.y() + header_h, card4.width(), row_h);
459 + let path_h = theme.metrics.pathbar.height;
460 + let path = Rect::from_xywh(
461 + card4.x() + md,
462 + row4.center().y - path_h / 2.0,
463 + 360.0,
464 + path_h,
465 + );
466 + let entry_h = theme.metrics.entry.height;
467 + let search = Rect::from_xywh(
468 + row4.right() - md - 240.0,
469 + row4.center().y - entry_h / 2.0,
470 + 240.0,
471 + entry_h,
472 + );
473 + let items = Rect::from_xywh(
474 + card4.x() + md,
475 + card4.y() + header_h + row_h,
476 + (card4.width() - 2.0 * md).max(0.0),
477 + (card4.height() - header_h - row_h - md).max(0.0),
478 + );
479 +
480 + Layout {
481 + sidebar,
482 + sidebar_handle,
483 + area,
484 + sidebar_button,
485 + sidebar_visible: demo.sidebar_visible,
486 + options_button,
487 + header,
488 + theme_switcher,
489 + accents,
490 + language,
491 + view_switcher,
492 + option_a,
493 + volume_track,
494 + bottom_bar,
495 + sidebar_toggle,
496 + tabs,
497 + tabs_value,
498 + path,
499 + search,
500 + items,
501 + burger,
502 + }
503 +}
504 +
505 +// --- Obsługa zdarzeń ---
506 +
507 +/// Reaguje na zdarzenia: kliknięcia, przeciąganie (pasek boczny, suwak) i skróty.
508 +///
509 +/// Nie zależy od Waylanda — bierze zdarzenia i ustawienia, więc da się ją wywołać
510 +/// także w testach i w headlessowym „driverze” (bez okna).
511 +pub fn handle_events(
512 + settings: &mut ThemeSettings,
513 + events: &[Event],
514 + size: Size,
515 + theme: &Theme,
516 + text: &TextEngine,
517 + demo: &mut Demo,
518 +) {
519 + let lay = layout(size, theme, demo);
520 + let theme_vs = ViewSwitcher::new(segment_labels().map(tr));
521 + let view_vs = ViewSwitcher::new([tr("List"), tr("Grid")]);
522 + let tabs_bar = tab_bar(demo);
523 + let path = path_bar(demo);
524 + let items = item_view(demo);
525 + let nav = make_nav();
526 + let slider = Slider::new(demo.volume);
527 + let popover = popover_menu(size, theme, text, &lay, demo);
528 + let header_rect = Rect::from_xywh(0.0, 0.0, size.width, theme.metrics.headerbar.height);
529 + let header = header_bar();
530 +
531 + for event in events {
532 + match event {
533 + Event::PointerButton(pe) if pe.button == PointerButton::Left => {
534 + let p = pe.position;
535 + match pe.state {
536 + ButtonState::Pressed => {
537 + // Dolny panel (modalny) ma pierwszeństwo nad resztą UI.
538 + if demo.sheet.is_open() {
539 + let area = demo.sheet.content_rect(size, theme);
540 + if demo.sheet.close_at(size, theme, p) {
541 + demo.sheet.set_open(false);
542 + } else if demo.sheet.drag_zone_at(size, theme, p) {
543 + demo.sheet.begin_drag(p.y);
544 + } else if demo.sheet.scrim_at(size, theme, p) {
545 + demo.sheet.set_open(false);
546 + } else if area.contains(p)
547 + && !demo.pager.click_indicator(area, theme, p)
548 + {
549 + demo.pager_dragging = true;
550 + demo.pager.begin_drag(p.x);
551 + }
552 + continue;
553 + }
554 +
555 + // Gdy popover otwarty, klik wybiera pozycję albo zamyka.
556 + if demo.popover.is_some() {
557 + if let (Some((panel, menu)), Some(kind)) = (&popover, demo.popover) {
558 + if let Some(entry) = menu.index_at(*panel, p, theme) {
559 + if kind == Popover::Language {
560 + let codes = libluna_i18n::languages();
561 + if let Some(code) = codes.get(entry) {
562 + libluna_i18n::set_locale(Locale::new(code));
563 + }
564 + } else if entry == ABOUT_ENTRY {
565 + // „O aplikacji” otwiera dolny panel.
566 + demo.sheet.set_open(true);
567 + }
568 + }
569 + }
570 + demo.popover = None;
571 + demo.popover_hover = None;
572 + continue;
573 + }
574 +
575 + // Klik poza polem wyszukiwania odbiera mu fokus.
576 + if !demo.search.contains(lay.search, p) {
577 + demo.search.set_focused(false);
578 + }
579 +
580 + if let Some(control) = header.control_at(header_rect, theme, p) {
581 + match control {
582 + WindowControl::Minimize => demo.pending_minimize = true,
583 + WindowControl::Maximize => demo.pending_maximize = true,
584 + WindowControl::Close => demo.pending_close = true,
585 + }
586 + } else if lay.burger.contains(p) {
587 + demo.popover = Some(Popover::Menu);
588 + } else if lay.language.contains(p) {
589 + demo.popover = Some(Popover::Language);
590 + } else if lay.sidebar_handle.contains(p) {
591 + demo.dragging_sidebar = true;
592 + } else if let Some(i) = theme_vs.index_at(lay.theme_switcher, p) {
593 + settings.set_preference(ThemePreference::ALL[i]);
594 + } else if let Some(i) = view_vs.index_at(lay.view_switcher, p) {
595 + demo.view_mode = i;
596 + demo.items.set_mode(if i == 1 {
597 + ViewMode::Grid
598 + } else {
599 + ViewMode::List
600 + });
601 + } else if let Some(i) = tabs_bar.index_at(lay.tabs, text, theme, p) {
602 + demo.tabs.select(i);
603 + } else if demo.search.contains(lay.search, p) {
604 + if demo.search.clear_at(lay.search, theme, p) {
605 + demo.search.clear();
606 + }
607 + demo.search.set_focused(true);
608 + } else if let Some(i) = path.index_at(lay.path, text, theme, p) {
609 + demo.path.truncate(i + 1);
610 + } else if let Some(i) = items.index_at(lay.items, p, theme) {
611 + demo.items.set_selected(Some(i));
612 + } else if let Some(i) = nav.index_at(lay.sidebar, p, theme) {
613 + demo.nav_selected = Some(i);
614 + } else if lay.option_a.contains(p) {
615 + demo.option_a = !demo.option_a;
616 + } else if slider.hit(lay.volume_track, p, theme) {
617 + demo.volume_dragging = true;
618 + demo.volume = slider.value_at(lay.volume_track, p);
619 + } else if lay.bottom_bar.contains(p) {
620 + demo.show_bottom_bar = !demo.show_bottom_bar;
621 + } else if lay.sidebar_toggle.contains(p) || lay.sidebar_button.contains(p) {
622 + demo.sidebar_visible = !demo.sidebar_visible;
623 + } else {
624 + for (i, r) in lay.accents.iter().enumerate() {
625 + if r.contains(p) {
626 + settings.set_accent(Accent::ALL[i]);
627 + }
628 + }
629 + }
630 + }
631 + ButtonState::Released => {
632 + demo.dragging_sidebar = false;
633 + demo.volume_dragging = false;
634 + if demo.pager_dragging {
635 + demo.pager.end_drag();
636 + demo.pager_dragging = false;
637 + }
638 + if demo.sheet.is_open() {
639 + demo.sheet.end_drag(theme);
640 + }
641 + }
642 + }
643 + }
644 + Event::PointerMotion { position, .. } => {
645 + let position = *position;
646 + if demo.sheet.is_open() {
647 + let close = demo.sheet.close_at(size, theme, position);
648 + demo.sheet.set_hover_close(close);
649 + demo.sheet.update_drag(position.y);
650 + if demo.pager_dragging {
651 + let area = demo.sheet.content_rect(size, theme);
652 + demo.pager.update_drag(position.x, area);
653 + }
654 + continue;
655 + }
656 + if demo.dragging_sidebar && demo.sidebar_visible {
657 + demo.sidebar_width = position.x.clamp(MIN_SIDEBAR_W, MAX_SIDEBAR_W);
658 + }
659 + if demo.volume_dragging {
660 + demo.volume = slider.value_at(lay.volume_track, position);
661 + }
662 +
663 + if demo.popover.is_some() {
664 + demo.popover_hover = popover
665 + .as_ref()
666 + .and_then(|(panel, menu)| menu.index_at(*panel, position, theme));
667 + } else {
668 + demo.nav_hover = nav.index_at(lay.sidebar, position, theme);
669 + demo.theme_hover = theme_vs.index_at(lay.theme_switcher, position);
670 + demo.view_hover = view_vs.index_at(lay.view_switcher, position);
671 + demo.options_hover = lay.options_button.contains(position);
672 + demo.switch_hover = lay.option_a.contains(position);
673 + demo.tabs_hover = tabs_bar.index_at(lay.tabs, text, theme, position);
674 + demo.path_hover = path.index_at(lay.path, text, theme, position);
675 + demo.items_hover = items.index_at(lay.items, position, theme);
676 + demo.search
677 + .set_hover_clear(demo.search.clear_at(lay.search, theme, position));
678 + demo.controls_hover = header.control_at(header_rect, theme, position);
679 + }
680 + }
681 + Event::PointerLeave => {
682 + demo.dragging_sidebar = false;
683 + demo.volume_dragging = false;
684 + demo.pager_dragging = false;
685 + demo.nav_hover = None;
686 + demo.theme_hover = None;
687 + demo.view_hover = None;
688 + demo.options_hover = false;
689 + demo.switch_hover = false;
690 + demo.tabs_hover = None;
691 + demo.path_hover = None;
692 + demo.items_hover = None;
693 + demo.search.set_hover_clear(false);
694 + demo.sheet.set_hover_close(false);
695 + demo.controls_hover = None;
696 + }
697 + Event::Key(ke) if ke.state == ButtonState::Pressed => {
698 + // Gdy pole wyszukiwania ma fokus, klawisze idą do niego.
699 + if demo.search.is_focused() && handle_search_key(&mut demo.search, ke) {
700 + continue;
701 + }
702 + match ke.keysym {
703 + // `l` — przełącz jasny/ciemny
704 + 0x6c => {
705 + let target = if theme.appearance == Appearance::Dark {
706 + ThemePreference::Light
707 + } else {
708 + ThemePreference::Dark
709 + };
710 + settings.set_preference(target);
711 + }
712 + // `a` — następny akcent
713 + 0x61 => {
714 + let idx = Accent::ALL
715 + .iter()
716 + .position(|a| *a == settings.accent())
717 + .unwrap_or(0);
718 + settings.set_accent(Accent::ALL[(idx + 1) % Accent::ALL.len()]);
719 + }
720 + // `g` — następny język
721 + 0x67 => cycle_language(),
722 + // `b` — pokaż/ukryj pasek dolny
723 + 0x62 => demo.show_bottom_bar = !demo.show_bottom_bar,
724 + // `s` — pokaż/ukryj pasek boczny
725 + 0x73 => demo.sidebar_visible = !demo.sidebar_visible,
726 + // `v` — przełącz widok (Lista/Siatka)
727 + 0x76 => demo.view_mode = (demo.view_mode + 1) % 2,
728 + // `o` — pokaż/ukryj dolny panel („O aplikacji”)
729 + 0x6f => demo.sheet.set_open(!demo.sheet.is_open()),
730 + _ => {}
731 + }
732 + }
733 + _ => {}
734 + }
735 + }
736 +}
737 +
738 +fn cycle_language() {
739 + let languages = libluna_i18n::languages();
740 + if languages.is_empty() {
741 + return;
742 + }
743 + let current = libluna_i18n::locale().language().to_owned();
744 + let idx = languages.iter().position(|l| *l == current).unwrap_or(0);
745 + let next = languages[(idx + 1) % languages.len()].clone();
746 + libluna_i18n::set_locale(Locale::new(&next));
747 +}
748 +
749 +/// Kieruje pojedyncze zdarzenie klawiatury do pola wyszukiwania.
750 +///
751 +/// Zwraca `true`, jeśli klawisz został zużyty (wtedy skróty demo są pomijane).
752 +fn handle_search_key(entry: &mut SearchEntry, ke: &KeyEvent) -> bool {
753 + // Tekst złożony przez platformę (obsługuje też znaki spoza ASCII).
754 + if let Some(t) = &ke.text {
755 + if !t.is_empty() && !t.chars().all(char::is_control) {
756 + entry.insert_str(t);
757 + return true;
758 + }
759 + }
760 + match ke.keysym {
761 + 0xff08 => entry.backspace(), // BackSpace
762 + 0xffff => entry.delete_forward(), // Delete
763 + 0xff51 => entry.move_left(), // Left
764 + 0xff53 => entry.move_right(), // Right
765 + 0xff50 => entry.home(), // Home
766 + 0xff57 => entry.end(), // End
767 + 0x20..=0x7e => {
768 + if let Some(c) = char::from_u32(ke.keysym) {
769 + entry.insert_char(c);
770 + }
771 + }
772 + _ => return false,
773 + }
774 + true
775 +}
776 +
777 +// --- Budowa sceny ---
778 +
779 +/// Buduje scenę makiety Ustawień o zadanym rozmiarze (piksele logiczne).
780 +pub fn build_scene(
781 + scene: &mut libluna_render::Scene,
782 + theme: &Theme,
783 + text: &TextEngine,
784 + size: Size,
785 + demo: &Demo,
786 + preference: ThemePreference,
787 + accent: Accent,
788 +) {
789 + let lay = layout(size, theme, demo);
790 + let mut paint = PaintCtx { scene, theme, text };
791 +
792 + let (w, h) = (size.width, size.height);
793 + let bg = theme.colors.window_bg;
794 + paint.scene.fill_rect(Rect::from_xywh(0.0, 0.0, w, h), bg);
795 +
796 + draw_headerbar(
797 + &mut paint,
798 + Rect::from_xywh(0.0, 0.0, w, theme.metrics.headerbar.height),
799 + &lay,
800 + demo,
801 + );
802 + if lay.sidebar.width() > 0.0 {
803 + draw_sidebar(&mut paint, &lay, demo);
804 + }
805 + draw_content(&mut paint, lay.area, &lay, demo, preference, accent);
806 + if demo.show_bottom_bar {
807 + draw_statusbar(&mut paint, Rect::from_xywh(0.0, h - STATUS_H, w, STATUS_H));
808 + }
809 +
810 + // Popover („☰" lub wybór języka) rysowany na wierzchu.
811 + if let Some((panel, mut menu)) = popover_menu(size, theme, text, &lay, demo) {
812 + menu.set_hovered(demo.popover_hover);
813 + menu.paint(&mut paint, panel);
814 + }
815 +
816 + // Dolny panel (modalny) rysowany na samym wierzchu.
817 + if demo.sheet.is_open() {
818 + let mut sheet = demo.sheet.clone();
819 + sheet.set_title(Some(tr("About")));
820 + sheet.paint(&mut paint, size);
821 + draw_sheet_content(&mut paint, demo, size);
822 + }
823 +}
824 +
825 +/// Zawartość dolnego panelu: karuzela stron (`Pager`) z kropkami-wskaźnikami.
826 +fn draw_sheet_content(paint: &mut PaintCtx, demo: &Demo, size: Size) {
827 + let theme = *paint.theme;
828 + let content = demo.sheet.content_rect(size, &theme);
829 + let pager = &demo.pager;
830 +
831 + // Obszar stron (nad kropkami).
832 + let dots_h = theme.metrics.pager.dot + theme.metrics.pager.pad;
833 + let pages = Rect::from_xywh(
834 + content.x(),
835 + content.y(),
836 + content.width(),
837 + (content.height() - dots_h).max(0.0),
838 + );
839 +
840 + paint.scene.push_clip(pages);
841 + for i in 0..pager.count() {
842 + draw_about_page(paint, pager.page_rect(pages, i), i);
843 + }
844 + paint.scene.pop_clip();
845 +
846 + pager.paint(paint, content);
847 +}
848 +
849 +/// Pojedyncza strona karuzeli w panelu „O aplikacji”.
850 +fn draw_about_page(paint: &mut PaintCtx, rect: Rect, index: usize) {
851 + let theme = *paint.theme;
852 + let (heading, body) = match index {
853 + 0 => (tr("About"), "libLuna 0.0.1".to_owned()),
854 + 1 => (tr("Keyboard Shortcuts"), "l · a · g · b · s · v".to_owned()),
855 + _ => (tr("License"), "MPL-2.0".to_owned()),
856 + };
857 + let cx = rect.center().x;
858 + let cy = rect.center().y;
859 + paint.label_center(
860 + cx,
861 + cy - 14.0,
862 + &heading,
863 + theme.typography.heading.size,
864 + weight::BOLD,
865 + theme.colors.text,
866 + );
867 + paint.label_center(
868 + cx,
869 + cy + 14.0,
870 + &body,
871 + theme.typography.body.size,
872 + weight::NORMAL,
873 + theme.colors.dim_text,
874 + );
875 +}
876 +
877 +fn draw_headerbar(paint: &mut PaintCtx, bar: Rect, lay: &Layout, demo: &Demo) {
878 + let theme = *paint.theme;
879 +
880 + // Tło, tytuł i kontrolki okna rysuje widget.
881 + header_bar().paint(paint, bar, demo.controls_hover);
882 +
883 + let cy = bar.center().y;
884 +
885 + // Ikona-przełącznik paska bocznego (◧ / ▭).
886 + let sidebar_button = lay.sidebar_button;
887 + paint
888 + .scene
889 + .fill_rounded_rect(sidebar_button, theme.radius.small, theme.colors.shade);
890 + draw_sidebar_icon(
891 + paint,
892 + theme.colors.text,
893 + sidebar_button,
894 + lay.sidebar_visible,
895 + );
896 +
897 + let control = theme.metrics.headerbar.control;
898 + let plus = Rect::from_xywh(
899 + sidebar_button.right() + PAD,
900 + cy - control / 2.0,
901 + control,
902 + control,
903 + );
904 + paint
905 + .scene
906 + .fill_rounded_rect(plus, theme.radius.small, theme.colors.shade);
907 + draw_plus(paint, theme.colors.text, plus, 5.0);
908 +
909 + // Przycisk „Opcje ▾” jako widget.
910 + let mut options = Button::new(tr("Options")).dropdown(true);
911 + options.set_hovered(demo.options_hover);
912 + options.paint(paint, lay.options_button);
913 +
914 + // Menu „☰”.
915 + let burger = lay.burger;
916 + paint
917 + .scene
918 + .fill_rounded_rect(burger, theme.radius.small, theme.colors.shade);
919 + draw_burger(paint, theme.colors.text, burger, 8.0);
920 +}
921 +
922 +fn draw_sidebar(paint: &mut PaintCtx, lay: &Layout, demo: &Demo) {
923 + let sb = lay.sidebar;
924 + paint.scene.fill_rect(sb, paint.theme.colors.sidebar_bg);
925 + paint.scene.fill_rect(
926 + Rect::from_xywh(sb.right() - 1.0, sb.y(), 1.0, sb.height()),
927 + paint.theme.colors.border,
928 + );
929 +
930 + let mut nav = make_nav();
931 + nav.set_selected(demo.nav_selected);
932 + nav.set_hovered(demo.nav_hover);
933 + nav.paint(paint, sb);
934 +}
935 +
936 +fn draw_content(
937 + paint: &mut PaintCtx,
938 + area: Rect,
939 + lay: &Layout,
940 + demo: &Demo,
941 + preference: ThemePreference,
942 + accent: Accent,
943 +) {
944 + let theme = *paint.theme;
945 + paint.scene.fill_rect(area, theme.colors.view_bg);
946 +
947 + let lg = theme.spacing.lg;
948 + let md = theme.spacing.md;
949 + let body = theme.typography.body.size;
950 +
951 + // --- Karta 1: Wygląd (3 wiersze) ---
952 + let card = Rect::from_xywh(
953 + area.x() + lg,
954 + area.y() + lg,
955 + (area.width() - 2.0 * lg).max(0.0),
956 + 208.0,
957 + );
958 + draw_card(paint, card, &tr("Appearance"));
959 +
960 + paint.label_left(
961 + card.x() + md,
962 + lay.theme_switcher.center().y,
963 + &tr("Theme"),
964 + body,
965 + weight::NORMAL,
966 + theme.colors.text,
967 + );
968 + theme_switcher(preference, demo.theme_hover).paint(paint, lay.theme_switcher);
969 +
970 + paint.label_left(
971 + card.x() + md,
972 + lay.accents[0].center().y,
973 + &tr("Accent color"),
974 + body,
975 + weight::NORMAL,
976 + theme.colors.text,
977 + );
978 + draw_accent_dots(paint, &lay.accents, accent);
979 +
980 + paint.label_left(
981 + card.x() + md,
982 + lay.language.center().y,
983 + &tr("Language"),
984 + body,
985 + weight::NORMAL,
986 + theme.colors.text,
987 + );
988 + let codes = libluna_i18n::languages();
989 + language_dropdown(&codes).paint(paint, lay.language);
990 +
991 + // --- Karta 2: Ustawienia ---
992 + let card2 = Rect::from_xywh(card.x(), card.bottom() + lg, card.width(), 320.0);
993 + draw_card(paint, card2, &tr("Settings"));
994 +
995 + paint.label_left(
996 + card2.x() + md,
997 + lay.option_a.center().y,
998 + &tr("Option A"),
999 + body,
1000 + weight::NORMAL,
1001 + theme.colors.text,
1002 + );
1003 + let mut sw = Switch::new(demo.option_a);
1004 + sw.set_hovered(demo.switch_hover);
1005 + sw.paint(paint, lay.option_a);
1006 +
1007 + paint.label_left(
1008 + card2.x() + md,
1009 + lay.volume_track.center().y,
1010 + &tr("Volume level"),
1011 + body,
1012 + weight::NORMAL,
1013 + theme.colors.text,
1014 + );
1015 + Slider::new(demo.volume).paint(paint, lay.volume_track);
1016 + paint.label_right(
1017 + lay.volume_track.x() - 12.0,
1018 + lay.volume_track.center().y,
1019 + &format!("{}%", (demo.volume * 100.0).round() as i32),
1020 + body,
1021 + weight::NORMAL,
1022 + theme.colors.text,
1023 + );
1024 +
1025 + paint.label_left(
1026 + card2.x() + md,
1027 + lay.view_switcher.center().y,
1028 + &tr("View"),
1029 + body,
1030 + weight::NORMAL,
1031 + theme.colors.text,
1032 + );
1033 + view_switcher(demo.view_mode, demo.view_hover).paint(paint, lay.view_switcher);
1034 +
1035 + paint.label_left(
1036 + card2.x() + md,
1037 + lay.bottom_bar.center().y,
1038 + &tr("Bottom bar"),
1039 + body,
1040 + weight::NORMAL,
1041 + theme.colors.text,
1042 + );
1043 + Switch::new(demo.show_bottom_bar).paint(paint, lay.bottom_bar);
1044 +
1045 + paint.label_left(
1046 + card2.x() + md,
1047 + lay.sidebar_toggle.center().y,
1048 + &tr("Sidebar"),
1049 + body,
1050 + weight::NORMAL,
1051 + theme.colors.text,
1052 + );
1053 + Switch::new(demo.sidebar_visible).paint(paint, lay.sidebar_toggle);
1054 +
1055 + // --- Karta 3: Zakładki ---
1056 + let card3 = Rect::from_xywh(card.x(), card2.bottom() + lg, card.width(), 152.0);
1057 + draw_card(paint, card3, &tr("Tabs"));
1058 +
1059 + // Wiersz 0: pasek zakładek (widget TabBar).
1060 + tab_bar(demo).paint(paint, lay.tabs);
1061 +
1062 + // Wiersz 1: aktywna strona (model TabView).
1063 + paint.label_left(
1064 + card3.x() + md,
1065 + lay.tabs_value.center().y,
1066 + &tr("Active tab"),
1067 + body,
1068 + weight::NORMAL,
1069 + theme.colors.text,
1070 + );
1071 + let active = demo.tabs.selected_title().map(tr).unwrap_or_default();
1072 + paint.label_right(
1073 + lay.tabs_value.right() - md,
1074 + lay.tabs_value.center().y,
1075 + &active,
1076 + body,
1077 + weight::MEDIUM,
1078 + theme.colors.text,
1079 + );
1080 +
1081 + // --- Karta 4: Pliki (PathBar + SearchEntry + ItemView) ---
1082 + let card4 = Rect::from_xywh(card.x(), card3.bottom() + lg, card.width(), 196.0);
1083 + draw_card(paint, card4, &tr("Files"));
1084 + path_bar(demo).paint(paint, lay.path);
1085 +
1086 + let mut search = demo.search.clone();
1087 + search.set_placeholder(tr("Search"));
1088 + search.paint(paint, lay.search);
1089 +
1090 + item_view(demo).paint(paint, lay.items);
1091 +}
1092 +
1093 +fn draw_card(paint: &mut PaintCtx, card: Rect, title: &str) {
1094 + let theme = *paint.theme;
1095 + paint
1096 + .scene
1097 + .fill_rounded_rect(card, theme.radius.large, theme.colors.card_bg);
1098 + paint
1099 + .scene
1100 + .stroke_rounded_rect(card, theme.radius.large, 1.0, theme.colors.border);
1101 + let header_h = 40.0;
1102 + paint.label_left(
1103 + card.x() + theme.spacing.md,
1104 + card.y() + header_h / 2.0,
1105 + title,
1106 + theme.typography.body.size,
1107 + weight::BOLD,
1108 + theme.colors.text,
1109 + );
1110 + paint.scene.fill_rect(
1111 + Rect::from_xywh(card.x(), card.y() + header_h, card.width(), 1.0),
1112 + theme.colors.border,
1113 + );
1114 +}
1115 +
1116 +fn draw_accent_dots(paint: &mut PaintCtx, dots: &[Rect; 9], accent: Accent) {
1117 + let theme = *paint.theme;
1118 + for (i, r) in dots.iter().enumerate() {
1119 + let a = Accent::ALL[i];
1120 + paint
1121 + .scene
1122 + .fill_rounded_rect(*r, r.width() / 2.0, a.color());
1123 + if a == accent {
1124 + let ring = r.inflate(Insets::splat(3.0));
1125 + paint
1126 + .scene
1127 + .stroke_rounded_rect(ring, ring.width() / 2.0, 2.0, theme.colors.text);
1128 + }
1129 + }
1130 +}
1131 +
1132 +fn draw_statusbar(paint: &mut PaintCtx, bar: Rect) {
1133 + let theme = *paint.theme;
1134 + let body = theme.typography.body.size;
1135 + paint.scene.fill_rect(bar, theme.colors.headerbar_bg);
1136 + paint.scene.fill_rect(
1137 + Rect::from_xywh(bar.x(), bar.y(), bar.width(), 1.0),
1138 + theme.colors.border,
1139 + );
1140 + paint.label_left(
1141 + bar.x() + PAD,
1142 + bar.center().y,
1143 + &tr("Status: Ready"),
1144 + body,
1145 + weight::NORMAL,
1146 + theme.colors.dim_text,
1147 + );
1148 + let version = format!("{} 1.0.0", tr("Version"));
1149 + paint.label_right(
1150 + bar.right() - PAD,
1151 + bar.center().y,
1152 + &version,
1153 + body,
1154 + weight::NORMAL,
1155 + theme.colors.dim_text,
1156 + );
1157 +}
1158 +
1159 +// --- Ikony rysowane wektorowo (bez zależności od fontu) ---
1160 +
1161 +fn draw_plus(paint: &mut PaintCtx, color: Color, box_: Rect, arm: f64) {
1162 + let c = box_.center();
1163 + paint.scene.stroke_line(
1164 + Point::new(c.x - arm, c.y),
1165 + Point::new(c.x + arm, c.y),
1166 + 2.0,
1167 + color,
1168 + );
1169 + paint.scene.stroke_line(
1170 + Point::new(c.x, c.y - arm),
1171 + Point::new(c.x, c.y + arm),
1172 + 2.0,
1173 + color,
1174 + );
1175 +}
1176 +
1177 +fn draw_burger(paint: &mut PaintCtx, color: Color, box_: Rect, arm: f64) {
1178 + let c = box_.center();
1179 + for dy in [-6.0, 0.0, 6.0] {
1180 + paint.scene.stroke_line(
1181 + Point::new(c.x - arm, c.y + dy),
1182 + Point::new(c.x + arm, c.y + dy),
1183 + 2.0,
1184 + color,
1185 + );
1186 + }
1187 +}
1188 +
1189 +/// Ikona paska bocznego: `◧` gdy widoczny, `▭` (sam kontur) gdy ukryty.
1190 +fn draw_sidebar_icon(paint: &mut PaintCtx, color: Color, box_: Rect, filled: bool) {
1191 + let c = box_.center();
1192 + let w = 18.0;
1193 + let h = 14.0;
1194 + let outer = Rect::from_xywh(c.x - w / 2.0, c.y - h / 2.0, w, h);
1195 + paint.scene.stroke_rounded_rect(outer, 3.0, 1.5, color);
1196 + if filled {
1197 + let inner = Rect::from_xywh(outer.x() + 2.0, outer.y() + 2.0, w * 0.36, h - 4.0);
1198 + paint.scene.fill_rounded_rect(inner, 2.0, color);
1199 + }
1200 +}
0 1201 new file mode 100644
@@ -0,0 +1,273 @@
1 +//! Testy interakcji demo — bez okna i bez Waylanda. Przepuszczają prawdziwe zdarzenia
2 +//! przez `ui::handle_events` i sprawdzają zmiany stanu.
3 +
4 +use libluna_core::{
5 + Accent, ButtonState, Event, KeyEvent, Modifiers, Point, PointerButton, PointerEvent, Size,
6 + ThemePreference, ThemeSettings,
7 +};
8 +use libluna_render::TextEngine;
9 +use libluna_widgets::ViewSwitcher;
10 +use luna_demo::ui::{self, make_nav, Demo, Popover};
11 +
12 +fn size() -> Size {
13 + Size::new(900.0, 1028.0)
14 +}
15 +
16 +fn press(p: Point) -> Event {
17 + Event::PointerButton(PointerEvent {
18 + position: p,
19 + button: PointerButton::Left,
20 + state: ButtonState::Pressed,
21 + modifiers: Modifiers::NONE,
22 + })
23 +}
24 +
25 +/// Wysyła jedno kliknięcie do logiki demo.
26 +fn click(settings: &mut ThemeSettings, demo: &mut Demo, p: Point) {
27 + let theme = settings.resolve(false);
28 + let text = TextEngine::system();
29 + ui::handle_events(settings, &[press(p)], size(), &theme, &text, demo);
30 +}
31 +
32 +fn layout(settings: &ThemeSettings, demo: &Demo) -> ui::Layout {
33 + ui::layout(size(), &settings.resolve(false), demo)
34 +}
35 +
36 +#[test]
37 +fn clicking_dark_segment_sets_dark_theme() {
38 + let mut settings = ThemeSettings::default();
39 + let mut demo = Demo::default();
40 + let seg = ViewSwitcher::new(["a", "b", "c"]).segments(layout(&settings, &demo).theme_switcher)
41 + [1]
42 + .center();
43 + click(&mut settings, &mut demo, seg);
44 + assert_eq!(settings.preference(), ThemePreference::Dark);
45 +}
46 +
47 +#[test]
48 +fn clicking_accent_dot_sets_accent() {
49 + let mut settings = ThemeSettings::default();
50 + let mut demo = Demo::default();
51 + let dot = layout(&settings, &demo).accents[7].center(); // Purple
52 + click(&mut settings, &mut demo, dot);
53 + assert_eq!(settings.accent(), Accent::Purple);
54 +}
55 +
56 +#[test]
57 +fn clicking_nav_item_selects_it() {
58 + let mut settings = ThemeSettings::default();
59 + let mut demo = Demo::default();
60 + let theme = settings.resolve(false);
61 + let item = make_nav().rows(layout(&settings, &demo).sidebar, &theme)[5]
62 + .rect
63 + .center(); // Sieć
64 + click(&mut settings, &mut demo, item);
65 + assert_eq!(demo.nav_selected, Some(5));
66 +}
67 +
68 +#[test]
69 +fn clicking_view_switcher_changes_mode() {
70 + let mut settings = ThemeSettings::default();
71 + let mut demo = Demo::default();
72 + let seg =
73 + ViewSwitcher::new(["a", "b"]).segments(layout(&settings, &demo).view_switcher)[1].center();
74 + click(&mut settings, &mut demo, seg);
75 + assert_eq!(demo.view_mode, 1);
76 +}
77 +
78 +#[test]
79 +fn clicking_tab_selects_page() {
80 + let mut settings = ThemeSettings::default();
81 + let mut demo = Demo::default();
82 + let theme = settings.resolve(false);
83 + let text = TextEngine::system();
84 + let seg = ui::tab_bar(&demo).segments(layout(&settings, &demo).tabs, &text, &theme)[2].center();
85 + click(&mut settings, &mut demo, seg);
86 + assert_eq!(demo.tabs.selected(), 2);
87 +}
88 +
89 +#[test]
90 +fn about_menu_opens_bottom_sheet_and_scrim_closes_it() {
91 + let mut settings = ThemeSettings::default();
92 + let mut demo = Demo::default();
93 +
94 + // Otwórz menu „☰”.
95 + let burger = layout(&settings, &demo).burger.center();
96 + click(&mut settings, &mut demo, burger);
97 + assert_eq!(demo.popover, Some(Popover::Menu));
98 +
99 + // Klik „O aplikacji” (indeks 4) otwiera dolny panel.
100 + let theme = settings.resolve(false);
101 + let text = TextEngine::system();
102 + let about = ui::popover_menu(size(), &theme, &text, &layout(&settings, &demo), &demo)
103 + .map(|(panel, menu)| menu.rows(panel, &theme)[4].rect.center())
104 + .unwrap();
105 + click(&mut settings, &mut demo, about);
106 + assert!(demo.sheet.is_open());
107 +
108 + // Klik w tło (poza panelem) zamyka panel.
109 + click(&mut settings, &mut demo, Point::new(120.0, 300.0));
110 + assert!(!demo.sheet.is_open());
111 +}
112 +
113 +#[test]
114 +fn search_entry_focus_and_typing() {
115 + let mut settings = ThemeSettings::default();
116 + let mut demo = Demo::default();
117 +
118 + // Klik w pole nadaje fokus.
119 + let search = layout(&settings, &demo).search;
120 + click(&mut settings, &mut demo, search.center());
121 + assert!(demo.search.is_focused());
122 +
123 + // Wpisanie tekstu (platforma dostarcza `text`).
124 + let theme = settings.resolve(false);
125 + let text = TextEngine::system();
126 + let key = Event::Key(KeyEvent {
127 + keysym: 0x61,
128 + text: Some("a".to_owned()),
129 + state: ButtonState::Pressed,
130 + modifiers: Modifiers::NONE,
131 + repeat: false,
132 + });
133 + ui::handle_events(&mut settings, &[key], size(), &theme, &text, &mut demo);
134 + assert_eq!(demo.search.text(), "a");
135 +}
136 +
137 +#[test]
138 +fn clicking_path_component_navigates_up() {
139 + let mut settings = ThemeSettings::default();
140 + let mut demo = Demo::default();
141 + assert_eq!(demo.path.len(), 3);
142 +
143 + let theme = settings.resolve(false);
144 + let text = TextEngine::system();
145 + let segs = ui::path_bar(&demo).segments(layout(&settings, &demo).path, &text, &theme);
146 + // Klik w element bieżący nawigację „w górę” — pierwszy widoczny składnik (np. Domowy).
147 + let first = segs
148 + .iter()
149 + .find(|(pick, _)| *pick == Some(0))
150 + .map(|(_, r)| r.center())
151 + .expect("pierwszy składnik ścieżki widoczny");
152 + click(&mut settings, &mut demo, first);
153 + assert_eq!(demo.path.len(), 1);
154 +}
155 +
156 +#[test]
157 +fn clicking_file_item_selects_it() {
158 + let mut settings = ThemeSettings::default();
159 + let mut demo = Demo::default();
160 + let theme = settings.resolve(false);
161 + let slot = ui::item_view(&demo)
162 + .slot_rect(layout(&settings, &demo).items, 1, &theme)
163 + .center();
164 + click(&mut settings, &mut demo, slot);
165 + assert_eq!(demo.items.selected(), Some(1));
166 +}
167 +
168 +#[test]
169 +fn clicking_sidebar_icon_toggles_and_back() {
170 + let mut settings = ThemeSettings::default();
171 + let mut demo = Demo::default();
172 + let icon = layout(&settings, &demo).sidebar_button.center();
173 +
174 + click(&mut settings, &mut demo, icon);
175 + assert!(!demo.sidebar_visible);
176 +
177 + // Klik w to samo miejsce (layout liczy się na nowo) rozwija z powrotem.
178 + click(&mut settings, &mut demo, icon);
179 + assert!(demo.sidebar_visible);
180 +}
181 +
182 +#[test]
183 +fn dragging_sidebar_edge_resizes_within_bounds() {
184 + let mut settings = ThemeSettings::default();
185 + let mut demo = Demo::default();
186 +
187 + let edge = layout(&settings, &demo).sidebar_handle.center();
188 + click(&mut settings, &mut demo, edge);
189 + assert!(demo.dragging_sidebar);
190 +
191 + // Ruch wskaźnika mocno w prawo — szerokość przycięta do maksimum.
192 + let theme = settings.resolve(false);
193 + let text = TextEngine::system();
194 + ui::handle_events(
195 + &mut settings,
196 + &[Event::PointerMotion {
197 + position: Point::new(5000.0, 300.0),
198 + modifiers: Modifiers::NONE,
199 + }],
200 + size(),
201 + &theme,
202 + &text,
203 + &mut demo,
204 + );
205 + assert_eq!(demo.sidebar_width, 460.0);
206 +}
207 +
208 +#[test]
209 +fn burger_opens_menu_and_clicking_action_closes_it() {
210 + let mut settings = ThemeSettings::default();
211 + let mut demo = Demo::default();
212 +
213 + // Klik „☰” otwiera popover.
214 + let burger = layout(&settings, &demo).burger.center();
215 + click(&mut settings, &mut demo, burger);
216 + assert_eq!(demo.popover, Some(Popover::Menu));
217 +
218 + // Klik w pierwszą pozycję menu zamyka je.
219 + let theme = settings.resolve(false);
220 + let text = TextEngine::system();
221 + let (panel, menu) =
222 + ui::popover_menu(size(), &theme, &text, &layout(&settings, &demo), &demo).unwrap();
223 + let first = menu.rows(panel, &theme)[0].rect.center();
224 + click(&mut settings, &mut demo, first);
225 + assert!(demo.popover.is_none());
226 +}
227 +
228 +#[test]
229 +fn clicking_outside_closes_menu() {
230 + let mut settings = ThemeSettings::default();
231 + let mut demo = Demo::default();
232 + let burger = layout(&settings, &demo).burger.center();
233 + click(&mut settings, &mut demo, burger);
234 + assert_eq!(demo.popover, Some(Popover::Menu));
235 + // Klik daleko od panelu (środek treści) zamyka menu.
236 + click(&mut settings, &mut demo, Point::new(400.0, 500.0));
237 + assert!(demo.popover.is_none());
238 +}
239 +
240 +#[test]
241 +fn window_controls_are_widget_managed() {
242 + let mut settings = ThemeSettings::default();
243 + let mut demo = Demo::default();
244 + let header = layout(&settings, &demo).header;
245 +
246 + let close = header.close.expect("kontrolki po prawej").center();
247 + click(&mut settings, &mut demo, close);
248 + assert!(demo.pending_close);
249 +
250 + let minimize = header.minimize.unwrap().center();
251 + click(&mut settings, &mut demo, minimize);
252 + assert!(demo.pending_minimize);
253 +}
254 +
255 +#[test]
256 +fn language_dropdown_opens_and_selects() {
257 + let mut settings = ThemeSettings::default();
258 + let mut demo = Demo::default();
259 +
260 + // Otwarcie listy języków.
261 + let dd = layout(&settings, &demo).language.center();
262 + click(&mut settings, &mut demo, dd);
263 + assert_eq!(demo.popover, Some(Popover::Language));
264 +
265 + // Wybór ostatniej pozycji ustawia język i zamyka popover.
266 + let theme = settings.resolve(false);
267 + let text = TextEngine::system();
268 + let (panel, menu) =
269 + ui::popover_menu(size(), &theme, &text, &layout(&settings, &demo), &demo).unwrap();
270 + let last = menu.rows(panel, &theme).last().unwrap().rect.center();
271 + click(&mut settings, &mut demo, last);
272 + assert!(demo.popover.is_none());
273 +}
0 274 new file mode 100644
@@ -0,0 +1,10 @@
1 +[package]
2 +name = "libluna-core"
3 +version.workspace = true
4 +edition.workspace = true
5 +license.workspace = true
6 +rust-version.workspace = true
7 +authors.workspace = true
8 +description = "Prymitywy libLuny: geometria, kolor, jednostki, zdarzenia i tokeny stylu."
9 +
10 +[dependencies]
0 11 new file mode 100644
@@ -0,0 +1,328 @@
1 +//! Preferencje wyglądu: tryb (auto/jasny/ciemny), kolor akcentu oraz ich trwałość.
2 +//!
3 +//! Warstwa [`ThemeSettings`] jest czysta (bez I/O poza zapisem/odczytem pliku) i może
4 +//! być używana zarówno przez platformę, jak i testy. Rozwiązywanie motywu
5 +//! (`resolve`) jest deterministyczne i tanie.
6 +
7 +use std::io;
8 +use std::path::PathBuf;
9 +
10 +use crate::color::Color;
11 +use crate::style::Theme;
12 +
13 +/// Kolor akcentu (paleta zgodna z duchem GNOME 47+).
14 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15 +pub enum Accent {
16 + #[default]
17 + Blue,
18 + Teal,
19 + Green,
20 + Yellow,
21 + Orange,
22 + Red,
23 + Pink,
24 + Purple,
25 + Slate,
26 +}
27 +
28 +impl Accent {
29 + /// Wszystkie kolory akcentu w kolejności prezentacji.
30 + pub const ALL: [Accent; 9] = [
31 + Accent::Blue,
32 + Accent::Teal,
33 + Accent::Green,
34 + Accent::Yellow,
35 + Accent::Orange,
36 + Accent::Red,
37 + Accent::Pink,
38 + Accent::Purple,
39 + Accent::Slate,
40 + ];
41 +
42 + /// Stabilna nazwa (do zapisu w konfiguracji).
43 + pub const fn name(self) -> &'static str {
44 + match self {
45 + Accent::Blue => "blue",
46 + Accent::Teal => "teal",
47 + Accent::Green => "green",
48 + Accent::Yellow => "yellow",
49 + Accent::Orange => "orange",
50 + Accent::Red => "red",
51 + Accent::Pink => "pink",
52 + Accent::Purple => "purple",
53 + Accent::Slate => "slate",
54 + }
55 + }
56 +
57 + pub fn from_name(name: &str) -> Option<Accent> {
58 + Accent::ALL.into_iter().find(|a| a.name() == name)
59 + }
60 +
61 + /// Kolor akcentu (jasny wariant — wspólny dla obu motywów).
62 + pub const fn color(self) -> Color {
63 + match self {
64 + Accent::Blue => Color::from_rgba8(0x35, 0x84, 0xe4, 0xff),
65 + Accent::Teal => Color::from_rgba8(0x21, 0x90, 0xa4, 0xff),
66 + Accent::Green => Color::from_rgba8(0x3a, 0x94, 0x4a, 0xff),
67 + Accent::Yellow => Color::from_rgba8(0xc8, 0x88, 0x00, 0xff),
68 + Accent::Orange => Color::from_rgba8(0xed, 0x5b, 0x00, 0xff),
69 + Accent::Red => Color::from_rgba8(0xe6, 0x2d, 0x42, 0xff),
70 + Accent::Pink => Color::from_rgba8(0xd5, 0x61, 0x99, 0xff),
71 + Accent::Purple => Color::from_rgba8(0x91, 0x41, 0xac, 0xff),
72 + Accent::Slate => Color::from_rgba8(0x6f, 0x83, 0x96, 0xff),
73 + }
74 + }
75 +
76 + /// Kolor tekstu/ikon na akcencie (kontrast).
77 + pub const fn foreground(self) -> Color {
78 + match self {
79 + // Żółty jest zbyt jasny na biały tekst.
80 + Accent::Yellow => Color::from_rgba8(0x2e, 0x2e, 0x2e, 0xff),
81 + _ => Color::from_rgba8(0xff, 0xff, 0xff, 0xff),
82 + }
83 + }
84 +}
85 +
86 +/// Preferencja trybu wyglądu.
87 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
88 +pub enum ThemePreference {
89 + /// Podążaj za ustawieniem systemu.
90 + #[default]
91 + System,
92 + Light,
93 + Dark,
94 +}
95 +
96 +impl ThemePreference {
97 + pub const ALL: [ThemePreference; 3] = [
98 + ThemePreference::Light,
99 + ThemePreference::Dark,
100 + ThemePreference::System,
101 + ];
102 +
103 + pub const fn name(self) -> &'static str {
104 + match self {
105 + ThemePreference::System => "system",
106 + ThemePreference::Light => "light",
107 + ThemePreference::Dark => "dark",
108 + }
109 + }
110 +
111 + pub fn from_name(name: &str) -> Option<ThemePreference> {
112 + ThemePreference::ALL.into_iter().find(|p| p.name() == name)
113 + }
114 +
115 + /// Czy przy zadanym stanie systemu wybrać wariant ciemny.
116 + pub const fn is_dark(self, system_dark: bool) -> bool {
117 + match self {
118 + ThemePreference::System => system_dark,
119 + ThemePreference::Light => false,
120 + ThemePreference::Dark => true,
121 + }
122 + }
123 +
124 + /// Rozwiązuje preferencję do konkretnego motywu z zadanym akcentem.
125 + pub fn resolve(self, system_dark: bool, accent: Accent) -> Theme {
126 + let base = if self.is_dark(system_dark) {
127 + Theme::dark()
128 + } else {
129 + Theme::light()
130 + };
131 + base.accented(accent.color(), accent.foreground())
132 + }
133 +}
134 +
135 +/// Trwałe ustawienia wyglądu (tryb + akcent) z flagą „zmienione”.
136 +#[derive(Debug, Clone, Copy)]
137 +pub struct ThemeSettings {
138 + preference: ThemePreference,
139 + accent: Accent,
140 + dirty: bool,
141 +}
142 +
143 +impl Default for ThemeSettings {
144 + fn default() -> Self {
145 + Self {
146 + preference: ThemePreference::System,
147 + accent: Accent::Blue,
148 + dirty: false,
149 + }
150 + }
151 +}
152 +
153 +impl PartialEq for ThemeSettings {
154 + fn eq(&self, other: &Self) -> bool {
155 + self.preference == other.preference && self.accent == other.accent
156 + }
157 +}
158 +
159 +impl ThemeSettings {
160 + pub fn new(preference: ThemePreference, accent: Accent) -> Self {
161 + Self {
162 + preference,
163 + accent,
164 + dirty: false,
165 + }
166 + }
167 +
168 + pub const fn preference(&self) -> ThemePreference {
169 + self.preference
170 + }
171 +
172 + pub const fn accent(&self) -> Accent {
173 + self.accent
174 + }
175 +
176 + pub fn set_preference(&mut self, preference: ThemePreference) {
177 + if self.preference != preference {
178 + self.preference = preference;
179 + self.dirty = true;
180 + }
181 + }
182 +
183 + pub fn set_accent(&mut self, accent: Accent) {
184 + if self.accent != accent {
185 + self.accent = accent;
186 + self.dirty = true;
187 + }
188 + }
189 +
190 + /// Czy coś zmieniono od ostatniego [`clear_dirty`](Self::clear_dirty).
191 + pub const fn is_dirty(&self) -> bool {
192 + self.dirty
193 + }
194 +
195 + /// Zdejmuje flagę „zmienione” (opcjonalnie zapisuje stan na dysk).
196 + pub fn clear_dirty(&mut self) {
197 + self.dirty = false;
198 + }
199 +
200 + /// Rozwiązuje bieżące ustawienia do konkretnego motywu (tryb + akcent).
201 + pub fn resolve(&self, system_dark: bool) -> Theme {
202 + let base = if self.preference.is_dark(system_dark) {
203 + Theme::dark()
204 + } else {
205 + Theme::light()
206 + };
207 + base.accented(self.accent.color(), self.accent.foreground())
208 + }
209 +
210 + // --- Trwałość (prosty format `klucz = wartość`) ---
211 +
212 + /// Domyślna ścieżka konfiguracji: `$XDG_CONFIG_HOME/luna/theme.conf`.
213 + pub fn config_path() -> Option<PathBuf> {
214 + let base = std::env::var_os("XDG_CONFIG_HOME")
215 + .map(PathBuf::from)
216 + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))?;
217 + Some(base.join("luna").join("theme.conf"))
218 + }
219 +
220 + /// Wczytuje ustawienia z domyślnej ścieżki (po cichu zwraca domyślne przy błędzie).
221 + pub fn load() -> Self {
222 + Self::config_path()
223 + .and_then(|p| std::fs::read_to_string(p).ok())
224 + .map(|s| Self::from_config_str(&s))
225 + .unwrap_or_default()
226 + }
227 +
228 + /// Parsuje konfigurację z tekstu (nieznane klucze są ignorowane).
229 + pub fn from_config_str(text: &str) -> Self {
230 + let mut settings = Self::default();
231 + for line in text.lines() {
232 + let line = line.trim();
233 + if line.is_empty() || line.starts_with('#') {
234 + continue;
235 + }
236 + let Some((key, value)) = line.split_once('=') else {
237 + continue;
238 + };
239 + let value = value.trim();
240 + match key.trim() {
241 + "preference" => {
242 + if let Some(p) = ThemePreference::from_name(value) {
243 + settings.preference = p;
244 + }
245 + }
246 + "accent" => {
247 + if let Some(a) = Accent::from_name(value) {
248 + settings.accent = a;
249 + }
250 + }
251 + _ => {}
252 + }
253 + }
254 + settings.dirty = false;
255 + settings
256 + }
257 +
258 + /// Serializuje ustawienia do tekstu konfiguracji.
259 + pub fn to_config_string(&self) -> String {
260 + format!(
261 + "preference = {}\naccent = {}\n",
262 + self.preference.name(),
263 + self.accent.name()
264 + )
265 + }
266 +
267 + /// Zapisuje ustawienia do domyślnej ścieżki (tworzy katalogi).
268 + pub fn save(&self) -> io::Result<()> {
269 + let Some(path) = Self::config_path() else {
270 + return Ok(());
271 + };
272 + if let Some(parent) = path.parent() {
273 + std::fs::create_dir_all(parent)?;
274 + }
275 + std::fs::write(path, self.to_config_string())
276 + }
277 +}
278 +
279 +#[cfg(test)]
280 +mod tests {
281 + use super::*;
282 +
283 + #[test]
284 + fn preference_is_dark_semantics() {
285 + assert!(ThemePreference::Dark.is_dark(false));
286 + assert!(!ThemePreference::Light.is_dark(true));
287 + assert!(ThemePreference::System.is_dark(true));
288 + assert!(!ThemePreference::System.is_dark(false));
289 + }
290 +
291 + #[test]
292 + fn resolve_applies_accent() {
293 + let t = ThemePreference::Light.resolve(false, Accent::Purple);
294 + assert_eq!(t.colors.accent, Accent::Purple.color());
295 + assert_ne!(t.colors.accent, Theme::light().colors.accent);
296 + }
297 +
298 + #[test]
299 + fn accent_roundtrip_names() {
300 + for a in Accent::ALL {
301 + assert_eq!(Accent::from_name(a.name()), Some(a));
302 + }
303 + for p in ThemePreference::ALL {
304 + assert_eq!(ThemePreference::from_name(p.name()), Some(p));
305 + }
306 + }
307 +
308 + #[test]
309 + fn settings_config_roundtrip() {
310 + let mut s = ThemeSettings::default();
311 + s.set_preference(ThemePreference::Dark);
312 + s.set_accent(Accent::Teal);
313 + assert!(s.is_dirty());
314 + let parsed = ThemeSettings::from_config_str(&s.to_config_string());
315 + assert_eq!(parsed, s);
316 + assert!(!parsed.is_dirty());
317 + }
318 +
319 + #[test]
320 + fn dirty_only_on_change() {
321 + let mut s = ThemeSettings::default();
322 + s.clear_dirty();
323 + s.set_preference(s.preference());
324 + assert!(!s.is_dirty());
325 + s.set_preference(ThemePreference::Dark);
326 + assert!(s.is_dirty());
327 + }
328 +}
0 329 new file mode 100644
@@ -0,0 +1,175 @@
1 +//! Kolor w przestrzeni sRGB z kanałem alfa.
2 +//!
3 +//! Kanały są przechowywane jako `f32` w zakresie `0.0..=1.0`, **bez premnożenia**
4 +//! (non-premultiplied). Premnożenie, jeśli backend go wymaga, robi warstwa rysowania.
5 +
6 +/// Kolor RGBA w sRGB, kanały `0.0..=1.0`.
7 +#[derive(Debug, Clone, Copy, PartialEq)]
8 +pub struct Color {
9 + pub r: f32,
10 + pub g: f32,
11 + pub b: f32,
12 + pub a: f32,
13 +}
14 +
15 +impl Color {
16 + pub const TRANSPARENT: Color = Color {
17 + r: 0.0,
18 + g: 0.0,
19 + b: 0.0,
20 + a: 0.0,
21 + };
22 + pub const BLACK: Color = Color {
23 + r: 0.0,
24 + g: 0.0,
25 + b: 0.0,
26 + a: 1.0,
27 + };
28 + pub const WHITE: Color = Color {
29 + r: 1.0,
30 + g: 1.0,
31 + b: 1.0,
32 + a: 1.0,
33 + };
34 +
35 + /// Nieprzezroczysty kolor z kanałów `0.0..=1.0`.
36 + pub const fn rgb(r: f32, g: f32, b: f32) -> Self {
37 + Self { r, g, b, a: 1.0 }
38 + }
39 +
40 + /// Kolor z kanałów `0.0..=1.0`, z jawną alfą.
41 + pub const fn rgba(r: f32, g: f32, b: f32, a: f32) -> Self {
42 + Self { r, g, b, a }
43 + }
44 +
45 + /// Z kanałów 8-bitowych (np. `#3584e4` → `from_rgba8(0x35, 0x84, 0xe4, 255)`).
46 + pub const fn from_rgba8(r: u8, g: u8, b: u8, a: u8) -> Self {
47 + Self {
48 + r: r as f32 / 255.0,
49 + g: g as f32 / 255.0,
50 + b: b as f32 / 255.0,
51 + a: a as f32 / 255.0,
52 + }
53 + }
54 +
55 + /// Zapis do kanałów 8-bitowych, z przycięciem do zakresu.
56 + pub fn to_rgba8(&self) -> [u8; 4] {
57 + [to_u8(self.r), to_u8(self.g), to_u8(self.b), to_u8(self.a)]
58 + }
59 +
60 + /// Ten sam kolor z inną alfą.
61 + pub const fn with_alpha(self, a: f32) -> Self {
62 + Self { a, ..self }
63 + }
64 +
65 + /// Ten sam kolor (RGB) z alfą pomnożoną przez `factor` (0.0..=1.0 zostaje).
66 + pub const fn scale_alpha(self, factor: f32) -> Self {
67 + Self {
68 + a: self.a * factor,
69 + ..self
70 + }
71 + }
72 +
73 + /// Liniowa interpolacja kanałów (`t = 0` → `self`, `t = 1` → `other`).
74 + ///
75 + /// Interpolacja jest w sRGB (wystarczająca dla animacji UI); docelowo można
76 + /// przełączyć na przestrzeń liniową.
77 + pub fn lerp(self, other: Color, t: f32) -> Color {
78 + let t = t.clamp(0.0, 1.0);
79 + let mix = |a: f32, b: f32| a + (b - a) * t;
80 + Color {
81 + r: mix(self.r, other.r),
82 + g: mix(self.g, other.g),
83 + b: mix(self.b, other.b),
84 + a: mix(self.a, other.a),
85 + }
86 + }
87 +
88 + /// Kolor w formacie `#rrggbb`, `#rrggbbaa`, `#rgb` lub `#rgba`.
89 + pub fn from_hex(hex: &str) -> Option<Color> {
90 + let hex = hex.strip_prefix('#').unwrap_or(hex);
91 + if !hex.is_ascii() {
92 + return None;
93 + }
94 + let byte = |s: &str| u8::from_str_radix(s, 16).ok();
95 + match hex.len() {
96 + 3 => {
97 + let r = byte(&hex[0..1])?;
98 + let g = byte(&hex[1..2])?;
99 + let b = byte(&hex[2..3])?;
100 + Some(Color::from_rgba8(r * 17, g * 17, b * 17, 255))
101 + }
102 + 4 => {
103 + let r = byte(&hex[0..1])?;
104 + let g = byte(&hex[1..2])?;
105 + let b = byte(&hex[2..3])?;
106 + let a = byte(&hex[3..4])?;
107 + Some(Color::from_rgba8(r * 17, g * 17, b * 17, a * 17))
108 + }
109 + 6 => {
110 + let r = byte(&hex[0..2])?;
111 + let g = byte(&hex[2..4])?;
112 + let b = byte(&hex[4..6])?;
113 + Some(Color::from_rgba8(r, g, b, 255))
114 + }
115 + 8 => {
116 + let r = byte(&hex[0..2])?;
117 + let g = byte(&hex[2..4])?;
118 + let b = byte(&hex[4..6])?;
119 + let a = byte(&hex[6..8])?;
120 + Some(Color::from_rgba8(r, g, b, a))
121 + }
122 + _ => None,
123 + }
124 + }
125 +}
126 +
127 +fn to_u8(v: f32) -> u8 {
128 + (v.clamp(0.0, 1.0) * 255.0).round() as u8
129 +}
130 +
131 +#[cfg(test)]
132 +mod tests {
133 + use super::*;
134 +
135 + #[test]
136 + fn rgba8_roundtrip() {
137 + let c = Color::from_rgba8(0x35, 0x84, 0xe4, 0xff);
138 + assert_eq!(c.to_rgba8(), [0x35, 0x84, 0xe4, 0xff]);
139 + }
140 +
141 + #[test]
142 + fn hex_parsing() {
143 + assert_eq!(Color::from_hex("#ffffff"), Some(Color::WHITE));
144 + assert_eq!(
145 + Color::from_hex("3584e4"),
146 + Some(Color::from_rgba8(0x35, 0x84, 0xe4, 255))
147 + );
148 + assert_eq!(Color::from_hex("#fff"), Some(Color::WHITE));
149 + assert_eq!(
150 + Color::from_hex("#00000080"),
151 + Some(Color::from_rgba8(0, 0, 0, 0x80))
152 + );
153 + assert_eq!(Color::from_hex("#12345"), None);
154 + assert_eq!(Color::from_hex("nie-hex"), None);
155 + }
156 +
157 + #[test]
158 + fn lerp_midpoint() {
159 + let m = Color::BLACK.lerp(Color::WHITE, 0.5);
160 + assert!((m.r - 0.5).abs() < 1e-6);
161 + assert!((m.a - 1.0).abs() < 1e-6);
162 + }
163 +
164 + #[test]
165 + fn lerp_clamps_t() {
166 + assert_eq!(Color::BLACK.lerp(Color::WHITE, -1.0), Color::BLACK);
167 + assert_eq!(Color::BLACK.lerp(Color::WHITE, 2.0), Color::WHITE);
168 + }
169 +
170 + #[test]
171 + fn alpha_helpers() {
172 + let c = Color::WHITE.scale_alpha(0.5).with_alpha(0.25);
173 + assert!((c.a - 0.25).abs() < 1e-6);
174 + }
175 +}
0 176 new file mode 100644
@@ -0,0 +1,122 @@
1 +//! Zdarzenia wejścia i okna — wspólny format dla całej reszty toolkitu.
2 +//!
3 +//! Warstwa platformy tłumaczy zdarzenia Waylanda na [`Event`]; widgety konsumują
4 +//! je w jednym drzewie i zwracają, czy zdarzenie zostało obsłużone.
5 +
6 +use crate::geometry::{Point, Size};
7 +
8 +/// Klawisz myszy.
9 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10 +pub enum PointerButton {
11 + Left,
12 + Middle,
13 + Right,
14 + Back,
15 + Forward,
16 + Other(u32),
17 +}
18 +
19 +/// Stan wciśnięcia (dla przycisków i klawiszy).
20 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21 +pub enum ButtonState {
22 + Pressed,
23 + Released,
24 +}
25 +
26 +/// Modyfikatory klawiatury.
27 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28 +pub struct Modifiers {
29 + pub ctrl: bool,
30 + pub alt: bool,
31 + pub shift: bool,
32 + pub logo: bool,
33 +}
34 +
35 +impl Modifiers {
36 + pub const NONE: Modifiers = Modifiers {
37 + ctrl: false,
38 + alt: false,
39 + shift: false,
40 + logo: false,
41 + };
42 +}
43 +
44 +/// Zdarzenie przycisku wskaźnika.
45 +#[derive(Debug, Clone, Copy, PartialEq)]
46 +pub struct PointerEvent {
47 + pub position: Point,
48 + pub button: PointerButton,
49 + pub state: ButtonState,
50 + pub modifiers: Modifiers,
51 +}
52 +
53 +/// Zdarzenie przewijania (rolka/gładzik); wartości w „krokach”.
54 +#[derive(Debug, Clone, Copy, PartialEq, Default)]
55 +pub struct ScrollEvent {
56 + pub position: Point,
57 + pub dx: f64,
58 + pub dy: f64,
59 +}
60 +
61 +/// Zdarzenie klawiatury.
62 +#[derive(Debug, Clone, PartialEq)]
63 +pub struct KeyEvent {
64 + /// Keysym w rozumieniu XKB.
65 + pub keysym: u32,
66 + /// Tekst wygenerowany przez klawisz (jeśli dotyczy).
67 + pub text: Option<String>,
68 + pub state: ButtonState,
69 + pub modifiers: Modifiers,
70 + /// Czy zdarzenie zostało powtórzone przez auto-repeat.
71 + pub repeat: bool,
72 +}
73 +
74 +/// Zdarzenia cyklu życia okna.
75 +#[derive(Debug, Clone, Copy, PartialEq)]
76 +pub enum WindowEvent {
77 + /// Nowy rozmiar w pikselach logicznych.
78 + Resize(Size),
79 + /// Skala ekranu uległa zmianie.
80 + ScaleChanged(f64),
81 + /// Uzyskano/utracono fokus.
82 + Focused(bool),
83 + /// Użytkownik (lub kompozytor) poprosił o zamknięcie okna.
84 + CloseRequested,
85 + /// Należy przerysować zawartość.
86 + Redraw,
87 +}
88 +
89 +/// Zunifikowane zdarzenie trafiające do drzewa widgetów.
90 +#[derive(Debug, Clone, PartialEq)]
91 +pub enum Event {
92 + PointerButton(PointerEvent),
93 + /// Ruch wskaźnika; `position` w pikselach logicznych.
94 + PointerMotion {
95 + position: Point,
96 + modifiers: Modifiers,
97 + },
98 + /// Wskaźnik opuścił powierzchnię.
99 + PointerLeave,
100 + Scroll(ScrollEvent),
101 + Key(KeyEvent),
102 + /// Wprowadzony tekst (po złożeniu IME).
103 + Text(String),
104 + Window(WindowEvent),
105 +}
106 +
107 +#[cfg(test)]
108 +mod tests {
109 + use super::*;
110 +
111 + #[test]
112 + fn pointer_event_is_copy() {
113 + let e = PointerEvent {
114 + position: Point::new(1.0, 2.0),
115 + button: PointerButton::Left,
116 + state: ButtonState::Pressed,
117 + modifiers: Modifiers::NONE,
118 + };
119 + let copy = e;
120 + assert_eq!(e, copy);
121 + }
122 +}
0 123 new file mode 100644
@@ -0,0 +1,296 @@
1 +//! Geometria w **pikselach logicznych** (niezależnych od gęstości ekranu).
2 +//!
3 +//! Konwersja do pikseli fizycznych odbywa się na granicy rysowania, przez
4 +//! [`crate::scale::Scale`]. Wszystkie typy są `Copy` i tanie — można je swobodnie
5 +//! przekazywać w silniku układu.
6 +
7 +use std::ops::{Add, Sub};
8 +
9 +/// Punkt w przestrzeni 2D.
10 +#[derive(Debug, Clone, Copy, PartialEq, Default)]
11 +pub struct Point {
12 + pub x: f64,
13 + pub y: f64,
14 +}
15 +
16 +impl Point {
17 + pub const ZERO: Point = Point { x: 0.0, y: 0.0 };
18 +
19 + pub const fn new(x: f64, y: f64) -> Self {
20 + Self { x, y }
21 + }
22 +}
23 +
24 +impl Add for Point {
25 + type Output = Point;
26 + fn add(self, rhs: Point) -> Point {
27 + Point::new(self.x + rhs.x, self.y + rhs.y)
28 + }
29 +}
30 +
31 +impl Sub for Point {
32 + type Output = Point;
33 + fn sub(self, rhs: Point) -> Point {
34 + Point::new(self.x - rhs.x, self.y - rhs.y)
35 + }
36 +}
37 +
38 +/// Rozmiar (szerokość × wysokość).
39 +#[derive(Debug, Clone, Copy, PartialEq, Default)]
40 +pub struct Size {
41 + pub width: f64,
42 + pub height: f64,
43 +}
44 +
45 +impl Size {
46 + pub const ZERO: Size = Size {
47 + width: 0.0,
48 + height: 0.0,
49 + };
50 +
51 + pub const fn new(width: f64, height: f64) -> Self {
52 + Self { width, height }
53 + }
54 +
55 + /// Pusty, jeśli którykolwiek wymiar jest niedodatni.
56 + pub fn is_empty(&self) -> bool {
57 + self.width <= 0.0 || self.height <= 0.0
58 + }
59 +}
60 +
61 +/// Prostokąt wyrównany do osi: pozycja + rozmiar.
62 +#[derive(Debug, Clone, Copy, PartialEq, Default)]
63 +pub struct Rect {
64 + pub origin: Point,
65 + pub size: Size,
66 +}
67 +
68 +impl Rect {
69 + pub const ZERO: Rect = Rect {
70 + origin: Point::ZERO,
71 + size: Size::ZERO,
72 + };
73 +
74 + pub const fn new(origin: Point, size: Size) -> Self {
75 + Self { origin, size }
76 + }
77 +
78 + pub const fn from_xywh(x: f64, y: f64, width: f64, height: f64) -> Self {
79 + Self {
80 + origin: Point::new(x, y),
81 + size: Size::new(width, height),
82 + }
83 + }
84 +
85 + pub const fn from_size(size: Size) -> Self {
86 + Self {
87 + origin: Point::ZERO,
88 + size,
89 + }
90 + }
91 +
92 + pub fn x(&self) -> f64 {
93 + self.origin.x
94 + }
95 + pub fn y(&self) -> f64 {
96 + self.origin.y
97 + }
98 + pub fn width(&self) -> f64 {
99 + self.size.width
100 + }
101 + pub fn height(&self) -> f64 {
102 + self.size.height
103 + }
104 + pub fn left(&self) -> f64 {
105 + self.origin.x
106 + }
107 + pub fn top(&self) -> f64 {
108 + self.origin.y
109 + }
110 + pub fn right(&self) -> f64 {
111 + self.origin.x + self.size.width
112 + }
113 + pub fn bottom(&self) -> f64 {
114 + self.origin.y + self.size.height
115 + }
116 +
117 + pub fn center(&self) -> Point {
118 + Point::new(
119 + self.origin.x + self.size.width / 2.0,
120 + self.origin.y + self.size.height / 2.0,
121 + )
122 + }
123 +
124 + pub fn is_empty(&self) -> bool {
125 + self.size.is_empty()
126 + }
127 +
128 + /// Czy punkt leży wewnątrz prostokąta (krawędź prawa/dolna jest wyłączna).
129 + pub fn contains(&self, p: Point) -> bool {
130 + p.x >= self.left() && p.x < self.right() && p.y >= self.top() && p.y < self.bottom()
131 + }
132 +
133 + /// Czy dwa prostokąty mają wspólne pole o niezerowej powierzchni.
134 + pub fn intersects(&self, other: &Rect) -> bool {
135 + self.left() < other.right()
136 + && other.left() < self.right()
137 + && self.top() < other.bottom()
138 + && other.top() < self.bottom()
139 + }
140 +
141 + /// Część wspólna lub `None`, gdy brak przecięcia.
142 + pub fn intersection(&self, other: &Rect) -> Option<Rect> {
143 + let left = self.left().max(other.left());
144 + let top = self.top().max(other.top());
145 + let right = self.right().min(other.right());
146 + let bottom = self.bottom().min(other.bottom());
147 + if right > left && bottom > top {
148 + Some(Rect::from_xywh(left, top, right - left, bottom - top))
149 + } else {
150 + None
151 + }
152 + }
153 +
154 + /// Najmniejszy prostokąt zawierający oba (pusty operand jest ignorowany).
155 + pub fn union(&self, other: &Rect) -> Rect {
156 + if self.is_empty() {
157 + return *other;
158 + }
159 + if other.is_empty() {
160 + return *self;
161 + }
162 + let left = self.left().min(other.left());
163 + let top = self.top().min(other.top());
164 + let right = self.right().max(other.right());
165 + let bottom = self.bottom().max(other.bottom());
166 + Rect::from_xywh(left, top, right - left, bottom - top)
167 + }
168 +
169 + /// Powiększenie o marginesy (wartości dodatnie rosną na zewnątrz).
170 + pub fn inflate(&self, insets: Insets) -> Rect {
171 + Rect::from_xywh(
172 + self.left() - insets.left,
173 + self.top() - insets.top,
174 + self.width() + insets.left + insets.right,
175 + self.height() + insets.top + insets.bottom,
176 + )
177 + }
178 +
179 + /// Zmniejszenie o marginesy; wymiary nigdy nie schodzą poniżej zera.
180 + pub fn deflate(&self, insets: Insets) -> Rect {
181 + Rect::from_xywh(
182 + self.left() + insets.left,
183 + self.top() + insets.top,
184 + (self.width() - insets.left - insets.right).max(0.0),
185 + (self.height() - insets.top - insets.bottom).max(0.0),
186 + )
187 + }
188 +
189 + /// Przesunięcie o wektor.
190 + pub fn offset(&self, dx: f64, dy: f64) -> Rect {
191 + Rect::new(Point::new(self.x() + dx, self.y() + dy), self.size)
192 + }
193 +}
194 +
195 +/// Marginesy (góra/prawo/dół/lewo) — używane na padding, border i spacing.
196 +#[derive(Debug, Clone, Copy, PartialEq, Default)]
197 +pub struct Insets {
198 + pub top: f64,
199 + pub right: f64,
200 + pub bottom: f64,
201 + pub left: f64,
202 +}
203 +
204 +impl Insets {
205 + pub const ZERO: Insets = Insets {
206 + top: 0.0,
207 + right: 0.0,
208 + bottom: 0.0,
209 + left: 0.0,
210 + };
211 +
212 + pub const fn new(top: f64, right: f64, bottom: f64, left: f64) -> Self {
213 + Self {
214 + top,
215 + right,
216 + bottom,
217 + left,
218 + }
219 + }
220 +
221 + pub const fn splat(value: f64) -> Self {
222 + Self::new(value, value, value, value)
223 + }
224 +
225 + pub const fn symmetric(vertical: f64, horizontal: f64) -> Self {
226 + Self::new(vertical, horizontal, vertical, horizontal)
227 + }
228 +
229 + pub const fn horizontal(&self) -> f64 {
230 + self.left + self.right
231 + }
232 +
233 + pub const fn vertical(&self) -> f64 {
234 + self.top + self.bottom
235 + }
236 +}
237 +
238 +#[cfg(test)]
239 +mod tests {
240 + use super::*;
241 +
242 + #[test]
243 + fn point_arithmetic() {
244 + let a = Point::new(1.0, 2.0);
245 + let b = Point::new(3.0, 5.0);
246 + assert_eq!(a + b, Point::new(4.0, 7.0));
247 + assert_eq!(b - a, Point::new(2.0, 3.0));
248 + }
249 +
250 + #[test]
251 + fn rect_contains_uses_half_open_bounds() {
252 + let r = Rect::from_xywh(10.0, 10.0, 100.0, 50.0);
253 + assert!(r.contains(Point::new(10.0, 10.0)));
254 + assert!(r.contains(Point::new(109.9, 59.9)));
255 + assert!(!r.contains(Point::new(110.0, 30.0)));
256 + assert!(!r.contains(Point::new(50.0, 60.0)));
257 + }
258 +
259 + #[test]
260 + fn rect_intersection_and_none() {
261 + let a = Rect::from_xywh(0.0, 0.0, 10.0, 10.0);
262 + let b = Rect::from_xywh(5.0, 5.0, 10.0, 10.0);
263 + assert_eq!(
264 + a.intersection(&b),
265 + Some(Rect::from_xywh(5.0, 5.0, 5.0, 5.0))
266 + );
267 +
268 + let c = Rect::from_xywh(20.0, 20.0, 1.0, 1.0);
269 + assert_eq!(a.intersection(&c), None);
270 + assert!(!a.intersects(&c));
271 + }
272 +
273 + #[test]
274 + fn rect_union_ignores_empty() {
275 + let a = Rect::from_xywh(0.0, 0.0, 10.0, 10.0);
276 + assert_eq!(a.union(&Rect::ZERO), a);
277 + let b = Rect::from_xywh(5.0, 5.0, 10.0, 10.0);
278 + assert_eq!(a.union(&b), Rect::from_xywh(0.0, 0.0, 15.0, 15.0));
279 + }
280 +
281 + #[test]
282 + fn inflate_deflate_roundtrip() {
283 + let r = Rect::from_xywh(10.0, 10.0, 100.0, 100.0);
284 + let m = Insets::splat(5.0);
285 + assert_eq!(r.inflate(m).deflate(m), r);
286 + }
287 +
288 + #[test]
289 + fn deflate_clamps_to_zero() {
290 + let r = Rect::from_xywh(0.0, 0.0, 3.0, 3.0);
291 + let m = Insets::splat(10.0);
292 + let d = r.deflate(m);
293 + assert_eq!(d.width(), 0.0);
294 + assert_eq!(d.height(), 0.0);
295 + }
296 +}
0 297 new file mode 100644
@@ -0,0 +1,32 @@
1 +//! # libluna-core
2 +//!
3 +//! Fundament libLuny: prymitywy niezależne od platformy ani backendu rysowania.
4 +//!
5 +//! - [`geometry`] — `Point`, `Size`, `Rect`, `Insets` (w pikselach logicznych).
6 +//! - [`color`] — [`Color`] w przestrzeni sRGB z kanałem alfa.
7 +//! - [`scale`] — [`Scale`] do przeliczania pikseli logicznych na fizyczne.
8 +//! - [`event`] — [`Event`] i towarzyszące typy wejścia.
9 +//! - [`style`] — tokeny wyglądu ([`style::Theme`], spacing, radius, typografia).
10 +//!
11 +//! Moduł nie zależy od Waylanda ani od konkretnego rasteryzatora — jest wspólnym
12 +//! słownikiem typów dla całej reszty toolkitu.
13 +
14 +pub mod appearance;
15 +pub mod color;
16 +pub mod event;
17 +pub mod geometry;
18 +pub mod scale;
19 +pub mod style;
20 +
21 +pub use appearance::{Accent, ThemePreference, ThemeSettings};
22 +pub use color::Color;
23 +pub use event::{
24 + ButtonState, Event, KeyEvent, Modifiers, PointerButton, PointerEvent, ScrollEvent, WindowEvent,
25 +};
26 +pub use geometry::{Insets, Point, Rect, Size};
27 +pub use scale::Scale;
28 +pub use style::{
29 + BottomSheetMetrics, ButtonMetrics, DropDownMetrics, EntryMetrics, HeaderBarMetrics,
30 + ItemViewMetrics, MenuMetrics, Metrics, NavMetrics, PagerMetrics, PathBarMetrics, Radius,
31 + SegmentedMetrics, SliderMetrics, Spacing, SwitchMetrics, TabsMetrics, Theme, Typography,
32 +};
0 33 new file mode 100644
@@ -0,0 +1,69 @@
1 +//! Skala ekranu: przeliczanie pikseli logicznych na fizyczne i z powrotem.
2 +//!
3 +//! Cały toolkit operuje na pikselach logicznych; [`Scale`] pojawia się dopiero
4 +//! w warstwie rysowania i obsługi wejścia (Wayland przekazuje współrzędne
5 +//! w pikselach logicznych, a bufor w fizycznych).
6 +
7 +/// Współczynnik skalowania (np. `2.0` dla HiDPI, `1.0` dla zwykłego ekranu).
8 +#[derive(Debug, Clone, Copy, PartialEq)]
9 +pub struct Scale(f64);
10 +
11 +impl Scale {
12 + /// Skala 1:1.
13 + pub const ONE: Scale = Scale(1.0);
14 +
15 + pub const fn new(factor: f64) -> Self {
16 + Self(factor)
17 + }
18 +
19 + /// Ze skali całkowitej — typowe wartości to 1, 2, 3.
20 + pub const fn integer(factor: i32) -> Self {
21 + Self(factor as f64)
22 + }
23 +
24 + pub const fn factor(&self) -> f64 {
25 + self.0
26 + }
27 +
28 + pub fn to_physical(&self, logical: f64) -> f64 {
29 + logical * self.0
30 + }
31 +
32 + pub fn to_logical(&self, physical: f64) -> f64 {
33 + physical / self.0
34 + }
35 +
36 + /// Rozmiar fizyczny (zaokrąglony w górę), przydatny do alokacji bufora.
37 + pub fn physical_size(&self, width: f64, height: f64) -> (u32, u32) {
38 + (
39 + self.to_physical(width).ceil().max(0.0) as u32,
40 + self.to_physical(height).ceil().max(0.0) as u32,
41 + )
42 + }
43 +}
44 +
45 +impl Default for Scale {
46 + fn default() -> Self {
47 + Scale::ONE
48 + }
49 +}
50 +
51 +#[cfg(test)]
52 +mod tests {
53 + use super::*;
54 +
55 + #[test]
56 + fn conversion_roundtrip() {
57 + let s = Scale::integer(2);
58 + assert_eq!(s.to_physical(10.0), 20.0);
59 + assert_eq!(s.to_logical(20.0), 10.0);
60 + }
61 +
62 + #[test]
63 + fn physical_size_rounds_up() {
64 + let s = Scale::new(1.5);
65 + assert_eq!(s.physical_size(10.0, 3.0), (15, 5));
66 + let s = Scale::new(1.25);
67 + assert_eq!(s.physical_size(10.0, 10.0), (13, 13));
68 + }
69 +}
0 70 new file mode 100644
@@ -0,0 +1,579 @@
1 +//! Tokeny wyglądu — odpowiednik „design tokens” w libadwaita.
2 +//!
3 +//! Zamiast arkuszy CSS libLuna zaczyna od typowanych tokenów: odstępów, promieni,
4 +//! skali typograficznej i palety (jasna/ciemna). Warstwa `libluna-widgets` buduje
5 +//! z nich konkretne widgety, dzięki czemu wygląd jest spójny i tani w zmianie.
6 +//!
7 +//! Wartości palety są zbliżone do libadwaita (GNOME 45+) i mogą wymagać kalibracji
8 +//! podczas wdrażania.
9 +
10 +use crate::color::Color;
11 +
12 +/// Odstępy w pikselach logicznych (siateczka 3/6/12/18/24 — jak w GNOME).
13 +#[derive(Debug, Clone, Copy, PartialEq)]
14 +pub struct Spacing {
15 + pub xs: f64,
16 + pub sm: f64,
17 + pub md: f64,
18 + pub lg: f64,
19 + pub xl: f64,
20 +}
21 +
22 +impl Spacing {
23 + pub const fn standard() -> Self {
24 + Self {
25 + xs: 3.0,
26 + sm: 6.0,
27 + md: 12.0,
28 + lg: 18.0,
29 + xl: 24.0,
30 + }
31 + }
32 +}
33 +
34 +impl Default for Spacing {
35 + fn default() -> Self {
36 + Self::standard()
37 + }
38 +}
39 +
40 +/// Promienie zaokrągleń (piksele logiczne).
41 +#[derive(Debug, Clone, Copy, PartialEq)]
42 +pub struct Radius {
43 + pub small: f64,
44 + pub medium: f64,
45 + pub large: f64,
46 + /// Wartość „pigułki” — na tyle duża, by przyciski były w pełni zaokrąglone.
47 + pub pill: f64,
48 +}
49 +
50 +impl Radius {
51 + pub const fn standard() -> Self {
52 + Self {
53 + small: 6.0,
54 + medium: 9.0,
55 + large: 12.0,
56 + pill: 999.0,
57 + }
58 + }
59 +}
60 +
61 +impl Default for Radius {
62 + fn default() -> Self {
63 + Self::standard()
64 + }
65 +}
66 +
67 +/// Grubości czcionek.
68 +pub mod weight {
69 + pub const NORMAL: u16 = 400;
70 + pub const MEDIUM: u16 = 500;
71 + pub const BOLD: u16 = 700;
72 +}
73 +
74 +/// Pojedynczy styl czcionki.
75 +#[derive(Debug, Clone, Copy, PartialEq)]
76 +pub struct FontStyle {
77 + /// Rozmiar (piksele logiczne).
78 + pub size: f64,
79 + /// Grubość (patrz [`weight`]).
80 + pub weight: u16,
81 + /// Wysokość linii (piksele logiczne).
82 + pub line_height: f64,
83 + /// Czy używać kroju o stałej szerokości.
84 + pub monospace: bool,
85 +}
86 +
87 +impl FontStyle {
88 + const fn new(size: f64, weight: u16, line_height: f64, monospace: bool) -> Self {
89 + Self {
90 + size,
91 + weight,
92 + line_height,
93 + monospace,
94 + }
95 + }
96 +}
97 +
98 +/// Skala typograficzna.
99 +#[derive(Debug, Clone, Copy, PartialEq)]
100 +pub struct Typography {
101 + /// Duży tytuł okna / strony.
102 + pub title: FontStyle,
103 + /// Nagłówek sekcji.
104 + pub heading: FontStyle,
105 + /// Zwykły tekst treści.
106 + pub body: FontStyle,
107 + /// Drobny tekst pomocniczy.
108 + pub caption: FontStyle,
109 +}
110 +
111 +impl Typography {
112 + pub const fn standard() -> Self {
113 + Self {
114 + title: FontStyle::new(20.0, weight::BOLD, 26.0, false),
115 + heading: FontStyle::new(15.0, weight::BOLD, 20.0, false),
116 + body: FontStyle::new(14.0, weight::NORMAL, 20.0, false),
117 + caption: FontStyle::new(12.0, weight::NORMAL, 16.0, false),
118 + }
119 + }
120 +}
121 +
122 +impl Default for Typography {
123 + fn default() -> Self {
124 + Self::standard()
125 + }
126 +}
127 +
128 +/// Paleta kolorów dla jednego wariantu (jasny lub ciemny).
129 +#[derive(Debug, Clone, Copy, PartialEq)]
130 +pub struct ColorScheme {
131 + /// Tło okna aplikacji.
132 + pub window_bg: Color,
133 + /// Tło głównego widoku / listy.
134 + pub view_bg: Color,
135 + /// Tło karty / powierzchni podniesionej.
136 + pub card_bg: Color,
137 + /// Tło Headera (HeaderBar).
138 + pub headerbar_bg: Color,
139 + /// Tło panelu bocznego.
140 + pub sidebar_bg: Color,
141 + /// Kolor tekstu podstawowego.
142 + pub text: Color,
143 + /// Kolor tekstu drugorzędnego / wyciszonego.
144 + pub dim_text: Color,
145 + /// Kolor akcentu (np. sugerowany przycisk, przełącznik wł.).
146 + pub accent: Color,
147 + /// Tekst na akcencie.
148 + pub accent_fg: Color,
149 + /// Kolor obramowań i separatorów.
150 + pub border: Color,
151 + /// Delikatna nakładka (hover/pressed/„shade”).
152 + pub shade: Color,
153 + pub error: Color,
154 + pub warning: Color,
155 + pub success: Color,
156 +}
157 +
158 +impl ColorScheme {
159 + /// Wariant jasny (przybliżenie libadwaita light).
160 + pub const fn light() -> Self {
161 + Self {
162 + window_bg: Color::from_rgba8(0xfa, 0xfa, 0xfa, 0xff),
163 + view_bg: Color::from_rgba8(0xff, 0xff, 0xff, 0xff),
164 + card_bg: Color::from_rgba8(0xff, 0xff, 0xff, 0xff),
165 + headerbar_bg: Color::from_rgba8(0xf2, 0xf2, 0xf2, 0xff),
166 + sidebar_bg: Color::from_rgba8(0xeb, 0xeb, 0xeb, 0xff),
167 + text: Color::from_rgba8(0x2e, 0x2e, 0x2e, 0xff),
168 + dim_text: Color::rgba(0.0, 0.0, 0.0, 0.55),
169 + accent: Color::from_rgba8(0x35, 0x84, 0xe4, 0xff),
170 + accent_fg: Color::from_rgba8(0xff, 0xff, 0xff, 0xff),
171 + border: Color::rgba(0.0, 0.0, 0.0, 0.10),
172 + shade: Color::rgba(0.0, 0.0, 0.0, 0.06),
173 + error: Color::from_rgba8(0xc0, 0x1c, 0x28, 0xff),
174 + warning: Color::from_rgba8(0xe5, 0xa5, 0x0a, 0xff),
175 + success: Color::from_rgba8(0x2e, 0xc2, 0x7e, 0xff),
176 + }
177 + }
178 +
179 + /// Wariant ciemny (przybliżenie libadwaita dark).
180 + pub const fn dark() -> Self {
181 + Self {
182 + window_bg: Color::from_rgba8(0x24, 0x24, 0x24, 0xff),
183 + view_bg: Color::from_rgba8(0x1e, 0x1e, 0x1e, 0xff),
184 + card_bg: Color::from_rgba8(0x30, 0x30, 0x30, 0xff),
185 + headerbar_bg: Color::from_rgba8(0x30, 0x30, 0x30, 0xff),
186 + sidebar_bg: Color::from_rgba8(0x2b, 0x2b, 0x2b, 0xff),
187 + text: Color::from_rgba8(0xff, 0xff, 0xff, 0xff),
188 + dim_text: Color::rgba(1.0, 1.0, 1.0, 0.55),
189 + accent: Color::from_rgba8(0x35, 0x84, 0xe4, 0xff),
190 + accent_fg: Color::from_rgba8(0xff, 0xff, 0xff, 0xff),
191 + border: Color::rgba(1.0, 1.0, 1.0, 0.12),
192 + shade: Color::rgba(1.0, 1.0, 1.0, 0.08),
193 + error: Color::from_rgba8(0xff, 0x7b, 0x63, 0xff),
194 + warning: Color::from_rgba8(0xf8, 0xe4, 0x5c, 0xff),
195 + success: Color::from_rgba8(0x8f, 0xf0, 0xa4, 0xff),
196 + }
197 + }
198 +}
199 +
200 +/// Czy motyw jest jasny czy ciemny.
201 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202 +pub enum Appearance {
203 + Light,
204 + Dark,
205 +}
206 +
207 +/// Geometria paska tytułu ([`HeaderBar`](crate::style)).
208 +#[derive(Debug, Clone, Copy, PartialEq)]
209 +pub struct HeaderBarMetrics {
210 + /// Wysokość paska (piksele logiczne).
211 + pub height: f64,
212 + /// Bok kwadratowego obszaru kontrolki okna (`— □ ✕`).
213 + pub control: f64,
214 + /// Odstęp między kontrolkami okna (i od krawędzi paska).
215 + pub control_gap: f64,
216 +}
217 +
218 +/// Geometria przycisku.
219 +#[derive(Debug, Clone, Copy, PartialEq)]
220 +pub struct ButtonMetrics {
221 + /// Domyślna wysokość.
222 + pub height: f64,
223 + /// Wewnętrzny padding poziomy (z obu stron etykiety).
224 + pub pad_x: f64,
225 + /// Rozpiętość wskaźnika rozwijania (chevron).
226 + pub chevron: f64,
227 + /// Odstęp etykieta–chevron.
228 + pub chevron_gap: f64,
229 +}
230 +
231 +/// Geometria przełącznika.
232 +#[derive(Debug, Clone, Copy, PartialEq)]
233 +pub struct SwitchMetrics {
234 + pub width: f64,
235 + pub height: f64,
236 +}
237 +
238 +/// Geometria suwaka.
239 +#[derive(Debug, Clone, Copy, PartialEq)]
240 +pub struct SliderMetrics {
241 + /// Promień uchwytu (także tolerancja trafienia w poziomie).
242 + pub knob_r: f64,
243 + /// Pionowa tolerancja trafienia ponad/pod torem.
244 + pub hit_pad: f64,
245 + /// Domyślna wysokość toru.
246 + pub track_height: f64,
247 +}
248 +
249 +/// Geometria listy rozwijanej ([`DropDown`](crate::style)).
250 +#[derive(Debug, Clone, Copy, PartialEq)]
251 +pub struct DropDownMetrics {
252 + pub height: f64,
253 + /// Wewnętrzny padding poziomy.
254 + pub pad_x: f64,
255 + /// Rozpiętość wskaźnika rozwijania.
256 + pub chevron: f64,
257 + /// Odstęp etykieta–chevron.
258 + pub chevron_gap: f64,
259 + /// Minimalna szerokość kontrolki.
260 + pub min_width: f64,
261 +}
262 +
263 +/// Geometria popoveru ([`Menu`](crate::style)).
264 +#[derive(Debug, Clone, Copy, PartialEq)]
265 +pub struct MenuMetrics {
266 + /// Wysokość pozycji akcji.
267 + pub item_h: f64,
268 + /// Wysokość nagłówka sekcji.
269 + pub section_h: f64,
270 + /// Wysokość separatora.
271 + pub separator_h: f64,
272 + /// Padding pionowy panelu (góra/dół).
273 + pub pad_v: f64,
274 + /// Padding poziomy zawartości.
275 + pub pad_h: f64,
276 + /// Minimalny odstęp między etykietą a skrótem.
277 + pub label_gap: f64,
278 + /// Minimalna szerokość panelu.
279 + pub min_width: f64,
280 + /// Margines od krawędzi okna przy umieszczaniu.
281 + pub margin: f64,
282 + /// Odstęp kotwica–panel.
283 + pub anchor_gap: f64,
284 +}
285 +
286 +/// Geometria menu nawigacji ([`NavList`](crate::style)).
287 +#[derive(Debug, Clone, Copy, PartialEq)]
288 +pub struct NavMetrics {
289 + /// Wysokość pozycji.
290 + pub item_h: f64,
291 + /// Wysokość nagłówka sekcji.
292 + pub section_h: f64,
293 + /// Padding górny/dolny listy.
294 + pub pad_v: f64,
295 + /// Padding poziomy pozycji.
296 + pub pad_x: f64,
297 +}
298 +
299 +/// Geometria przełącznika segmentowego ([`ViewSwitcher`](crate::style)).
300 +#[derive(Debug, Clone, Copy, PartialEq)]
301 +pub struct SegmentedMetrics {
302 + /// O ile tło-„szyna” wystaje poza segmenty.
303 + pub track_pad: f64,
304 +}
305 +
306 +/// Geometria paska zakładek ([`TabBar`](crate::style)).
307 +#[derive(Debug, Clone, Copy, PartialEq)]
308 +pub struct TabsMetrics {
309 + /// Wysokość paska zakładek.
310 + pub height: f64,
311 + /// Minimalna szerokość zakładki.
312 + pub min_width: f64,
313 + /// Maksymalna szerokość zakładki.
314 + pub max_width: f64,
315 + /// Wewnętrzny padding poziomy etykiety.
316 + pub pad_x: f64,
317 + /// Odstęp między zakładkami.
318 + pub gap: f64,
319 + /// Bok obszaru przycisku zamknięcia zakładki.
320 + pub close: f64,
321 +}
322 +
323 +/// Geometria dolnego panelu ([`BottomSheet`](crate::style)).
324 +#[derive(Debug, Clone, Copy, PartialEq)]
325 +pub struct BottomSheetMetrics {
326 + /// Domyślna wysokość panelu.
327 + pub height: f64,
328 + /// Szerokość „grabbera”.
329 + pub handle_width: f64,
330 + /// Wysokość „grabbera”.
331 + pub handle_height: f64,
332 + /// Wysokość nagłówka panelu.
333 + pub header: f64,
334 + /// Promień górnych narożników panelu.
335 + pub radius: f64,
336 + /// Krycie przygaszającego tła (0..1).
337 + pub scrim: f64,
338 + /// Przesunięcie w dół (px) po którym puszczenie zamyka panel.
339 + pub dismiss: f64,
340 +}
341 +
342 +/// Geometria karuzeli stron ([`Pager`](crate::style)).
343 +#[derive(Debug, Clone, Copy, PartialEq)]
344 +pub struct PagerMetrics {
345 + /// Średnica kropki-wskaźnika.
346 + pub dot: f64,
347 + /// Odstęp między kropkami.
348 + pub gap: f64,
349 + /// Odstęp kropek od dolnej krawędzi.
350 + pub pad: f64,
351 +}
352 +
353 +/// Geometria paska ścieżki / okruszków ([`PathBar`](crate::style)).
354 +#[derive(Debug, Clone, Copy, PartialEq)]
355 +pub struct PathBarMetrics {
356 + /// Wysokość paska.
357 + pub height: f64,
358 + /// Wewnętrzny padding poziomy pozycji.
359 + pub pad_x: f64,
360 + /// Odstęp wokół separatora.
361 + pub gap: f64,
362 +}
363 +
364 +/// Geometria pola tekstowego ([`SearchEntry`](crate::style)).
365 +#[derive(Debug, Clone, Copy, PartialEq)]
366 +pub struct EntryMetrics {
367 + /// Wysokość pola.
368 + pub height: f64,
369 + /// Wewnętrzny padding poziomy.
370 + pub pad_x: f64,
371 + /// Bok ikony (lupka, „×”).
372 + pub icon: f64,
373 +}
374 +
375 +/// Geometria widoku elementów ([`ItemView`](crate::style)) — siatka i lista.
376 +#[derive(Debug, Clone, Copy, PartialEq)]
377 +pub struct ItemViewMetrics {
378 + /// Szerokość kafelka (tryb siatki).
379 + pub tile_w: f64,
380 + /// Wysokość kafelka (tryb siatki).
381 + pub tile_h: f64,
382 + /// Odstęp między kafelkami.
383 + pub gap: f64,
384 + /// Wysokość wiersza (tryb listy).
385 + pub row_h: f64,
386 + /// Rozmiar ikony elementu.
387 + pub icon: f64,
388 +}
389 +
390 +/// Geometria kontrolek („component metrics”) — rozmiary i paddingi w pikselach logicznych.
391 +///
392 +/// Widgety nie mają już własnych stałych rozmiarów: czytają je z motywu, dzięki czemu
393 +/// zmiana tokenów (np. w przyszłym, instalowanym stylu) steruje także geometrią kontrolek —
394 +/// nie tylko kolorami.
395 +#[derive(Debug, Clone, Copy, PartialEq)]
396 +pub struct Metrics {
397 + pub headerbar: HeaderBarMetrics,
398 + pub button: ButtonMetrics,
399 + pub switch: SwitchMetrics,
400 + pub slider: SliderMetrics,
401 + pub dropdown: DropDownMetrics,
402 + pub menu: MenuMetrics,
403 + pub nav: NavMetrics,
404 + pub segmented: SegmentedMetrics,
405 + pub tabs: TabsMetrics,
406 + pub sheet: BottomSheetMetrics,
407 + pub pager: PagerMetrics,
408 + pub pathbar: PathBarMetrics,
409 + pub entry: EntryMetrics,
410 + pub itemview: ItemViewMetrics,
411 +}
412 +
413 +impl Metrics {
414 + pub const fn standard() -> Self {
415 + Self {
416 + headerbar: HeaderBarMetrics {
417 + height: 46.0,
418 + control: 30.0,
419 + control_gap: 2.0,
420 + },
421 + button: ButtonMetrics {
422 + height: 34.0,
423 + pad_x: 14.0,
424 + chevron: 9.0,
425 + chevron_gap: 8.0,
426 + },
427 + switch: SwitchMetrics {
428 + width: 44.0,
429 + height: 24.0,
430 + },
431 + slider: SliderMetrics {
432 + knob_r: 9.0,
433 + hit_pad: 12.0,
434 + track_height: 6.0,
435 + },
436 + dropdown: DropDownMetrics {
437 + height: 34.0,
438 + pad_x: 12.0,
439 + chevron: 9.0,
440 + chevron_gap: 10.0,
441 + min_width: 140.0,
442 + },
443 + menu: MenuMetrics {
444 + item_h: 34.0,
445 + section_h: 26.0,
446 + separator_h: 9.0,
447 + pad_v: 6.0,
448 + pad_h: 10.0,
449 + label_gap: 20.0,
450 + min_width: 200.0,
451 + margin: 6.0,
452 + anchor_gap: 4.0,
453 + },
454 + nav: NavMetrics {
455 + item_h: 34.0,
456 + section_h: 30.0,
457 + pad_v: 6.0,
458 + pad_x: 12.0,
459 + },
460 + segmented: SegmentedMetrics { track_pad: 2.0 },
461 + tabs: TabsMetrics {
462 + height: 34.0,
463 + min_width: 120.0,
464 + max_width: 200.0,
465 + pad_x: 12.0,
466 + gap: 2.0,
467 + close: 16.0,
468 + },
469 + sheet: BottomSheetMetrics {
470 + height: 320.0,
471 + handle_width: 36.0,
472 + handle_height: 4.0,
473 + header: 44.0,
474 + radius: 12.0,
475 + scrim: 0.4,
476 + dismiss: 96.0,
477 + },
478 + pager: PagerMetrics {
479 + dot: 8.0,
480 + gap: 8.0,
481 + pad: 12.0,
482 + },
483 + pathbar: PathBarMetrics {
484 + height: 34.0,
485 + pad_x: 10.0,
486 + gap: 6.0,
487 + },
488 + entry: EntryMetrics {
489 + height: 34.0,
490 + pad_x: 12.0,
491 + icon: 16.0,
492 + },
493 + itemview: ItemViewMetrics {
494 + tile_w: 100.0,
495 + tile_h: 76.0,
496 + gap: 12.0,
497 + row_h: 44.0,
498 + icon: 28.0,
499 + },
500 + }
501 + }
502 +}
503 +
504 +impl Default for Metrics {
505 + fn default() -> Self {
506 + Self::standard()
507 + }
508 +}
509 +
510 +/// Kompletny motyw: odstępy, promienie, typografia, geometria kontrolek i paleta.
511 +#[derive(Debug, Clone, Copy, PartialEq)]
512 +pub struct Theme {
513 + pub spacing: Spacing,
514 + pub radius: Radius,
515 + pub metrics: Metrics,
516 + pub typography: Typography,
517 + pub colors: ColorScheme,
518 + pub appearance: Appearance,
519 +}
520 +
521 +impl Theme {
522 + pub const fn light() -> Self {
523 + Self {
524 + spacing: Spacing::standard(),
525 + radius: Radius::standard(),
526 + metrics: Metrics::standard(),
527 + typography: Typography::standard(),
528 + colors: ColorScheme::light(),
529 + appearance: Appearance::Light,
530 + }
531 + }
532 +
533 + pub const fn dark() -> Self {
534 + Self {
535 + spacing: Spacing::standard(),
536 + radius: Radius::standard(),
537 + metrics: Metrics::standard(),
538 + typography: Typography::standard(),
539 + colors: ColorScheme::dark(),
540 + appearance: Appearance::Dark,
541 + }
542 + }
543 +
544 + /// Ten sam motyw z podmienionym akcentem oraz kolorem treści na akcencie.
545 + pub fn accented(mut self, accent: Color, foreground: Color) -> Self {
546 + self.colors.accent = accent;
547 + self.colors.accent_fg = foreground;
548 + self
549 + }
550 +}
551 +
552 +impl Default for Theme {
553 + fn default() -> Self {
554 + Theme::light()
555 + }
556 +}
557 +
558 +#[cfg(test)]
559 +mod tests {
560 + use super::*;
561 +
562 + #[test]
563 + fn light_and_dark_differ() {
564 + assert_ne!(
565 + Theme::light().colors.window_bg,
566 + Theme::dark().colors.window_bg
567 + );
568 + assert_eq!(Theme::light().appearance, Appearance::Light);
569 + assert_eq!(Theme::dark().appearance, Appearance::Dark);
570 + }
571 +
572 + #[test]
573 + fn standard_tokens_are_sane() {
574 + let s = Spacing::standard();
575 + assert!(s.xs < s.sm && s.sm < s.md && s.md < s.lg && s.lg < s.xl);
576 + let t = Typography::standard();
577 + assert!(t.title.size > t.heading.size && t.heading.size > t.body.size);
578 + }
579 +}
0 580 new file mode 100644
@@ -0,0 +1,10 @@
1 +[package]
2 +name = "libluna-i18n"
3 +version.workspace = true
4 +edition.workspace = true
5 +license.workspace = true
6 +rust-version.workspace = true
7 +authors.workspace = true
8 +description = "Lekki system wielojęzyczny (i18n) dla libLuny — bez gettext."
9 +
10 +[dependencies]
0 11 new file mode 100644
@@ -0,0 +1,47 @@
1 +# Deutsch. Format: Schlüssel = Übersetzung (Schlüssel ist der englische Text).
2 +# Zeilen, die mit # beginnen, sind Kommentare. `__name__` = Sprachname in der UI.
3 +
4 +__name__ = Deutsch
5 +
6 +Application = Anwendung
7 +Options = Optionen
8 +Overview = Übersicht
9 +Account = Konto
10 +Appearance = Erscheinungsbild
11 +Notifications = Benachrichtigungen
12 +Advanced = Erweitert
13 +Network = Netzwerk
14 +System = System
15 +Settings = Einstellungen
16 +Option A = OPTION A
17 +Option B = OPTION B
18 +Volume level = Lautstärke
19 +Choose… = Auswählen…
20 +Status: Ready = Status: Bereit
21 +Version = Version
22 +Theme = Design
23 +Light = Hell
24 +Dark = Dunkel
25 +Auto = Automatisch
26 +Accent color = Akzentfarbe
27 +Language = Sprache
28 +Bottom bar = Fußleiste
29 +Sidebar = Seitenleiste
30 +View = Ansicht
31 +List = Liste
32 +Grid = Raster
33 +New Window = Neues Fenster
34 +Keyboard Shortcuts = Tastenkürzel
35 +About = Über
36 +General = Allgemein
37 +Tabs = Reiter
38 +Active tab = Aktiver Tab
39 +License = Lizenz
40 +Home = Persönlicher Ordner
41 +Documents = Dokumente
42 +Projects = Projekte
43 +Search = Suchen
44 +Files = Dateien
45 +Report = Bericht
46 +Notes = Notizen
47 +Budget = Budget
0 48 new file mode 100644
@@ -0,0 +1,48 @@
1 +# English — język bazowy. Kluczem jest sam angielski napis.
2 +# Format: klucz = tłumaczenie (linie zaczynające się od # to komentarze).
3 +# `__name__` to nazwa języka pokazywana w interfejsie (w tym języku).
4 +
5 +__name__ = English
6 +
7 +Application = Application
8 +Options = Options
9 +Overview = Overview
10 +Account = Account
11 +Appearance = Appearance
12 +Notifications = Notifications
13 +Advanced = Advanced
14 +Network = Network
15 +System = System
16 +Settings = Settings
17 +Option A = Option A
18 +Option B = Option B
19 +Volume level = Volume level
20 +Choose… = Choose…
21 +Status: Ready = Status: Ready
22 +Version = Version
23 +Theme = Theme
24 +Light = Light
25 +Dark = Dark
26 +Auto = Auto
27 +Accent color = Accent color
28 +Language = Language
29 +Bottom bar = Bottom bar
30 +Sidebar = Sidebar
31 +View = View
32 +List = List
33 +Grid = Grid
34 +New Window = New Window
35 +Keyboard Shortcuts = Keyboard Shortcuts
36 +About = About
37 +General = General
38 +Tabs = Tabs
39 +Active tab = Active tab
40 +License = License
41 +Home = Home
42 +Documents = Documents
43 +Projects = Projects
44 +Search = Search
45 +Files = Files
46 +Report = Report
47 +Notes = Notes
48 +Budget = Budget
0 49 new file mode 100644
@@ -0,0 +1,47 @@
1 +# Polski. Format: klucz = tłumaczenie (kluczem jest angielski napis).
2 +# Linie zaczynające się od # to komentarze. `__name__` = nazwa języka w UI.
3 +
4 +__name__ = Polski
5 +
6 +Application = Aplikacja
7 +Options = Opcje
8 +Overview = Przegląd
9 +Account = Konto
10 +Appearance = Wygląd
11 +Notifications = Powiadomienia
12 +Advanced = Zaawansowane
13 +Network = Sieć
14 +System = System
15 +Settings = Ustawienia
16 +Option A = OPCJA A
17 +Option B = OPCJA B
18 +Volume level = Poziom głośności
19 +Choose… = Wybierz…
20 +Status: Ready = Status: Gotowy
21 +Version = Wersja
22 +Theme = Motyw
23 +Light = Jasny
24 +Dark = Ciemny
25 +Auto = Auto
26 +Accent color = Kolor akcentu
27 +Language = Język
28 +Bottom bar = Pasek dolny
29 +Sidebar = Pasek boczny
30 +View = Widok
31 +List = Lista
32 +Grid = Siatka
33 +New Window = Nowe okno
34 +Keyboard Shortcuts = Skróty klawiszowe
35 +About = O aplikacji
36 +General = Ogólne
37 +Tabs = Zakładki
38 +Active tab = Aktywna zakładka
39 +License = Licencja
40 +Home = Domowy
41 +Documents = Dokumenty
42 +Projects = Projekty
43 +Search = Szukaj
44 +Files = Pliki
45 +Report = Raport
46 +Notes = Notatki
47 +Budget = Budżet
0 48 new file mode 100644
@@ -0,0 +1,423 @@
1 +//! # libluna-i18n
2 +//!
3 +//! Lekki system wielojęzyczny dla libLuny — bez gettext i bez zależności zewnętrznych.
4 +//!
5 +//! Model jest celowo prosty, w duchu gettext:
6 +//!
7 +//! - **kluczem jest angielski napis** (np. `"Appearance"`), więc brak tłumaczenia
8 +//! degraduje się łagodnie do angielskiego;
9 +//! - każdy język to **osobny plik** `i18n/<kod>.lang` (format `klucz = tłumaczenie`),
10 +//! wbudowany w binarkę przez `include_str!` — łatwo dodawać i edytować;
11 +//! - dodatkowo języki można **doładować w czasie działania** z katalogów
12 +//! (`/usr/share/luna/i18n`, `~/.local/share/luna/i18n`, `~/.config/luna/i18n`).
13 +//!
14 +//! ```no_run
15 +//! use libluna_i18n::{tr, Locale};
16 +//! libluna_i18n::set_locale(Locale::new("pl"));
17 +//! assert_eq!(tr("Appearance"), "Wygląd");
18 +//! ```
19 +//!
20 +//! Podstawianie argumentów: `tr_args("Hello, {name}", &[("name", "Ania")])`.
21 +//! Każdy katalog może zdefiniować `__name__` — natywną nazwę języka do UI.
22 +
23 +use std::collections::{BTreeMap, HashMap};
24 +use std::io;
25 +use std::path::{Path, PathBuf};
26 +use std::sync::{OnceLock, RwLock};
27 +
28 +/// Klucz z nazwą języka w danym katalogu (np. `Polski`).
29 +const NAME_KEY: &str = "__name__";
30 +
31 +/// Znacznik języka, np. `pl`, `pl_PL`, `en`, `de`.
32 +#[derive(Debug, Clone, PartialEq, Eq, Hash)]
33 +pub struct Locale(String);
34 +
35 +impl Locale {
36 + /// Tworzy język z tagu (np. `pl_PL.UTF-8` → `pl`).
37 + pub fn new(tag: impl AsRef<str>) -> Self {
38 + let raw = tag.as_ref();
39 + // Utnij kodowanie (`.UTF-8`) i modyfikator (`@euro`).
40 + let base = raw.split(['.', '@']).next().unwrap_or(raw);
41 + let language = base.split(['_', '-']).next().unwrap_or(base);
42 + Self(language.to_ascii_lowercase())
43 + }
44 +
45 + /// Wykrywa język ze środowiska (`LC_ALL`, `LC_MESSAGES`, `LANG`).
46 + pub fn from_env() -> Self {
47 + for key in ["LC_ALL", "LC_MESSAGES", "LANG"] {
48 + if let Ok(value) = std::env::var(key) {
49 + if !value.is_empty() && value != "C" && value != "POSIX" {
50 + return Locale::new(value);
51 + }
52 + }
53 + }
54 + Locale::new("en")
55 + }
56 +
57 + /// Kod języka (np. `pl`).
58 + pub fn language(&self) -> &str {
59 + &self.0
60 + }
61 +}
62 +
63 +impl Default for Locale {
64 + fn default() -> Self {
65 + Locale::new("en")
66 + }
67 +}
68 +
69 +impl From<&str> for Locale {
70 + fn from(value: &str) -> Self {
71 + Locale::new(value)
72 + }
73 +}
74 +
75 +impl From<String> for Locale {
76 + fn from(value: String) -> Self {
77 + Locale::new(value)
78 + }
79 +}
80 +
81 +/// Katalog tłumaczeń jednego języka.
82 +#[derive(Debug, Clone, Default)]
83 +pub struct Catalog {
84 + entries: HashMap<String, String>,
85 +}
86 +
87 +impl Catalog {
88 + pub fn new() -> Self {
89 + Self::default()
90 + }
91 +
92 + /// Buduje katalog z tekstu (format `klucz = tłumaczenie`).
93 + pub fn parse(src: &str) -> Self {
94 + let mut catalog = Self::new();
95 + catalog.parse_into(src);
96 + catalog
97 + }
98 +
99 + /// Dodaje/aktualizuje wpisy z tekstu (pozwala nakładać pliki na siebie).
100 + pub fn parse_into(&mut self, src: &str) {
101 + for line in src.lines() {
102 + let line = line.trim();
103 + if line.is_empty() || line.starts_with('#') {
104 + continue;
105 + }
106 + if let Some((key, value)) = line.split_once('=') {
107 + self.entries
108 + .insert(key.trim().to_owned(), value.trim().to_owned());
109 + }
110 + }
111 + }
112 +
113 + /// Tłumaczenie dla klucza, jeśli istnieje.
114 + pub fn get(&self, key: &str) -> Option<&str> {
115 + self.entries.get(key).map(String::as_str)
116 + }
117 +
118 + /// Natywna nazwa języka z `__name__` (jeśli zdefiniowana).
119 + pub fn name(&self) -> Option<&str> {
120 + self.get(NAME_KEY)
121 + }
122 +
123 + pub fn len(&self) -> usize {
124 + self.entries.len()
125 + }
126 +
127 + pub fn is_empty(&self) -> bool {
128 + self.entries.is_empty()
129 + }
130 +}
131 +
132 +/// Zbiór katalogów + bieżący język + język zapasowy.
133 +#[derive(Debug, Clone)]
134 +pub struct Translations {
135 + locale: Locale,
136 + catalogs: BTreeMap<String, Catalog>,
137 + fallback: String,
138 +}
139 +
140 +impl Translations {
141 + /// Tworzy pusty zbiór z językiem zapasowym (domyślnie `en`).
142 + pub fn new(locale: Locale) -> Self {
143 + Self {
144 + locale,
145 + catalogs: BTreeMap::new(),
146 + fallback: "en".to_owned(),
147 + }
148 + }
149 +
150 + /// Dodaje/nałada katalog języka z tekstu.
151 + pub fn add_source(&mut self, language: &str, src: &str) -> &mut Self {
152 + self.catalogs
153 + .entry(language.to_ascii_lowercase())
154 + .or_default()
155 + .parse_into(src);
156 + self
157 + }
158 +
159 + /// Zestaw wbudowanych języków libLuny (pliki `i18n/*.lang` wbudowane w binarkę).
160 + pub fn builtin() -> Self {
161 + let mut t = Translations::new(Locale::from_env());
162 + t.add_source("en", include_str!("../i18n/en.lang"));
163 + t.add_source("pl", include_str!("../i18n/pl.lang"));
164 + t.add_source("de", include_str!("../i18n/de.lang"));
165 + t
166 + }
167 +
168 + pub fn locale(&self) -> &Locale {
169 + &self.locale
170 + }
171 +
172 + /// Ustawia bieżący język.
173 + pub fn set_locale(&mut self, locale: Locale) {
174 + self.locale = locale;
175 + }
176 +
177 + /// Krótki kod bieżącego języka.
178 + pub fn language(&self) -> &str {
179 + self.locale.language()
180 + }
181 +
182 + /// Dostępne kody języków (posortowane).
183 + pub fn languages(&self) -> Vec<String> {
184 + self.catalogs.keys().cloned().collect()
185 + }
186 +
187 + /// Natywna nazwa języka z jego katalogu (np. `Polski`).
188 + pub fn language_name(&self, language: &str) -> Option<&str> {
189 + self.catalogs.get(language).and_then(Catalog::name)
190 + }
191 +
192 + /// Tłumaczenie klucza; przy braku wpisu zwraca sam klucz (angielski).
193 + pub fn translate<'a>(&'a self, key: &'a str) -> &'a str {
194 + if let Some(catalog) = self.catalogs.get(self.locale.language()) {
195 + if let Some(value) = catalog.get(key) {
196 + return value;
197 + }
198 + }
199 + if let Some(catalog) = self.catalogs.get(&self.fallback) {
200 + if let Some(value) = catalog.get(key) {
201 + return value;
202 + }
203 + }
204 + key
205 + }
206 +
207 + /// Tłumaczenie z podstawieniem `{argumentów}`.
208 + pub fn translate_args(&self, key: &str, args: &[(&str, &str)]) -> String {
209 + let mut out = self.translate(key).to_owned();
210 + for (name, value) in args {
211 + out = out.replace(&format!("{{{name}}}"), value);
212 + }
213 + out
214 + }
215 +
216 + /// Doładowuje katalog z pojedynczego pliku `*.lang` (kod języka = nazwa pliku).
217 + pub fn load_file(&mut self, path: &Path) -> io::Result<()> {
218 + let Some(code) = path.file_stem().and_then(|s| s.to_str()) else {
219 + return Ok(());
220 + };
221 + let src = std::fs::read_to_string(path)?;
222 + self.add_source(code, &src);
223 + Ok(())
224 + }
225 +
226 + /// Doładowuje wszystkie pliki `*.lang` z katalogu; zwraca liczbę wczytanych.
227 + pub fn load_dir(&mut self, dir: &Path) -> usize {
228 + let Ok(entries) = std::fs::read_dir(dir) else {
229 + return 0;
230 + };
231 + let mut count = 0;
232 + for entry in entries.flatten() {
233 + let path = entry.path();
234 + if path.extension().and_then(|e| e.to_str()) == Some("lang")
235 + && self.load_file(&path).is_ok()
236 + {
237 + count += 1;
238 + }
239 + }
240 + count
241 + }
242 +}
243 +
244 +impl Default for Translations {
245 + fn default() -> Self {
246 + Translations::builtin()
247 + }
248 +}
249 +
250 +/// Standardowe katalogi wyszukiwania języków doładowywanych w runtime.
251 +pub fn catalog_dirs() -> Vec<PathBuf> {
252 + let mut dirs = Vec::new();
253 + dirs.push(PathBuf::from("/usr/share/luna/i18n"));
254 + if let Some(data) = std::env::var_os("XDG_DATA_HOME")
255 + .map(PathBuf::from)
256 + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/share")))
257 + {
258 + dirs.push(data.join("luna/i18n"));
259 + }
260 + if let Some(config) = std::env::var_os("XDG_CONFIG_HOME")
261 + .map(PathBuf::from)
262 + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
263 + {
264 + dirs.push(config.join("luna/i18n"));
265 + }
266 + dirs
267 +}
268 +
269 +/// Globalna instancja tłumaczeń.
270 +fn global() -> &'static RwLock<Translations> {
271 + static GLOBAL: OnceLock<RwLock<Translations>> = OnceLock::new();
272 + GLOBAL.get_or_init(|| RwLock::new(Translations::builtin()))
273 +}
274 +
275 +/// Ustawia globalny język.
276 +pub fn set_locale(locale: impl Into<Locale>) {
277 + if let Ok(mut guard) = global().write() {
278 + guard.set_locale(locale.into());
279 + }
280 +}
281 +
282 +/// Bieżący globalny język.
283 +pub fn locale() -> Locale {
284 + global()
285 + .read()
286 + .map(|g| g.locale().clone())
287 + .unwrap_or_default()
288 +}
289 +
290 +/// Doładowuje języki ze standardowych katalogów (nadpisują wbudowane).
291 +/// Zwraca liczbę wczytanych plików.
292 +pub fn load_catalogs_from_dirs() -> usize {
293 + let dirs = catalog_dirs();
294 + match global().write() {
295 + Ok(mut guard) => dirs.iter().map(|d| guard.load_dir(d)).sum(),
296 + Err(_) => 0,
297 + }
298 +}
299 +
300 +/// Dostępne kody języków (globalnie).
301 +pub fn languages() -> Vec<String> {
302 + global().read().map(|g| g.languages()).unwrap_or_default()
303 +}
304 +
305 +/// Natywna nazwa języka (globalnie).
306 +pub fn language_name(language: &str) -> Option<String> {
307 + global()
308 + .read()
309 + .ok()
310 + .and_then(|g| g.language_name(language).map(str::to_owned))
311 +}
312 +
313 +/// Przetłumaczony napis (globalnie).
314 +pub fn tr(key: &str) -> String {
315 + global()
316 + .read()
317 + .map(|g| g.translate(key).to_owned())
318 + .unwrap_or_else(|_| key.to_owned())
319 +}
320 +
321 +/// Przetłumaczony napis z podstawieniem argumentów (globalnie).
322 +pub fn tr_args(key: &str, args: &[(&str, &str)]) -> String {
323 + global()
324 + .read()
325 + .map(|g| g.translate_args(key, args))
326 + .unwrap_or_else(|_| key.to_owned())
327 +}
328 +
329 +/// Skrót do [`tr`] / [`tr_args`].
330 +///
331 +/// ```ignore
332 +/// let s = t!("Appearance");
333 +/// let s = t!("Witaj, {name}", name = "Ania");
334 +/// ```
335 +#[macro_export]
336 +macro_rules! t {
337 + ($key:expr) => {
338 + $crate::tr($key)
339 + };
340 + ($key:expr, $($k:ident = $v:expr),+ $(,)?) => {
341 + $crate::tr_args($key, &[$( (stringify!($k), $v) ),+])
342 + };
343 +}
344 +
345 +#[cfg(test)]
346 +mod tests {
347 + use super::*;
348 + use std::io::Write;
349 +
350 + #[test]
351 + fn locale_normalization() {
352 + assert_eq!(Locale::new("pl_PL.UTF-8").language(), "pl");
353 + assert_eq!(Locale::new("de-DE").language(), "de");
354 + assert_eq!(Locale::new("en_US").language(), "en");
355 + assert_eq!(Locale::new("C").language(), "c");
356 + }
357 +
358 + #[test]
359 + fn catalog_parses_and_ignores_comments() {
360 + let c = Catalog::parse("# komentarz\nA = B\n\nC = D = E\n");
361 + assert_eq!(c.get("A"), Some("B"));
362 + // Dzielimy tylko po pierwszym `=`.
363 + assert_eq!(c.get("C"), Some("D = E"));
364 + }
365 +
366 + #[test]
367 + fn builtin_translates_and_falls_back() {
368 + let mut t = Translations::new(Locale::new("pl"));
369 + t.add_source("pl", include_str!("../i18n/pl.lang"));
370 + assert_eq!(t.translate("Appearance"), "Wygląd");
371 + assert_eq!(t.translate("Nieznany klucz"), "Nieznany klucz");
372 + }
373 +
374 + #[test]
375 + fn switching_locale_changes_output() {
376 + let mut t = Translations::new(Locale::new("en"));
377 + t.add_source("pl", include_str!("../i18n/pl.lang"));
378 + t.add_source("de", include_str!("../i18n/de.lang"));
379 + t.set_locale(Locale::new("de"));
380 + assert_eq!(t.translate("Dark"), "Dunkel");
381 + t.set_locale(Locale::new("pl"));
382 + assert_eq!(t.translate("Dark"), "Ciemny");
383 + }
384 +
385 + #[test]
386 + fn language_names_and_listing() {
387 + let t = Translations::builtin();
388 + assert_eq!(t.language_name("pl"), Some("Polski"));
389 + assert_eq!(t.language_name("de"), Some("Deutsch"));
390 + assert_eq!(t.language_name("en"), Some("English"));
391 + assert!(t.languages().contains(&"pl".to_owned()));
392 + }
393 +
394 + #[test]
395 + fn args_substitution() {
396 + let t = Translations::new(Locale::new("en"));
397 + assert_eq!(
398 + t.translate_args("Hi, {name}!", &[("name", "Ada")]),
399 + "Hi, Ada!"
400 + );
401 + }
402 +
403 + #[test]
404 + fn load_file_overrides_and_adds() {
405 + let dir = std::env::temp_dir().join("libluna-i18n-test");
406 + std::fs::create_dir_all(&dir).unwrap();
407 + let path = dir.join("xx.lang");
408 + let mut f = std::fs::File::create(&path).unwrap();
409 + writeln!(f, "__name__ = Testish").unwrap();
410 + writeln!(f, "Appearance = Xyglad").unwrap();
411 + drop(f);
412 +
413 + let mut t = Translations::builtin();
414 + t.load_file(&path).unwrap();
415 + t.set_locale(Locale::new("xx"));
416 + assert_eq!(t.translate("Appearance"), "Xyglad");
417 + assert_eq!(t.language_name("xx"), Some("Testish"));
418 + // Klucz nieprzetłumaczony w `xx` spada do bazy `en`.
419 + assert_eq!(t.translate("System"), "System");
420 +
421 + let _ = std::fs::remove_file(&path);
422 + }
423 +}
0 424 new file mode 100644
@@ -0,0 +1,15 @@
1 +[package]
2 +name = "libluna-platform"
3 +version.workspace = true
4 +edition.workspace = true
5 +license.workspace = true
6 +rust-version.workspace = true
7 +authors.workspace = true
8 +description = "Warstwa platformy libLuny: okna Wayland (xdg-shell) i wejście."
9 +
10 +[dependencies]
11 +libluna-core.workspace = true
12 +libluna-render.workspace = true
13 +
14 +smithay-client-toolkit = "0.19"
15 +wayland-client = "0.31"
0 16 new file mode 100644
@@ -0,0 +1,14 @@
1 +//! # libluna-platform
2 +//!
3 +//! Warstwa platformy libLuny — odpowiednik GDK w GTK.
4 +//!
5 +//! Odpowiada za połączenie z kompozytorem Wayland (Selene), tworzenie okien przez
6 +//! `xdg-shell` z dekoracjami po stronie klienta (CSD), bufory `wl_shm`, obsługę
7 +//! wejścia (`seat`, `xkbcommon`) oraz pętlę zdarzeń.
8 +//!
9 +//! Punkt wejścia to [`run`] z [`AppConfig`]: otwiera okno i wywołuje domknięcie
10 +//! rysujące przy każdym odświeżeniu.
11 +
12 +pub mod window;
13 +
14 +pub use window::{run, AppConfig, Frame};
0 15 new file mode 100644
@@ -0,0 +1,720 @@
1 +//! Pojedyncze okno Wayland (`xdg-shell`) z buforem `wl_shm`, wejściem i pętlą zdarzeń.
2 +//!
3 +//! Kontrakt: [`run`] otwiera okno i woła domknięcie rysujące. Domknięcie otrzymuje
4 +//! [`Frame`], który daje dostęp do sceny, motywu, silnika tekstu oraz **zdarzeń wejścia**
5 +//! od ostatniej klatki. Motywem steruje się z poziomu domknięcia przez
6 +//! [`Frame::set_preference`] i [`Frame::set_accent`] (zmiana jest natychmiast odzwierciedlona
7 +//! i zapisana).
8 +//!
9 +//! Dekoracje są po stronie klienta (CSD) — rysujemy własny HeaderBar.
10 +
11 +use std::error::Error;
12 +use std::time::Duration;
13 +
14 +use libluna_core::{
15 + Accent, ButtonState, Event, KeyEvent, Modifiers, Point, PointerButton, PointerEvent,
16 + ScrollEvent, Size, Theme, ThemePreference, ThemeSettings, WindowEvent,
17 +};
18 +use libluna_render::{Scene, SoftwareRenderer, TextEngine};
19 +
20 +use smithay_client_toolkit::reexports::calloop::{EventLoop, LoopHandle};
21 +use smithay_client_toolkit::reexports::calloop_wayland_source::WaylandSource;
22 +use smithay_client_toolkit::{
23 + compositor::{CompositorHandler, CompositorState},
24 + delegate_compositor, delegate_keyboard, delegate_output, delegate_pointer, delegate_registry,
25 + delegate_seat, delegate_shm, delegate_xdg_shell, delegate_xdg_window,
26 + output::{OutputHandler, OutputState},
27 + registry::{ProvidesRegistryState, RegistryState},
28 + registry_handlers,
29 + seat::{
30 + keyboard::{KeyEvent as SctkKeyEvent, KeyboardHandler, Keysym, Modifiers as KbModifiers},
31 + pointer::{PointerEvent as SctkPointerEvent, PointerEventKind, PointerHandler},
32 + Capability, SeatHandler, SeatState,
33 + },
34 + shell::{
35 + xdg::{
36 + window::{Window, WindowConfigure, WindowDecorations, WindowHandler},
37 + XdgShell,
38 + },
39 + WaylandSurface,
40 + },
41 + shm::{
42 + slot::{Buffer, SlotPool},
43 + Shm, ShmHandler,
44 + },
45 +};
46 +use wayland_client::{
47 + globals::registry_queue_init,
48 + protocol::{wl_keyboard, wl_output, wl_pointer, wl_seat, wl_shm, wl_surface},
49 + Connection, QueueHandle,
50 +};
51 +
52 +/// Domknięcie rysujące jedną klatkę (łącznie z obsługą zdarzeń).
53 +type RenderFn = dyn for<'a> FnMut(&mut Frame<'a>);
54 +
55 +/// Konfiguracja okna.
56 +#[derive(Debug, Clone)]
57 +pub struct AppConfig {
58 + pub title: String,
59 + pub app_id: String,
60 + pub initial_size: (u32, u32),
61 + pub settings: ThemeSettings,
62 + /// Czy system zgłasza tryb ciemny (dla preferencji `System`).
63 + pub system_dark: bool,
64 +}
65 +
66 +impl AppConfig {
67 + pub fn new(title: impl Into<String>) -> Self {
68 + Self {
69 + title: title.into(),
70 + app_id: "eu.paganlinux.Luna.Demo".to_owned(),
71 + initial_size: (900, 560),
72 + settings: ThemeSettings::default(),
73 + system_dark: false,
74 + }
75 + }
76 +
77 + pub fn app_id(mut self, app_id: impl Into<String>) -> Self {
78 + self.app_id = app_id.into();
79 + self
80 + }
81 +
82 + pub fn initial_size(mut self, width: u32, height: u32) -> Self {
83 + self.initial_size = (width, height);
84 + self
85 + }
86 +
87 + pub fn settings(mut self, settings: ThemeSettings) -> Self {
88 + self.settings = settings;
89 + self
90 + }
91 +
92 + pub fn system_dark(mut self, dark: bool) -> Self {
93 + self.system_dark = dark;
94 + self
95 + }
96 +}
97 +
98 +/// Akcje okna zgłaszane przez aplikację (przez [`Frame::close`] itd.).
99 +#[derive(Debug, Clone, Copy, Default)]
100 +pub struct WindowActions {
101 + pub close: bool,
102 + pub minimize: bool,
103 + pub toggle_maximize: bool,
104 +}
105 +
106 +/// Kontekst rysowania i obsługi zdarzeń przekazywany do domknięcia aplikacji.
107 +///
108 +/// Scena jest w pikselach **logicznych**; [`Frame::scale`] mówi, ile pikseli
109 +/// fizycznych przypada na jednostkę. Zdarzenia z [`Frame::events`] dotyczą okresu
110 +/// od poprzedniej klatki.
111 +pub struct Frame<'a> {
112 + pub scene: &'a mut Scene,
113 + /// Rozwiązany motyw (jasny/ciemny + akcent) dla bieżącej klatki.
114 + pub theme: Theme,
115 + pub text: &'a TextEngine,
116 + pub size: Size,
117 + pub scale: f64,
118 + /// Zdarzenia wejścia od poprzedniej klatki.
119 + pub events: &'a [Event],
120 + settings: &'a mut ThemeSettings,
121 + actions: &'a mut WindowActions,
122 + system_dark: bool,
123 +}
124 +
125 +impl Frame<'_> {
126 + /// Prosi o zamknięcie okna.
127 + pub fn close(&mut self) {
128 + self.actions.close = true;
129 + }
130 +
131 + /// Prosi o zminimalizowanie okna.
132 + pub fn minimize(&mut self) {
133 + self.actions.minimize = true;
134 + }
135 +
136 + /// Przełącza maksymalizację okna.
137 + pub fn toggle_maximize(&mut self) {
138 + self.actions.toggle_maximize = true;
139 + }
140 + /// Bieżące ustawienia wyglądu (kopia).
141 + pub fn settings(&self) -> ThemeSettings {
142 + *self.settings
143 + }
144 +
145 + /// Mierzy tekst w pikselach logicznych (szerokość i wysokość).
146 + pub fn measure_text(&self, text: &str, size: f64, weight: u16) -> Size {
147 + self.text.measure(text, size, weight)
148 + }
149 +
150 + /// Bieżąca preferencja trybu.
151 + pub fn preference(&self) -> ThemePreference {
152 + self.settings.preference()
153 + }
154 +
155 + /// Ustawia preferencję trybu (auto/jasny/ciemny); zmiana jest zapisywana.
156 + pub fn set_preference(&mut self, preference: ThemePreference) {
157 + self.settings.set_preference(preference);
158 + let _ = self.settings.save();
159 + }
160 +
161 + /// Bieżący kolor akcentu.
162 + pub fn accent(&self) -> Accent {
163 + self.settings.accent()
164 + }
165 +
166 + /// Ustawia kolor akcentu; zmiana jest zapisywana.
167 + pub fn set_accent(&mut self, accent: Accent) {
168 + self.settings.set_accent(accent);
169 + let _ = self.settings.save();
170 + }
171 +
172 + /// Czy system zgłasza tryb ciemny.
173 + pub const fn system_dark(&self) -> bool {
174 + self.system_dark
175 + }
176 +}
177 +
178 +/// Otwiera okno i uruchamia pętlę zdarzeń, wołając `render` przy każdej potrzebie
179 +/// odświeżenia (start, zmiana rozmiaru/skali, zdarzenia wejścia, zmiana motywu).
180 +///
181 +/// Funkcja wraca, gdy okno zostanie zamknięte (przez użytkownika lub `Escape`).
182 +pub fn run<F>(config: AppConfig, render: F) -> Result<(), Box<dyn Error>>
183 +where
184 + F: for<'a> FnMut(&mut Frame<'a>) + 'static,
185 +{
186 + let conn = Connection::connect_to_env()?;
187 + let (globals, event_queue) = registry_queue_init(&conn)?;
188 + let qh = event_queue.handle();
189 +
190 + let mut event_loop: EventLoop<App> = EventLoop::try_new()?;
191 + let loop_handle = event_loop.handle();
192 + WaylandSource::new(conn.clone(), event_queue).insert(loop_handle)?;
193 +
194 + let compositor = CompositorState::bind(&globals, &qh)
195 + .map_err(|e| format!("wl_compositor niedostępny: {e}"))?;
196 + let xdg_shell =
197 + XdgShell::bind(&globals, &qh).map_err(|e| format!("xdg-shell niedostępny: {e}"))?;
198 + let shm = Shm::bind(&globals, &qh).map_err(|e| format!("wl_shm niedostępny: {e}"))?;
199 +
200 + let surface = compositor.create_surface(&qh);
201 + // CSD: sami rysujemy HeaderBar (brak dekoracji serwera).
202 + let window = xdg_shell.create_window(surface, WindowDecorations::ClientOnly, &qh);
203 + window.set_title(&config.title);
204 + window.set_app_id(&config.app_id);
205 + window.set_min_size(Some((380, 260)));
206 + // Pierwszy commit bez bufora — kompozytor odpowie konfiguracją.
207 + window.commit();
208 +
209 + let (iw, ih) = config.initial_size;
210 + let pool = SlotPool::new((iw as usize * ih as usize * 4).max(1024), &shm)?;
211 +
212 + let mut app = App {
213 + registry_state: RegistryState::new(&globals),
214 + seat_state: SeatState::new(&globals, &qh),
215 + output_state: OutputState::new(&globals, &qh),
216 + shm,
217 + loop_handle: event_loop.handle(),
218 + exit: false,
219 + pool,
220 + width: iw,
221 + height: ih,
222 + scale: 1.0,
223 + system_dark: config.system_dark,
224 + buffer: None,
225 + window,
226 + keyboard: None,
227 + pointer: None,
228 + modifiers: Modifiers::NONE,
229 + renderer: SoftwareRenderer::new(iw, ih),
230 + settings: config.settings,
231 + scene: Scene::new(),
232 + render: Box::new(render),
233 + pending_events: Vec::new(),
234 + needs_redraw: true,
235 + actions: WindowActions::default(),
236 + maximized: false,
237 + };
238 +
239 + while !app.exit {
240 + event_loop.dispatch(Duration::from_millis(100), &mut app)?;
241 + if app.needs_redraw {
242 + app.redraw();
243 + }
244 + }
245 + Ok(())
246 +}
247 +
248 +struct App {
249 + registry_state: RegistryState,
250 + seat_state: SeatState,
251 + output_state: OutputState,
252 + shm: Shm,
253 + #[allow(dead_code)]
254 + loop_handle: LoopHandle<'static, App>,
255 + exit: bool,
256 + pool: SlotPool,
257 + width: u32,
258 + height: u32,
259 + scale: f64,
260 + system_dark: bool,
261 + buffer: Option<Buffer>,
262 + window: Window,
263 + keyboard: Option<wl_keyboard::WlKeyboard>,
264 + pointer: Option<wl_pointer::WlPointer>,
265 + modifiers: Modifiers,
266 + renderer: SoftwareRenderer,
267 + settings: ThemeSettings,
268 + scene: Scene,
269 + render: Box<RenderFn>,
270 + pending_events: Vec<Event>,
271 + needs_redraw: bool,
272 + actions: WindowActions,
273 + maximized: bool,
274 +}
275 +
276 +impl App {
277 + fn queue(&mut self, event: Event) {
278 + self.pending_events.push(event);
279 + self.needs_redraw = true;
280 + }
281 +
282 + fn redraw(&mut self) {
283 + let events = std::mem::take(&mut self.pending_events);
284 + self.needs_redraw = false;
285 + self.actions = WindowActions::default();
286 +
287 + self.settings.clear_dirty();
288 + let mut theme = self.settings.resolve(self.system_dark);
289 + self.render_frame(theme, &events);
290 +
291 + // Jeśli domknięcie zmieniło motyw w trakcie, przerysuj bez zdarzeń.
292 + let mut guard = 0;
293 + while self.settings.is_dirty() && guard < 4 {
294 + self.settings.clear_dirty();
295 + theme = self.settings.resolve(self.system_dark);
296 + self.render_frame(theme, &[]);
297 + guard += 1;
298 + }
299 + if self.settings.is_dirty() {
300 + self.settings.clear_dirty();
301 + self.needs_redraw = true;
302 + }
303 +
304 + self.present();
305 + self.apply_actions();
306 + }
307 +
308 + /// Wykonuje akcje okna zgłoszone w trakcie klatki.
309 + fn apply_actions(&mut self) {
310 + if self.actions.close {
311 + self.exit = true;
312 + }
313 + if self.actions.minimize {
314 + self.window.set_minimized();
315 + }
316 + if self.actions.toggle_maximize {
317 + self.maximized = !self.maximized;
318 + if self.maximized {
319 + self.window.set_maximized();
320 + } else {
321 + self.window.unset_maximized();
322 + }
323 + }
324 + }
325 +
326 + fn render_frame(&mut self, theme: Theme, events: &[Event]) {
327 + let (width, height, scale, system_dark) =
328 + (self.width, self.height, self.scale, self.system_dark);
329 + let phys_w = (width as f64 * scale).round().max(1.0) as u32;
330 + let phys_h = (height as f64 * scale).round().max(1.0) as u32;
331 + self.renderer.resize(phys_w, phys_h);
332 +
333 + {
334 + let App {
335 + render,
336 + scene,
337 + renderer,
338 + settings,
339 + actions,
340 + ..
341 + } = self;
342 + let mut frame = Frame {
343 + scene,
344 + theme,
345 + text: renderer.text_engine(),
346 + size: Size::new(width as f64, height as f64),
347 + scale,
348 + events,
349 + settings,
350 + actions,
351 + system_dark,
352 + };
353 + render(&mut frame);
354 + }
355 +
356 + self.renderer
357 + .draw(&self.scene, scale, theme.colors.window_bg);
358 + }
359 +
360 + fn present(&mut self) {
361 + let (width, height, scale) = (self.width, self.height, self.scale);
362 + let phys_w = (width as f64 * scale).round().max(1.0) as u32;
363 + let phys_h = (height as f64 * scale).round().max(1.0) as u32;
364 + let stride = phys_w as i32 * 4;
365 +
366 + let buffer = self.buffer.get_or_insert_with(|| {
367 + self.pool
368 + .create_buffer(
369 + phys_w as i32,
370 + phys_h as i32,
371 + stride,
372 + wl_shm::Format::Argb8888,
373 + )
374 + .expect("alokacja bufora shm")
375 + .0
376 + });
377 +
378 + let canvas = match self.pool.canvas(buffer) {
379 + Some(canvas) => canvas,
380 + None => {
381 + // Kompozytor wciąż trzyma poprzedni bufor — potrzebny drugi (double buffering).
382 + let (second, canvas) = self
383 + .pool
384 + .create_buffer(
385 + phys_w as i32,
386 + phys_h as i32,
387 + stride,
388 + wl_shm::Format::Argb8888,
389 + )
390 + .expect("alokacja drugiego bufora shm");
391 + *buffer = second;
392 + canvas
393 + }
394 + };
395 +
396 + // tiny-skia daje RGBA8888; wl_shm Argb8888 to w pamięci BGRA.
397 + let pixels = self.renderer.data();
398 + let count = (phys_w as usize * phys_h as usize).min(canvas.len() / 4);
399 + for (dst, src) in canvas
400 + .chunks_exact_mut(4)
401 + .zip(pixels.chunks_exact(4))
402 + .take(count)
403 + {
404 + dst[0] = src[2];
405 + dst[1] = src[1];
406 + dst[2] = src[0];
407 + dst[3] = src[3];
408 + }
409 +
410 + let surface = self.window.wl_surface();
411 + surface.damage_buffer(0, 0, phys_w as i32, phys_h as i32);
412 + buffer.attach_to(surface).expect("podłączenie bufora");
413 + self.window.commit();
414 + }
415 +}
416 +
417 +/// Zamienia kod przycisku evdev na typ libLuny.
418 +fn map_button(code: u32) -> PointerButton {
419 + match code {
420 + 0x110 => PointerButton::Left,
421 + 0x111 => PointerButton::Right,
422 + 0x112 => PointerButton::Middle,
423 + 0x116 => PointerButton::Back,
424 + 0x115 => PointerButton::Forward,
425 + other => PointerButton::Other(other),
426 + }
427 +}
428 +
429 +impl CompositorHandler for App {
430 + fn scale_factor_changed(
431 + &mut self,
432 + _conn: &Connection,
433 + _qh: &QueueHandle<Self>,
434 + _surface: &wl_surface::WlSurface,
435 + new_factor: i32,
436 + ) {
437 + self.scale = new_factor as f64;
438 + self.queue(Event::Window(WindowEvent::ScaleChanged(self.scale)));
439 + }
440 +
441 + fn transform_changed(
442 + &mut self,
443 + _conn: &Connection,
444 + _qh: &QueueHandle<Self>,
445 + _surface: &wl_surface::WlSurface,
446 + _transform: wl_output::Transform,
447 + ) {
448 + }
449 +
450 + fn frame(
451 + &mut self,
452 + _conn: &Connection,
453 + _qh: &QueueHandle<Self>,
454 + _surface: &wl_surface::WlSurface,
455 + _time: u32,
456 + ) {
457 + self.needs_redraw = true;
458 + }
459 +
460 + fn surface_enter(
461 + &mut self,
462 + _conn: &Connection,
463 + _qh: &QueueHandle<Self>,
464 + _surface: &wl_surface::WlSurface,
465 + _output: &wl_output::WlOutput,
466 + ) {
467 + }
468 +
469 + fn surface_leave(
470 + &mut self,
471 + _conn: &Connection,
472 + _qh: &QueueHandle<Self>,
473 + _surface: &wl_surface::WlSurface,
474 + _output: &wl_output::WlOutput,
475 + ) {
476 + }
477 +}
478 +
479 +impl OutputHandler for App {
480 + fn output_state(&mut self) -> &mut OutputState {
481 + &mut self.output_state
482 + }
483 +
484 + fn new_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
485 + fn update_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
486 + fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
487 +}
488 +
489 +impl WindowHandler for App {
490 + fn request_close(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &Window) {
491 + self.exit = true;
492 + }
493 +
494 + fn configure(
495 + &mut self,
496 + _conn: &Connection,
497 + _qh: &QueueHandle<Self>,
498 + _window: &Window,
499 + configure: WindowConfigure,
500 + _serial: u32,
501 + ) {
502 + self.buffer = None;
503 + self.width = configure.new_size.0.map(|v| v.get()).unwrap_or(self.width);
504 + self.height = configure.new_size.1.map(|v| v.get()).unwrap_or(self.height);
505 + self.queue(Event::Window(WindowEvent::Resize(Size::new(
506 + self.width as f64,
507 + self.height as f64,
508 + ))));
509 + }
510 +}
511 +
512 +impl SeatHandler for App {
513 + fn seat_state(&mut self) -> &mut SeatState {
514 + &mut self.seat_state
515 + }
516 +
517 + fn new_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_seat::WlSeat) {}
518 +
519 + fn new_capability(
520 + &mut self,
521 + _conn: &Connection,
522 + qh: &QueueHandle<Self>,
523 + seat: wl_seat::WlSeat,
524 + capability: Capability,
525 + ) {
526 + if capability == Capability::Keyboard && self.keyboard.is_none() {
527 + if let Ok(keyboard) = self.seat_state.get_keyboard(qh, &seat, None) {
528 + self.keyboard = Some(keyboard);
529 + }
530 + }
531 + if capability == Capability::Pointer && self.pointer.is_none() {
532 + if let Ok(pointer) = self.seat_state.get_pointer(qh, &seat) {
533 + self.pointer = Some(pointer);
534 + }
535 + }
536 + }
537 +
538 + fn remove_capability(
539 + &mut self,
540 + _conn: &Connection,
541 + _: &QueueHandle<Self>,
542 + _seat: wl_seat::WlSeat,
543 + capability: Capability,
544 + ) {
545 + if capability == Capability::Keyboard {
546 + if let Some(kbd) = self.keyboard.take() {
547 + kbd.release();
548 + }
549 + }
550 + if capability == Capability::Pointer {
551 + if let Some(ptr) = self.pointer.take() {
552 + ptr.release();
553 + }
554 + }
555 + }
556 +
557 + fn remove_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_seat::WlSeat) {}
558 +}
559 +
560 +impl KeyboardHandler for App {
561 + fn enter(
562 + &mut self,
563 + _: &Connection,
564 + _: &QueueHandle<Self>,
565 + _: &wl_keyboard::WlKeyboard,
566 + _surface: &wl_surface::WlSurface,
567 + _serial: u32,
568 + _raw: &[u32],
569 + _keysyms: &[Keysym],
570 + ) {
571 + }
572 +
573 + fn leave(
574 + &mut self,
575 + _: &Connection,
576 + _: &QueueHandle<Self>,
577 + _: &wl_keyboard::WlKeyboard,
578 + _surface: &wl_surface::WlSurface,
579 + _serial: u32,
580 + ) {
581 + }
582 +
583 + fn press_key(
584 + &mut self,
585 + _: &Connection,
586 + _: &QueueHandle<Self>,
587 + _: &wl_keyboard::WlKeyboard,
588 + _serial: u32,
589 + event: SctkKeyEvent,
590 + ) {
591 + if event.keysym == Keysym::Escape {
592 + self.exit = true;
593 + }
594 + self.queue(Event::Key(KeyEvent {
595 + keysym: u32::from(event.keysym),
596 + text: event.utf8.clone(),
597 + state: ButtonState::Pressed,
598 + modifiers: self.modifiers,
599 + repeat: false,
600 + }));
601 + }
602 +
603 + fn release_key(
604 + &mut self,
605 + _: &Connection,
606 + _: &QueueHandle<Self>,
607 + _: &wl_keyboard::WlKeyboard,
608 + _serial: u32,
609 + event: SctkKeyEvent,
610 + ) {
611 + self.queue(Event::Key(KeyEvent {
612 + keysym: u32::from(event.keysym),
613 + text: None,
614 + state: ButtonState::Released,
615 + modifiers: self.modifiers,
616 + repeat: false,
617 + }));
618 + }
619 +
620 + fn update_modifiers(
621 + &mut self,
622 + _: &Connection,
623 + _: &QueueHandle<Self>,
624 + _: &wl_keyboard::WlKeyboard,
625 + _serial: u32,
626 + modifiers: KbModifiers,
627 + _layout: u32,
628 + ) {
629 + self.modifiers = Modifiers {
630 + ctrl: modifiers.ctrl,
631 + alt: modifiers.alt,
632 + shift: modifiers.shift,
633 + logo: modifiers.logo,
634 + };
635 + }
636 +}
637 +
638 +impl PointerHandler for App {
639 + fn pointer_frame(
640 + &mut self,
641 + _: &Connection,
642 + _: &QueueHandle<Self>,
643 + _: &wl_pointer::WlPointer,
644 + events: &[SctkPointerEvent],
645 + ) {
646 + for event in events {
647 + if &event.surface != self.window.wl_surface() {
648 + continue;
649 + }
650 + let position = Point::new(event.position.0, event.position.1);
651 + match event.kind {
652 + PointerEventKind::Enter { .. } => {
653 + self.queue(Event::PointerMotion {
654 + position,
655 + modifiers: self.modifiers,
656 + });
657 + }
658 + PointerEventKind::Leave { .. } => {
659 + self.queue(Event::PointerLeave);
660 + }
661 + PointerEventKind::Motion { .. } => {
662 + self.queue(Event::PointerMotion {
663 + position,
664 + modifiers: self.modifiers,
665 + });
666 + }
667 + PointerEventKind::Press { button, .. } => {
668 + self.queue(Event::PointerButton(PointerEvent {
669 + position,
670 + button: map_button(button),
671 + state: ButtonState::Pressed,
672 + modifiers: self.modifiers,
673 + }));
674 + }
675 + PointerEventKind::Release { button, .. } => {
676 + self.queue(Event::PointerButton(PointerEvent {
677 + position,
678 + button: map_button(button),
679 + state: ButtonState::Released,
680 + modifiers: self.modifiers,
681 + }));
682 + }
683 + PointerEventKind::Axis {
684 + horizontal,
685 + vertical,
686 + ..
687 + } => {
688 + self.queue(Event::Scroll(ScrollEvent {
689 + position,
690 + dx: horizontal.absolute,
691 + dy: vertical.absolute,
692 + }));
693 + }
694 + }
695 + }
696 + }
697 +}
698 +
699 +impl ShmHandler for App {
700 + fn shm_state(&mut self) -> &mut Shm {
701 + &mut self.shm
702 + }
703 +}
704 +
705 +delegate_compositor!(App);
706 +delegate_output!(App);
707 +delegate_shm!(App);
708 +delegate_seat!(App);
709 +delegate_keyboard!(App);
710 +delegate_pointer!(App);
711 +delegate_xdg_shell!(App);
712 +delegate_xdg_window!(App);
713 +delegate_registry!(App);
714 +
715 +impl ProvidesRegistryState for App {
716 + fn registry(&mut self) -> &mut RegistryState {
717 + &mut self.registry_state
718 + }
719 + registry_handlers![OutputState, SeatState];
720 +}
0 721 new file mode 100644
@@ -0,0 +1,13 @@
1 +[package]
2 +name = "libluna-render"
3 +version.workspace = true
4 +edition.workspace = true
5 +license.workspace = true
6 +rust-version.workspace = true
7 +authors.workspace = true
8 +description = "Warstwa rysowania libLuny: graf sceny i backend software (tiny-skia + fontdue)."
9 +
10 +[dependencies]
11 +libluna-core.workspace = true
12 +tiny-skia.workspace = true
13 +fontdue = "0.9"
0 14 new file mode 100644
@@ -0,0 +1,22 @@
1 +//! # libluna-render
2 +//!
3 +//! Warstwa rysowania libLuny — odpowiednik Gsk w GTK.
4 +//!
5 +//! Składa się z dwóch niezależnych kawałków:
6 +//!
7 +//! - [`scene`] — **graf sceny** ([`Scene`]): lista prymitywów (prostokąty, zaokrąglone
8 +//! prostokąty, obramowania, clip, tekst). Platforma i widgety budują scenę, nie
9 +//! dotykając pikseli.
10 +//! - [`SoftwareRenderer`] — backend **CPU** (tiny-skia + fontdue), który rasteryzuje
11 +//! scenę do bufora RGBA8888 gotowego do wysłania przez `wl_shm`.
12 +//!
13 +//! Rozdzielenie sceny od backendu pozwala później dodać backend GPU bez zmian
14 +//! w widgetach.
15 +
16 +pub mod scene;
17 +pub mod software;
18 +pub mod text;
19 +
20 +pub use scene::{DrawCommand, Scene, TextRun};
21 +pub use software::SoftwareRenderer;
22 +pub use text::TextEngine;
0 23 new file mode 100644
@@ -0,0 +1,178 @@
1 +//! Graf sceny — opis tego, co ma zostać narysowane, w pikselach logicznych.
2 +//!
3 +//! Scena jest niezależna od backendu: budują ją widgety, a [`crate::SoftwareRenderer`]
4 +//! (albo przyszły backend GPU) ją rasteryzuje.
5 +
6 +use libluna_core::style::weight;
7 +use libluna_core::{Color, Point, Rect};
8 +
9 +/// Pojedynczy fragment tekstu do narysowania.
10 +#[derive(Debug, Clone, PartialEq)]
11 +pub struct TextRun {
12 + /// Lewy-górny róg obszaru tekstu; `origin.y` to linia bazowa pierwszego wiersza.
13 + pub origin: Point,
14 + pub text: String,
15 + /// Rozmiar czcionki w pikselach logicznych.
16 + pub size: f64,
17 + /// Grubość (patrz [`libluna_core::style::weight`]).
18 + pub weight: u16,
19 + pub color: Color,
20 +}
21 +
22 +impl TextRun {
23 + pub fn new(origin: Point, text: impl Into<String>, size: f64) -> Self {
24 + Self {
25 + origin,
26 + text: text.into(),
27 + size,
28 + weight: weight::NORMAL,
29 + color: Color::BLACK,
30 + }
31 + }
32 +
33 + pub fn with_color(mut self, color: Color) -> Self {
34 + self.color = color;
35 + self
36 + }
37 +
38 + pub fn with_weight(mut self, weight: u16) -> Self {
39 + self.weight = weight;
40 + self
41 + }
42 +}
43 +
44 +/// Polecenie rysowania.
45 +#[derive(Debug, Clone, PartialEq)]
46 +pub enum DrawCommand {
47 + /// Wypełniony prostokąt.
48 + FillRect { rect: Rect, color: Color },
49 + /// Wypełniony prostokąt z zaokrąglonymi narożnikami.
50 + FillRoundedRect {
51 + rect: Rect,
52 + radius: f64,
53 + color: Color,
54 + },
55 + /// Obramowanie zaokrąglonego prostokąta (linia o zadanej grubości).
56 + StrokeRoundedRect {
57 + rect: Rect,
58 + radius: f64,
59 + width: f64,
60 + color: Color,
61 + },
62 + /// Odcinek o zadanej grubości (z zaokrąglonymi końcami).
63 + StrokeLine {
64 + from: Point,
65 + to: Point,
66 + width: f64,
67 + color: Color,
68 + },
69 + /// Ogranicza kolejne polecenia do prostokąta (zastosowanie kumulacyjne).
70 + PushClip { rect: Rect },
71 + /// Zdejmuje ostatnie ograniczenie.
72 + PopClip,
73 + /// Tekst.
74 + Text(TextRun),
75 +}
76 +
77 +/// Uporządkowana lista poleceń rysowania.
78 +#[derive(Debug, Clone, Default)]
79 +pub struct Scene {
80 + commands: Vec<DrawCommand>,
81 +}
82 +
83 +impl Scene {
84 + pub fn new() -> Self {
85 + Self {
86 + commands: Vec::new(),
87 + }
88 + }
89 +
90 + /// Czyści scenę do ponownego użycia (bez zwalniania pamięci).
91 + pub fn clear(&mut self) {
92 + self.commands.clear();
93 + }
94 +
95 + pub fn len(&self) -> usize {
96 + self.commands.len()
97 + }
98 +
99 + pub fn is_empty(&self) -> bool {
100 + self.commands.is_empty()
101 + }
102 +
103 + pub fn commands(&self) -> &[DrawCommand] {
104 + &self.commands
105 + }
106 +
107 + pub fn fill_rect(&mut self, rect: Rect, color: Color) -> &mut Self {
108 + self.commands.push(DrawCommand::FillRect { rect, color });
109 + self
110 + }
111 +
112 + pub fn fill_rounded_rect(&mut self, rect: Rect, radius: f64, color: Color) -> &mut Self {
113 + self.commands.push(DrawCommand::FillRoundedRect {
114 + rect,
115 + radius,
116 + color,
117 + });
118 + self
119 + }
120 +
121 + pub fn stroke_rounded_rect(
122 + &mut self,
123 + rect: Rect,
124 + radius: f64,
125 + width: f64,
126 + color: Color,
127 + ) -> &mut Self {
128 + self.commands.push(DrawCommand::StrokeRoundedRect {
129 + rect,
130 + radius,
131 + width,
132 + color,
133 + });
134 + self
135 + }
136 +
137 + pub fn stroke_line(&mut self, from: Point, to: Point, width: f64, color: Color) -> &mut Self {
138 + self.commands.push(DrawCommand::StrokeLine {
139 + from,
140 + to,
141 + width,
142 + color,
143 + });
144 + self
145 + }
146 +
147 + pub fn push_clip(&mut self, rect: Rect) -> &mut Self {
148 + self.commands.push(DrawCommand::PushClip { rect });
149 + self
150 + }
151 +
152 + pub fn pop_clip(&mut self) -> &mut Self {
153 + self.commands.push(DrawCommand::PopClip);
154 + self
155 + }
156 +
157 + pub fn text(&mut self, run: TextRun) -> &mut Self {
158 + self.commands.push(DrawCommand::Text(run));
159 + self
160 + }
161 +}
162 +
163 +#[cfg(test)]
164 +mod tests {
165 + use super::*;
166 +
167 + #[test]
168 + fn builds_commands_in_order() {
169 + let mut s = Scene::new();
170 + s.fill_rect(Rect::from_xywh(0.0, 0.0, 10.0, 10.0), Color::BLACK)
171 + .push_clip(Rect::from_xywh(0.0, 0.0, 5.0, 5.0))
172 + .pop_clip();
173 + assert_eq!(s.len(), 3);
174 + assert!(matches!(s.commands()[0], DrawCommand::FillRect { .. }));
175 + assert!(matches!(s.commands()[1], DrawCommand::PushClip { .. }));
176 + assert!(matches!(s.commands()[2], DrawCommand::PopClip));
177 + }
178 +}
0 179 new file mode 100644
@@ -0,0 +1,328 @@
1 +//! Backend rysowania na CPU: rasteryzuje [`Scene`] do bufora RGBA8888.
2 +//!
3 +//! Używa `tiny-skia` do kształtów (z antyaliasingiem, zaokrągleniami i clipem)
4 +//! oraz [`TextEngine`] do tekstu. Bufor wyjściowy ma format zgodny z `wl_shm`
5 +//! (ARGB8888 little-endian to to samo ułożenie bajtów co RGBA8888 — do potwierdzenia
6 +//! przy podłączaniu Waylanda).
7 +
8 +use libluna_core::{Color, Point, Rect, Scale, Size};
9 +use tiny_skia::{
10 + FillRule, LineCap, LineJoin, Mask, Paint, Path, PathBuilder, Pixmap, Shader, Stroke, Transform,
11 +};
12 +
13 +use crate::scene::{DrawCommand, Scene};
14 +use crate::text::TextEngine;
15 +
16 +/// Renderer software'owy: trzyma własny bufor pikseli.
17 +pub struct SoftwareRenderer {
18 + pixmap: Pixmap,
19 + text: TextEngine,
20 +}
21 +
22 +impl SoftwareRenderer {
23 + /// Tworzy renderer o zadanym rozmiarze **fizycznym** (piksele).
24 + pub fn new(width: u32, height: u32) -> Self {
25 + Self {
26 + pixmap: Pixmap::new(width.max(1), height.max(1)).expect("alokacja bufora"),
27 + text: TextEngine::system(),
28 + }
29 + }
30 +
31 + /// Zmienia rozmiar bufora (zawartość jest zerowana).
32 + pub fn resize(&mut self, width: u32, height: u32) {
33 + if self.pixmap.width() != width.max(1) || self.pixmap.height() != height.max(1) {
34 + self.pixmap = Pixmap::new(width.max(1), height.max(1)).expect("realokacja bufora");
35 + }
36 + }
37 +
38 + pub fn width(&self) -> u32 {
39 + self.pixmap.width()
40 + }
41 +
42 + pub fn height(&self) -> u32 {
43 + self.pixmap.height()
44 + }
45 +
46 + /// Surowy bufor RGBA8888 (premnożony) — do skopiowania w `wl_shm`.
47 + pub fn data(&self) -> &[u8] {
48 + self.pixmap.data()
49 + }
50 +
51 + /// Koduje bufor do PNG — przydatne do podglądu offline (bez kompozytora).
52 + pub fn encode_png(&self) -> Option<Vec<u8>> {
53 + self.pixmap.encode_png().ok()
54 + }
55 +
56 + pub fn text_engine(&self) -> &TextEngine {
57 + &self.text
58 + }
59 +
60 + /// Mierzy tekst w pikselach logicznych.
61 + pub fn measure_text(&self, text: &str, size: f64, weight: u16) -> Size {
62 + self.text.measure(text, size, weight)
63 + }
64 +
65 + /// Rasteryzuje scenę. `scale` przelicza piksele logiczne sceny na fizyczne.
66 + pub fn draw(&mut self, scene: &Scene, scale: f64, background: Color) {
67 + let SoftwareRenderer { pixmap, text } = self;
68 + let scale = Scale::new(scale);
69 + let transform = Transform::from_scale(scale.factor() as f32, scale.factor() as f32);
70 +
71 + pixmap.fill(ts_color(background));
72 +
73 + let mut clip_stack: Vec<Rect> = Vec::new();
74 + let mut mask: Option<Mask> = None;
75 +
76 + for cmd in scene.commands() {
77 + match cmd {
78 + DrawCommand::FillRect { rect, color } => {
79 + if let Some(path) = rect_path(*rect, 0.0) {
80 + fill(pixmap, &path, *color, transform, mask.as_ref());
81 + }
82 + }
83 + DrawCommand::FillRoundedRect {
84 + rect,
85 + radius,
86 + color,
87 + } => {
88 + if let Some(path) = rect_path(*rect, *radius) {
89 + fill(pixmap, &path, *color, transform, mask.as_ref());
90 + }
91 + }
92 + DrawCommand::StrokeRoundedRect {
93 + rect,
94 + radius,
95 + width,
96 + color,
97 + } => {
98 + if let Some(path) = rect_path(*rect, *radius) {
99 + stroke(pixmap, &path, *width, *color, transform, mask.as_ref());
100 + }
101 + }
102 + DrawCommand::StrokeLine {
103 + from,
104 + to,
105 + width,
106 + color,
107 + } => {
108 + if color.a > 0.0 && *width > 0.0 {
109 + if let Some(path) = line_path(*from, *to) {
110 + let stroke = Stroke {
111 + width: *width as f32,
112 + line_cap: LineCap::Round,
113 + line_join: LineJoin::Round,
114 + ..Default::default()
115 + };
116 + pixmap.stroke_path(
117 + &path,
118 + &paint(*color),
119 + &stroke,
120 + transform,
121 + mask.as_ref(),
122 + );
123 + }
124 + }
125 + }
126 + DrawCommand::PushClip { rect } => {
127 + let effective = match clip_stack.last() {
128 + Some(current) => current.intersection(rect).unwrap_or(Rect::ZERO),
129 + None => *rect,
130 + };
131 + clip_stack.push(effective);
132 + mask = build_mask(pixmap, effective, transform);
133 + }
134 + DrawCommand::PopClip => {
135 + clip_stack.pop();
136 + mask = clip_stack
137 + .last()
138 + .copied()
139 + .and_then(|r| build_mask(pixmap, r, transform));
140 + }
141 + DrawCommand::Text(run) => {
142 + let (w, h) = (pixmap.width() as usize, pixmap.height() as usize);
143 + text.draw(
144 + pixmap.data_mut(),
145 + w,
146 + h,
147 + scale.to_physical(run.origin.x),
148 + scale.to_physical(run.origin.y),
149 + &run.text,
150 + run.size,
151 + run.weight,
152 + run.color,
153 + scale.factor(),
154 + );
155 + }
156 + }
157 + }
158 + }
159 +}
160 +
161 +fn ts_color(color: Color) -> tiny_skia::Color {
162 + let [r, g, b, a] = color.to_rgba8();
163 + tiny_skia::Color::from_rgba8(r, g, b, a)
164 +}
165 +
166 +fn line_path(from: Point, to: Point) -> Option<Path> {
167 + let mut pb = PathBuilder::new();
168 + pb.move_to(from.x as f32, from.y as f32);
169 + pb.line_to(to.x as f32, to.y as f32);
170 + pb.finish()
171 +}
172 +
173 +fn rect_path(rect: Rect, radius: f64) -> Option<Path> {
174 + if rect.width() <= 0.0 || rect.height() <= 0.0 {
175 + return None;
176 + }
177 + let x = rect.x() as f32;
178 + let y = rect.y() as f32;
179 + let w = rect.width() as f32;
180 + let h = rect.height() as f32;
181 + let mut pb = PathBuilder::new();
182 + let r = radius.min(rect.width() / 2.0).min(rect.height() / 2.0) as f32;
183 + if r > 0.0 {
184 + pb.move_to(x + r, y);
185 + pb.line_to(x + w - r, y);
186 + pb.quad_to(x + w, y, x + w, y + r);
187 + pb.line_to(x + w, y + h - r);
188 + pb.quad_to(x + w, y + h, x + w - r, y + h);
189 + pb.line_to(x + r, y + h);
190 + pb.quad_to(x, y + h, x, y + h - r);
191 + pb.line_to(x, y + r);
192 + pb.quad_to(x, y, x + r, y);
193 + pb.close();
194 + } else {
195 + pb.push_rect(tiny_skia::Rect::from_xywh(x, y, w, h)?);
196 + }
197 + pb.finish()
198 +}
199 +
200 +fn paint(color: Color) -> Paint<'static> {
201 + Paint {
202 + shader: Shader::SolidColor(ts_color(color)),
203 + anti_alias: true,
204 + ..Default::default()
205 + }
206 +}
207 +
208 +fn fill(pixmap: &mut Pixmap, path: &Path, color: Color, transform: Transform, mask: Option<&Mask>) {
209 + if color.a <= 0.0 {
210 + return;
211 + }
212 + pixmap.fill_path(path, &paint(color), FillRule::Winding, transform, mask);
213 +}
214 +
215 +fn stroke(
216 + pixmap: &mut Pixmap,
217 + path: &Path,
218 + width: f64,
219 + color: Color,
220 + transform: Transform,
221 + mask: Option<&Mask>,
222 +) {
223 + if color.a <= 0.0 || width <= 0.0 {
224 + return;
225 + }
226 + let stroke = Stroke {
227 + width: width as f32,
228 + ..Default::default()
229 + };
230 + pixmap.stroke_path(path, &paint(color), &stroke, transform, mask);
231 +}
232 +
233 +fn build_mask(pixmap: &Pixmap, rect: Rect, transform: Transform) -> Option<Mask> {
234 + let mut mask = Mask::new(pixmap.width(), pixmap.height())?;
235 + if let Some(path) = rect_path(rect, 0.0) {
236 + mask.fill_path(&path, FillRule::Winding, true, transform);
237 + }
238 + Some(mask)
239 +}
240 +
241 +#[cfg(test)]
242 +mod tests {
243 + use super::*;
244 + use libluna_core::{style::weight, Point};
245 +
246 + fn pixel(renderer: &SoftwareRenderer, x: u32, y: u32) -> [u8; 4] {
247 + let w = renderer.width() as usize;
248 + let idx = (y as usize * w + x as usize) * 4;
249 + let d = renderer.data();
250 + [d[idx], d[idx + 1], d[idx + 2], d[idx + 3]]
251 + }
252 +
253 + #[test]
254 + fn fills_background_opaque() {
255 + let mut r = SoftwareRenderer::new(8, 8);
256 + let scene = Scene::new();
257 + r.draw(&scene, 1.0, Color::WHITE);
258 + assert_eq!(pixel(&r, 0, 0), [255, 255, 255, 255]);
259 + assert_eq!(pixel(&r, 7, 7), [255, 255, 255, 255]);
260 + }
261 +
262 + #[test]
263 + fn draws_red_rect_center_but_not_corner() {
264 + let mut r = SoftwareRenderer::new(60, 60);
265 + let mut scene = Scene::new();
266 + scene.fill_rounded_rect(
267 + Rect::from_xywh(10.0, 10.0, 40.0, 40.0),
268 + 10.0,
269 + Color::from_rgba8(255, 0, 0, 255),
270 + );
271 + r.draw(&scene, 1.0, Color::WHITE);
272 +
273 + // Środek prostokąta jest czerwony.
274 + let c = pixel(&r, 30, 30);
275 + assert!(c[0] > 200 && c[1] < 40 && c[2] < 40, "środek: {c:?}");
276 + // Narożnik kwadratu (10,10) wypada poza zaokrąglenie → tło.
277 + let corner = pixel(&r, 10, 10);
278 + assert!(
279 + corner[0] > 200 && corner[1] > 200 && corner[2] > 200,
280 + "róg: {corner:?}"
281 + );
282 + }
283 +
284 + #[test]
285 + fn clip_limits_drawing() {
286 + let mut r = SoftwareRenderer::new(40, 40);
287 + let mut scene = Scene::new();
288 + scene
289 + .push_clip(Rect::from_xywh(0.0, 0.0, 10.0, 40.0))
290 + .fill_rect(
291 + Rect::from_xywh(0.0, 0.0, 40.0, 40.0),
292 + Color::from_rgba8(0, 0, 255, 255),
293 + )
294 + .pop_clip();
295 + r.draw(&scene, 1.0, Color::WHITE);
296 +
297 + let inside = pixel(&r, 5, 20);
298 + assert!(inside[2] > 200 && inside[0] < 40, "w clipie: {inside:?}");
299 + let outside = pixel(&r, 30, 20);
300 + assert!(
301 + outside[0] > 200 && outside[2] > 200,
302 + "poza clipem: {outside:?}"
303 + );
304 + }
305 +
306 + #[test]
307 + fn scale_2x_halves_effective_area() {
308 + let mut r = SoftwareRenderer::new(20, 20);
309 + let mut scene = Scene::new();
310 + // Prostokąt 5x5 w px logicznych przy scale 2 zajmuje 10x10 px fizycznych.
311 + scene.fill_rect(
312 + Rect::from_xywh(0.0, 0.0, 5.0, 5.0),
313 + Color::from_rgba8(0, 0, 0, 255),
314 + );
315 + r.draw(&scene, 2.0, Color::WHITE);
316 + assert_eq!(pixel(&r, 9, 9)[0], 0);
317 + assert_eq!(pixel(&r, 10, 10)[0], 255);
318 + }
319 +
320 + #[test]
321 + fn text_measurement_never_panics() {
322 + let r = SoftwareRenderer::new(16, 16);
323 + let s = r.measure_text("Ustawienia", 14.0, weight::NORMAL);
324 + assert!(s.width > 0.0);
325 + // Punkt nieużywany wprost — upewnia się, że typ jest w zasięgu.
326 + let _ = Point::ZERO;
327 + }
328 +}
0 329 new file mode 100644
@@ -0,0 +1,222 @@
1 +//! Minimalny silnik tekstu oparty na `fontdue` (bez harfbuzz/pango).
2 +//!
3 +//! Obsługuje pojedyncze pismo bez kerningu i bez kształtowania złożonego — to
4 +//! świadome uproszczenie M0. Docelowo miejsce to zajmie `cosmic-text` (shaping,
5 +//! fallback fontów, pisma złożone), ale kontrakt (`measure`/`draw`) zostaje ten sam.
6 +//!
7 +//! Ładuje font systemowy (najpierw Adwaita Sans, potem DejaVu), a jeśli żaden nie
8 +//! jest dostępny, działa w trybie „bez fontu” (przybliżone metryki, brak rysowania).
9 +
10 +use libluna_core::style::weight;
11 +use libluna_core::{Color, Size};
12 +
13 +use fontdue::{Font, FontSettings};
14 +
15 +/// Ścieżki fontu podstawowego, w kolejności preferencji (Adwaita Sans → DejaVu).
16 +const REGULAR_CANDIDATES: &[&str] = &[
17 + "/usr/share/fonts/Adwaita/AdwaitaSans-Regular.ttf",
18 + "/usr/share/fonts/adwaita/AdwaitaSans-Regular.ttf",
19 + "/usr/share/fonts/dejavu/DejaVuSans.ttf",
20 + "/usr/share/fonts/TTF/DejaVuSans.ttf",
21 +];
22 +
23 +/// Ścieżki fontu pogrubionego.
24 +const BOLD_CANDIDATES: &[&str] = &[
25 + "/usr/share/fonts/Adwaita/AdwaitaSans-Bold.ttf",
26 + "/usr/share/fonts/adwaita/AdwaitaSans-Bold.ttf",
27 + "/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf",
28 + "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
29 +];
30 +
31 +/// Silnik tekstu: trzyma fonty i potrafi mierzyć oraz rysować napisy.
32 +pub struct TextEngine {
33 + regular: Option<Font>,
34 + bold: Option<Font>,
35 +}
36 +
37 +impl TextEngine {
38 + /// Tworzy silnik z fontów systemowych (może działać bez nich).
39 + pub fn system() -> Self {
40 + let regular = load_first(REGULAR_CANDIDATES).or_else(|| load_from_env("LUNA_FONT"));
41 + let bold = load_first(BOLD_CANDIDATES).or_else(|| load_from_env("LUNA_FONT_BOLD"));
42 + Self { regular, bold }
43 + }
44 +
45 + /// Czy jakikolwiek font został wczytany.
46 + pub fn has_font(&self) -> bool {
47 + self.regular.is_some()
48 + }
49 +
50 + fn font_for(&self, weight: u16) -> Option<&Font> {
51 + if weight >= weight::BOLD {
52 + self.bold.as_ref().or(self.regular.as_ref())
53 + } else {
54 + self.regular.as_ref()
55 + }
56 + }
57 +
58 + /// Szerokość i wysokość tekstu w pikselach logicznych (bez kerningu).
59 + pub fn measure(&self, text: &str, size: f64, weight: u16) -> Size {
60 + let Some(font) = self.font_for(weight) else {
61 + // Tryb bez fontu: przybliżenie, żeby układ miał sens.
62 + return Size::new(text.chars().count() as f64 * size * 0.5, size * 1.2);
63 + };
64 +
65 + let px = size as f32;
66 + let mut advance = 0.0f32;
67 + let mut max_ascent = 0.0f32;
68 + let mut max_descent = 0.0f32;
69 + for ch in text.chars() {
70 + let m = font.metrics(ch, px);
71 + advance += m.advance_width;
72 + max_ascent = max_ascent.max(m.height as f32 + m.ymin as f32);
73 + max_descent = max_descent.max(-(m.ymin as f32));
74 + }
75 + Size::new(advance as f64, (max_ascent + max_descent) as f64)
76 + }
77 +
78 + /// Rysuje tekst do bufora RGBA8888 **premnożonego** (format tiny-skia).
79 + ///
80 + /// `origin` w pikselach fizycznych, `size` skalowana przez `scale`.
81 + #[allow(clippy::too_many_arguments)]
82 + pub fn draw(
83 + &self,
84 + buf: &mut [u8],
85 + buf_w: usize,
86 + buf_h: usize,
87 + origin_x: f64,
88 + origin_y: f64,
89 + text: &str,
90 + size: f64,
91 + weight: u16,
92 + color: Color,
93 + scale: f64,
94 + ) {
95 + let Some(font) = self.font_for(weight) else {
96 + return;
97 + };
98 +
99 + let px = (size * scale) as f32;
100 + let (r, g, b) = (color.r * color.a, color.g * color.a, color.b * color.a);
101 + let alpha = color.a;
102 +
103 + let mut pen_x = origin_x;
104 + let baseline_y = origin_y;
105 + for ch in text.chars() {
106 + let (metrics, coverage) = font.rasterize(ch, px);
107 + if metrics.width > 0 && metrics.height > 0 {
108 + let gx = (pen_x + metrics.xmin as f64).round() as i64;
109 + let gy =
110 + (baseline_y - (metrics.height as f64 + metrics.ymin as f64)).round() as i64;
111 + blend_glyph(
112 + buf,
113 + buf_w,
114 + buf_h,
115 + gx,
116 + gy,
117 + metrics.width,
118 + metrics.height,
119 + &coverage,
120 + r,
121 + g,
122 + b,
123 + alpha,
124 + );
125 + }
126 + pen_x += metrics.advance_width as f64;
127 + }
128 + }
129 +}
130 +
131 +fn load_first(paths: &[&str]) -> Option<Font> {
132 + paths.iter().find_map(|p| load_font(p))
133 +}
134 +
135 +fn load_from_env(var: &str) -> Option<Font> {
136 + std::env::var(var).ok().and_then(|p| load_font(&p))
137 +}
138 +
139 +fn load_font(path: &str) -> Option<Font> {
140 + let bytes = std::fs::read(path).ok()?;
141 + Font::from_bytes(bytes, FontSettings::default()).ok()
142 +}
143 +
144 +/// Nakłada bitmapę krycia glifu na bufor premnożony (operacja „over”).
145 +#[allow(clippy::too_many_arguments)]
146 +fn blend_glyph(
147 + buf: &mut [u8],
148 + buf_w: usize,
149 + buf_h: usize,
150 + gx: i64,
151 + gy: i64,
152 + gw: usize,
153 + gh: usize,
154 + coverage: &[u8],
155 + sr: f32,
156 + sg: f32,
157 + sb: f32,
158 + sa: f32,
159 +) {
160 + for row in 0..gh {
161 + let y = gy + row as i64;
162 + if y < 0 || y >= buf_h as i64 {
163 + continue;
164 + }
165 + for col in 0..gw {
166 + let x = gx + col as i64;
167 + if x < 0 || x >= buf_w as i64 {
168 + continue;
169 + }
170 + let cov = coverage[row * gw + col] as f32 / 255.0;
171 + if cov <= 0.0 {
172 + continue;
173 + }
174 + // Źródło premnożone, przemnożone jeszcze przez krycie glifu.
175 + let a_s = sa * cov;
176 + let r_s = sr * cov;
177 + let g_s = sg * cov;
178 + let b_s = sb * cov;
179 +
180 + let idx = (y as usize * buf_w + x as usize) * 4;
181 + let inv = 1.0 - a_s;
182 + let a_d = buf[idx + 3] as f32 / 255.0;
183 + let r_d = buf[idx] as f32 / 255.0;
184 + let g_d = buf[idx + 1] as f32 / 255.0;
185 + let b_d = buf[idx + 2] as f32 / 255.0;
186 +
187 + let out_a = a_s + a_d * inv;
188 + let out_r = r_s + r_d * inv;
189 + let out_g = g_s + g_d * inv;
190 + let out_b = b_s + b_d * inv;
191 +
192 + buf[idx] = (out_r * 255.0).round().clamp(0.0, 255.0) as u8;
193 + buf[idx + 1] = (out_g * 255.0).round().clamp(0.0, 255.0) as u8;
194 + buf[idx + 2] = (out_b * 255.0).round().clamp(0.0, 255.0) as u8;
195 + buf[idx + 3] = (out_a * 255.0).round().clamp(0.0, 255.0) as u8;
196 + }
197 + }
198 +}
199 +
200 +#[cfg(test)]
201 +mod tests {
202 + use super::*;
203 +
204 + #[test]
205 + fn measure_without_font_is_positive() {
206 + let engine = TextEngine {
207 + regular: None,
208 + bold: None,
209 + };
210 + let s = engine.measure("Hello", 14.0, weight::NORMAL);
211 + assert!(s.width > 0.0);
212 + assert!(s.height > 0.0);
213 + }
214 +
215 + #[test]
216 + fn system_engine_loads_something_on_this_host() {
217 + // Nie jest to twardy wymóg — jeśli brak fontów, po prostu nie rysujemy.
218 + let engine = TextEngine::system();
219 + let s = engine.measure("AG", 14.0, weight::NORMAL);
220 + assert!(s.width > 0.0);
221 + }
222 +}
0 223 new file mode 100644
@@ -0,0 +1,12 @@
1 +[package]
2 +name = "libluna-widgets"
3 +version.workspace = true
4 +edition.workspace = true
5 +license.workspace = true
6 +rust-version.workspace = true
7 +authors.workspace = true
8 +description = "Widgety libLuny (tryb immediate): ViewSwitcher, NavList i wspólny kontekst rysowania."
9 +
10 +[dependencies]
11 +libluna-core.workspace = true
12 +libluna-render.workspace = true
0 13 new file mode 100644
@@ -0,0 +1,281 @@
1 +//! `BottomSheet` — dolny panel (modalny), wysuwany z dołu okna.
2 +//!
3 +//! Widget immediate: trzyma stan otwarcia, opcjonalny tytuł i przesunięcie przeciągania.
4 +//! Aplikacja woła [`BottomSheet::paint`] oraz metody trafień ([`BottomSheet::close_at`],
5 +//! [`BottomSheet::scrim_at`]) i obsługę przeciągania
6 +//! ([`BottomSheet::begin_drag`] / [`BottomSheet::update_drag`] / [`BottomSheet::end_drag`]).
7 +//! Zawartość panelu rysuje aplikacja w [`BottomSheet::content_rect`].
8 +
9 +use libluna_core::style::weight;
10 +use libluna_core::{Color, Point, Rect, Size, Theme};
11 +
12 +use crate::ctx::PaintCtx;
13 +use crate::icon;
14 +
15 +/// Bok obszaru przycisku zamknięcia.
16 +const CLOSE: f64 = 24.0;
17 +/// Odstęp „grabbera” od górnej krawędzi.
18 +const HANDLE_TOP: f64 = 8.0;
19 +
20 +/// Dolny panel.
21 +#[derive(Debug, Clone, Default)]
22 +pub struct BottomSheet {
23 + title: Option<String>,
24 + open: bool,
25 + height: Option<f64>,
26 + /// Bieżące przesunięcie w dół podczas przeciągania (px).
27 + drag: f64,
28 + /// Pozycja wskaźnika (y) na początku przeciągania.
29 + drag_from: Option<f64>,
30 + hover_close: bool,
31 +}
32 +
33 +impl BottomSheet {
34 + pub fn new() -> Self {
35 + Self::default()
36 + }
37 +
38 + pub fn title(mut self, title: impl Into<String>) -> Self {
39 + self.title = Some(title.into());
40 + self
41 + }
42 +
43 + pub fn set_title(&mut self, title: Option<String>) {
44 + self.title = title;
45 + }
46 +
47 + /// Wysokość panelu (domyślnie z tokenów motywu).
48 + pub fn set_height(&mut self, height: f64) {
49 + self.height = Some(height);
50 + }
51 +
52 + pub fn set_open(&mut self, open: bool) {
53 + self.open = open;
54 + if !open {
55 + self.drag = 0.0;
56 + self.drag_from = None;
57 + }
58 + }
59 +
60 + pub fn is_open(&self) -> bool {
61 + self.open
62 + }
63 +
64 + pub fn set_hover_close(&mut self, hovered: bool) {
65 + self.hover_close = hovered;
66 + }
67 +
68 + pub fn height(&self, theme: &Theme) -> f64 {
69 + self.height.unwrap_or(theme.metrics.sheet.height)
70 + }
71 +
72 + /// Prostokąt panelu (przyklejony do dołu, z uwzględnieniem przeciągania).
73 + pub fn sheet_rect(&self, size: Size, theme: &Theme) -> Rect {
74 + let h = self.height(theme);
75 + Rect::from_xywh(0.0, size.height - h + self.drag, size.width, h)
76 + }
77 +
78 + /// Pełnoekranowe tło przygaszające.
79 + pub fn scrim_rect(&self, size: Size) -> Rect {
80 + Rect::from_xywh(0.0, 0.0, size.width, size.height)
81 + }
82 +
83 + /// Strefa chwytu do przeciągania (nagłówek z „grabberem”).
84 + pub fn drag_zone(&self, size: Size, theme: &Theme) -> Rect {
85 + let s = self.sheet_rect(size, theme);
86 + Rect::from_xywh(s.x(), s.y(), s.width(), theme.metrics.sheet.header)
87 + }
88 +
89 + /// Prostokąt „grabbera”.
90 + pub fn grabber_rect(&self, size: Size, theme: &Theme) -> Rect {
91 + let s = self.sheet_rect(size, theme);
92 + let m = theme.metrics.sheet;
93 + Rect::from_xywh(
94 + s.center().x - m.handle_width / 2.0,
95 + s.y() + HANDLE_TOP,
96 + m.handle_width,
97 + m.handle_height,
98 + )
99 + }
100 +
101 + /// Prostokąt przycisku zamknięcia.
102 + pub fn close_rect(&self, size: Size, theme: &Theme) -> Rect {
103 + let s = self.sheet_rect(size, theme);
104 + let md = theme.spacing.md;
105 + let cy = s.y() + theme.metrics.sheet.header / 2.0;
106 + Rect::from_xywh(s.right() - md - CLOSE, cy - CLOSE / 2.0, CLOSE, CLOSE)
107 + }
108 +
109 + /// Obszar zawartości (poniżej nagłówka).
110 + pub fn content_rect(&self, size: Size, theme: &Theme) -> Rect {
111 + let s = self.sheet_rect(size, theme);
112 + let header = theme.metrics.sheet.header;
113 + Rect::from_xywh(
114 + s.x(),
115 + s.y() + header,
116 + s.width(),
117 + (s.height() - header).max(0.0),
118 + )
119 + }
120 +
121 + /// Czy punkt trafia w przycisk zamknięcia.
122 + pub fn close_at(&self, size: Size, theme: &Theme, p: Point) -> bool {
123 + self.open && self.close_rect(size, theme).contains(p)
124 + }
125 +
126 + /// Czy punkt trafia w tło poza panelem (klik zamykający).
127 + pub fn scrim_at(&self, size: Size, theme: &Theme, p: Point) -> bool {
128 + self.open && !self.sheet_rect(size, theme).contains(p)
129 + }
130 +
131 + /// Czy punkt trafia w strefę przeciągania panelu.
132 + pub fn drag_zone_at(&self, size: Size, theme: &Theme, p: Point) -> bool {
133 + self.open && self.drag_zone(size, theme).contains(p)
134 + }
135 +
136 + /// Rozpoczyna przeciąganie od pozycji wskaźnika `y`.
137 + pub fn begin_drag(&mut self, y: f64) {
138 + self.drag_from = Some(y);
139 + }
140 +
141 + /// Aktualizuje przeciągnięcie (w dół).
142 + pub fn update_drag(&mut self, y: f64) {
143 + if let Some(from) = self.drag_from {
144 + self.drag = (y - from).max(0.0);
145 + }
146 + }
147 +
148 + /// Kończy przeciąganie; zwraca `true`, jeśli panel został zamknięty.
149 + pub fn end_drag(&mut self, theme: &Theme) -> bool {
150 + let dismissed = self.drag > theme.metrics.sheet.dismiss;
151 + if dismissed {
152 + self.open = false;
153 + }
154 + self.drag = 0.0;
155 + self.drag_from = None;
156 + dismissed
157 + }
158 +
159 + /// Rysuje przygaszenie i panel (na wierzchu sceny).
160 + pub fn paint(&self, ctx: &mut PaintCtx, size: Size) {
161 + if !self.open {
162 + return;
163 + }
164 + let theme = *ctx.theme;
165 + let m = theme.metrics.sheet;
166 + let colors = theme.colors;
167 +
168 + ctx.scene.fill_rect(
169 + self.scrim_rect(size),
170 + Color::rgba(0.0, 0.0, 0.0, m.scrim as f32),
171 + );
172 +
173 + let sheet = self.sheet_rect(size, &theme);
174 + // Panel: zaokrąglone górne narożniki, dolne „przycięte” do krawędzi.
175 + ctx.scene.fill_rounded_rect(sheet, m.radius, colors.card_bg);
176 + ctx.scene.fill_rect(
177 + Rect::from_xywh(
178 + sheet.x(),
179 + sheet.y() + m.radius,
180 + sheet.width(),
181 + (sheet.height() - m.radius).max(0.0),
182 + ),
183 + colors.card_bg,
184 + );
185 + ctx.scene.fill_rect(
186 + Rect::from_xywh(sheet.x(), sheet.y(), sheet.width(), 1.0),
187 + colors.border,
188 + );
189 +
190 + let g = self.grabber_rect(size, &theme);
191 + ctx.scene
192 + .fill_rounded_rect(g, g.height() / 2.0, colors.dim_text.scale_alpha(0.5));
193 +
194 + if let Some(title) = &self.title {
195 + ctx.label_center(
196 + sheet.center().x,
197 + sheet.y() + m.header / 2.0,
198 + title,
199 + theme.typography.body.size,
200 + weight::BOLD,
201 + colors.text,
202 + );
203 + }
204 +
205 + let c = self.close_rect(size, &theme);
206 + if self.hover_close {
207 + ctx.scene
208 + .fill_rounded_rect(c, theme.radius.small, colors.shade);
209 + }
210 + icon::close(ctx, colors.dim_text, c, 5.0);
211 + }
212 +}
213 +
214 +#[cfg(test)]
215 +mod tests {
216 + use super::*;
217 +
218 + fn size() -> Size {
219 + Size::new(900.0, 820.0)
220 + }
221 +
222 + #[test]
223 + fn sheet_is_bottom_anchored() {
224 + let theme = Theme::light();
225 + let mut s = BottomSheet::new();
226 + s.set_open(true);
227 + let r = s.sheet_rect(size(), &theme);
228 + assert!((r.bottom() - 820.0).abs() < 1e-9);
229 + assert!((r.height() - theme.metrics.sheet.height).abs() < 1e-9);
230 + }
231 +
232 + #[test]
233 + fn scrim_hit_is_above_sheet_only() {
234 + let theme = Theme::light();
235 + let mut s = BottomSheet::new();
236 + s.set_open(true);
237 + let sheet = s.sheet_rect(size(), &theme);
238 + assert!(s.scrim_at(size(), &theme, Point::new(450.0, sheet.y() - 20.0)));
239 + assert!(!s.scrim_at(size(), &theme, Point::new(450.0, sheet.y() + 20.0)));
240 + }
241 +
242 + #[test]
243 + fn close_hit_and_content_below_header() {
244 + let theme = Theme::light();
245 + let mut s = BottomSheet::new().title("O aplikacji");
246 + s.set_open(true);
247 + assert!(s.close_at(size(), &theme, s.close_rect(size(), &theme).center()));
248 + let c = s.content_rect(size(), &theme);
249 + assert!(c.y() >= s.sheet_rect(size(), &theme).y() + theme.metrics.sheet.header);
250 + }
251 +
252 + #[test]
253 + fn closed_sheet_ignores_hits() {
254 + let theme = Theme::light();
255 + let s = BottomSheet::new();
256 + assert!(!s.scrim_at(size(), &theme, Point::new(10.0, 10.0)));
257 + assert!(!s.close_at(size(), &theme, Point::new(880.0, 520.0)));
258 + }
259 +
260 + #[test]
261 + fn drag_past_threshold_dismisses() {
262 + let theme = Theme::light();
263 + let mut s = BottomSheet::new();
264 + s.set_open(true);
265 + s.begin_drag(500.0);
266 + s.update_drag(500.0 + theme.metrics.sheet.dismiss + 5.0);
267 + assert!(s.end_drag(&theme));
268 + assert!(!s.is_open());
269 + }
270 +
271 + #[test]
272 + fn small_drag_keeps_open() {
273 + let theme = Theme::light();
274 + let mut s = BottomSheet::new();
275 + s.set_open(true);
276 + s.begin_drag(500.0);
277 + s.update_drag(500.0 + 10.0);
278 + assert!(!s.end_drag(&theme));
279 + assert!(s.is_open());
280 + }
281 +}
0 282 new file mode 100644
@@ -0,0 +1,187 @@
1 +//! `Button` — przycisk z etykietą (warianty: domyślny, płaski, sugerowany, destrukcyjny).
2 +//!
3 +//! Widget immediate: trzyma wygląd (etykieta, wariant, stan), a aplikacja wywołuje
4 +//! [`Button::paint`] i [`Button::click_at`].
5 +
6 +use libluna_core::style::{weight, ColorScheme};
7 +use libluna_core::{Color, Point, Rect, Theme};
8 +use libluna_render::TextEngine;
9 +
10 +use crate::ctx::PaintCtx;
11 +use crate::icon;
12 +
13 +/// Wariant wizualny przycisku.
14 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15 +pub enum ButtonVariant {
16 + /// Tło „shade” (jak przyciski w HeaderBarze).
17 + #[default]
18 + Default,
19 + /// Bez tła; tło pojawia się przy najechaniu/wciśnięciu.
20 + Flat,
21 + /// Sugerowana akcja (akcent).
22 + Suggested,
23 + /// Akcja destrukcyjna (kolor błędu).
24 + Destructive,
25 +}
26 +
27 +/// Przycisk z etykietą.
28 +#[derive(Debug, Clone)]
29 +pub struct Button {
30 + label: String,
31 + variant: ButtonVariant,
32 + dropdown: bool,
33 + enabled: bool,
34 + hovered: bool,
35 + pressed: bool,
36 +}
37 +
38 +impl Button {
39 + pub fn new(label: impl Into<String>) -> Self {
40 + Self {
41 + label: label.into(),
42 + variant: ButtonVariant::Default,
43 + dropdown: false,
44 + enabled: true,
45 + hovered: false,
46 + pressed: false,
47 + }
48 + }
49 +
50 + pub fn variant(mut self, variant: ButtonVariant) -> Self {
51 + self.variant = variant;
52 + self
53 + }
54 +
55 + /// Dodaje na końcu wskaźnik rozwijania (chevron) — przyciski menu.
56 + pub fn dropdown(mut self, dropdown: bool) -> Self {
57 + self.dropdown = dropdown;
58 + self
59 + }
60 +
61 + pub fn enabled(mut self, enabled: bool) -> Self {
62 + self.enabled = enabled;
63 + self
64 + }
65 +
66 + pub fn set_hovered(&mut self, hovered: bool) {
67 + self.hovered = hovered;
68 + }
69 +
70 + pub fn set_pressed(&mut self, pressed: bool) {
71 + self.pressed = pressed;
72 + }
73 +
74 + pub fn label(&self) -> &str {
75 + &self.label
76 + }
77 +
78 + pub fn is_enabled(&self) -> bool {
79 + self.enabled
80 + }
81 +
82 + /// Szerokość dopasowana do etykiety.
83 + pub fn width(&self, text: &TextEngine, theme: &Theme) -> f64 {
84 + let m = theme.metrics.button;
85 + let body = theme.typography.body.size;
86 + let mut w = m.pad_x * 2.0 + text.measure(&self.label, body, weight::NORMAL).width;
87 + if self.dropdown {
88 + w += m.chevron_gap + m.chevron;
89 + }
90 + w
91 + }
92 +
93 + pub fn height(&self, theme: &Theme) -> f64 {
94 + theme.metrics.button.height
95 + }
96 +
97 + pub fn hit(&self, rect: Rect, p: Point) -> bool {
98 + rect.contains(p)
99 + }
100 +
101 + /// Zwraca `true`, jeśli klik trafił w aktywny przycisk.
102 + pub fn click_at(&self, rect: Rect, p: Point) -> bool {
103 + self.enabled && rect.contains(p)
104 + }
105 +
106 + pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
107 + let theme = *ctx.theme;
108 + let colors = theme.colors;
109 + let radius = theme.radius.small;
110 + let strong = self.pressed || self.hovered;
111 +
112 + match self.variant {
113 + ButtonVariant::Suggested => {
114 + ctx.scene.fill_rounded_rect(rect, radius, colors.accent);
115 + }
116 + ButtonVariant::Destructive => {
117 + ctx.scene.fill_rounded_rect(rect, radius, colors.error);
118 + }
119 + ButtonVariant::Default => {
120 + ctx.scene
121 + .fill_rounded_rect(rect, radius, shade(&colors, strong));
122 + }
123 + ButtonVariant::Flat => {
124 + if strong {
125 + ctx.scene
126 + .fill_rounded_rect(rect, radius, shade(&colors, false));
127 + }
128 + }
129 + }
130 +
131 + let fg = match self.variant {
132 + ButtonVariant::Suggested | ButtonVariant::Destructive => colors.accent_fg,
133 + _ if self.enabled => colors.text,
134 + _ => colors.dim_text,
135 + };
136 +
137 + let body = theme.typography.body.size;
138 + let m = theme.metrics.button;
139 + let cy = rect.center().y;
140 + let label_w = ctx.measure(&self.label, body, weight::NORMAL);
141 + let content_w = label_w
142 + + if self.dropdown {
143 + m.chevron_gap + m.chevron
144 + } else {
145 + 0.0
146 + };
147 + let start_x = rect.center().x - content_w / 2.0;
148 + ctx.label_left(start_x, cy, &self.label, body, weight::NORMAL, fg);
149 +
150 + if self.dropdown {
151 + let cx = start_x + label_w + m.chevron_gap + m.chevron / 2.0;
152 + icon::chevron_down(ctx, fg, Point::new(cx, cy), 4.0, 2.0);
153 + }
154 + }
155 +}
156 +
157 +fn shade(colors: &ColorScheme, strong: bool) -> Color {
158 + if strong {
159 + colors.shade.scale_alpha(1.8)
160 + } else {
161 + colors.shade
162 + }
163 +}
164 +
165 +#[cfg(test)]
166 +mod tests {
167 + use super::*;
168 +
169 + #[test]
170 + fn click_only_when_enabled_and_inside() {
171 + let rect = Rect::from_xywh(0.0, 0.0, 100.0, 34.0);
172 + let b = Button::new("OK");
173 + assert!(b.click_at(rect, Point::new(50.0, 17.0)));
174 + assert!(!b.click_at(rect, Point::new(200.0, 17.0)));
175 + let disabled = Button::new("OK").enabled(false);
176 + assert!(!disabled.click_at(rect, Point::new(50.0, 17.0)));
177 + }
178 +
179 + #[test]
180 + fn width_grows_with_dropdown() {
181 + let theme = Theme::light();
182 + let text = TextEngine::system();
183 + let plain = Button::new("Opcje");
184 + let dd = Button::new("Opcje").dropdown(true);
185 + assert!(dd.width(&text, &theme) > plain.width(&text, &theme));
186 + }
187 +}
0 188 new file mode 100644
@@ -0,0 +1,106 @@
1 +//! Wspólny kontekst rysowania widgetów: scena + motyw + silnik tekstu.
2 +//!
3 +//! Widgety nie tworzą własnej sceny — rysują do przekazanej [`Scene`]. `PaintCtx`
4 +//! daje im do tego wygodne skróty (pomiar i wyprowadzanie tekstu).
5 +
6 +use libluna_core::style::{weight, FontStyle};
7 +use libluna_core::{Color, Point, Theme};
8 +use libluna_render::{Scene, TextEngine, TextRun};
9 +
10 +/// Kontekst przekazywany do `paint` widgetów.
11 +pub struct PaintCtx<'a> {
12 + pub scene: &'a mut Scene,
13 + pub theme: &'a Theme,
14 + pub text: &'a TextEngine,
15 +}
16 +
17 +impl<'a> PaintCtx<'a> {
18 + pub fn new(scene: &'a mut Scene, theme: &'a Theme, text: &'a TextEngine) -> Self {
19 + Self { scene, theme, text }
20 + }
21 +
22 + /// Szerokość napisu w pikselach logicznych.
23 + pub fn measure(&self, s: &str, size: f64, weight: u16) -> f64 {
24 + self.text.measure(s, size, weight).width
25 + }
26 +
27 + /// Szerokość napisu dla stylu z motywu.
28 + pub fn measure_style(&self, s: &str, style: FontStyle) -> f64 {
29 + self.measure(s, style.size, style.weight)
30 + }
31 +
32 + /// Rysuje napis; `baseline_y` to linia bazowa.
33 + pub fn label(
34 + &mut self,
35 + x: f64,
36 + baseline_y: f64,
37 + s: &str,
38 + size: f64,
39 + weight: u16,
40 + color: Color,
41 + ) {
42 + self.scene.text(
43 + TextRun::new(Point::new(x, baseline_y), s, size)
44 + .with_weight(weight)
45 + .with_color(color),
46 + );
47 + }
48 +
49 + /// Napis wyrównany do lewej, wyśrodkowany pionowo w `cy`.
50 + pub fn label_left(&mut self, x: f64, cy: f64, s: &str, size: f64, weight: u16, color: Color) {
51 + self.label(x, cy + size * 0.35, s, size, weight, color);
52 + }
53 +
54 + /// Napis wyśrodkowany poziomo w `cx` i pionowo w `cy`.
55 + pub fn label_center(
56 + &mut self,
57 + cx: f64,
58 + cy: f64,
59 + s: &str,
60 + size: f64,
61 + weight: u16,
62 + color: Color,
63 + ) {
64 + let w = self.measure(s, size, weight);
65 + self.label(cx - w / 2.0, cy + size * 0.35, s, size, weight, color);
66 + }
67 +
68 + /// Napis wyrównany do prawej (koniec w `right_x`), wyśrodkowany w `cy`.
69 + pub fn label_right(
70 + &mut self,
71 + right_x: f64,
72 + cy: f64,
73 + s: &str,
74 + size: f64,
75 + weight: u16,
76 + color: Color,
77 + ) {
78 + let w = self.measure(s, size, weight);
79 + self.label_left(right_x - w, cy, s, size, weight, color);
80 + }
81 +
82 + /// Domyślny kolor tekstu motywu.
83 + pub fn text_color(&self) -> Color {
84 + self.theme.colors.text
85 + }
86 +
87 + /// Kolor akcentu motywu.
88 + pub fn accent(&self) -> Color {
89 + self.theme.colors.accent
90 + }
91 +
92 + /// Kolor tekstu drugorzędnego.
93 + pub fn dim_text(&self) -> Color {
94 + self.theme.colors.dim_text
95 + }
96 +
97 + /// Domyślny styl tekstu treści.
98 + pub fn body(&self) -> FontStyle {
99 + self.theme.typography.body
100 + }
101 +
102 + /// Domyślna grubość „aktywna”.
103 + pub fn medium_weight(&self) -> u16 {
104 + weight::MEDIUM
105 + }
106 +}
0 107 new file mode 100644
@@ -0,0 +1,139 @@
1 +//! `DropDown` — kontrolka wyboru jednej opcji. Zamknięty stan to pudełko
2 +//! z etykietą i chevronem; rozwinięcie realizuje [`Menu`] (patrz [`DropDown::menu`]).
3 +
4 +use libluna_core::style::weight;
5 +use libluna_core::{Point, Rect, Theme};
6 +use libluna_render::TextEngine;
7 +
8 +use crate::ctx::PaintCtx;
9 +use crate::icon;
10 +use crate::menu::{Menu, MenuEntry};
11 +
12 +/// Lista rozwijana.
13 +#[derive(Debug, Clone)]
14 +pub struct DropDown {
15 + options: Vec<String>,
16 + selected: usize,
17 +}
18 +
19 +impl DropDown {
20 + pub fn new<I, S>(options: I) -> Self
21 + where
22 + I: IntoIterator<Item = S>,
23 + S: Into<String>,
24 + {
25 + Self {
26 + options: options.into_iter().map(Into::into).collect(),
27 + selected: 0,
28 + }
29 + }
30 +
31 + pub fn options(&self) -> &[String] {
32 + &self.options
33 + }
34 +
35 + pub fn selected(&self) -> usize {
36 + self.selected
37 + }
38 +
39 + pub fn selected_label(&self) -> Option<&str> {
40 + self.options.get(self.selected).map(String::as_str)
41 + }
42 +
43 + pub fn set_selected(&mut self, index: usize) {
44 + if !self.options.is_empty() {
45 + self.selected = index.min(self.options.len() - 1);
46 + }
47 + }
48 +
49 + /// Szerokość dopasowana do najdłuższej opcji.
50 + pub fn width(&self, text: &TextEngine, theme: &Theme) -> f64 {
51 + let m = theme.metrics.dropdown;
52 + let body = theme.typography.body.size;
53 + let mut w = m.min_width;
54 + for option in &self.options {
55 + w = w.max(
56 + text.measure(option, body, weight::NORMAL).width
57 + + 2.0 * m.pad_x
58 + + m.chevron_gap
59 + + m.chevron,
60 + );
61 + }
62 + w
63 + }
64 +
65 + pub fn height(&self, theme: &Theme) -> f64 {
66 + theme.metrics.dropdown.height
67 + }
68 +
69 + pub fn hit(&self, rect: Rect, p: Point) -> bool {
70 + rect.contains(p)
71 + }
72 +
73 + /// Zwraca `true`, jeśli klik trafił w kontrolkę (rozwinięcie robi aplikacja).
74 + pub fn click_at(&self, rect: Rect, p: Point) -> bool {
75 + rect.contains(p)
76 + }
77 +
78 + /// Pozycje do popoveru (menu rozwijane).
79 + pub fn menu(&self) -> Menu {
80 + Menu::new(
81 + self.options
82 + .iter()
83 + .cloned()
84 + .map(MenuEntry::action)
85 + .collect(),
86 + )
87 + }
88 +
89 + /// Rysuje zamkniętą kontrolkę.
90 + pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
91 + let theme = *ctx.theme;
92 + let colors = theme.colors;
93 + ctx.scene
94 + .fill_rounded_rect(rect, theme.radius.small, colors.card_bg);
95 + ctx.scene
96 + .stroke_rounded_rect(rect, theme.radius.small, 1.0, colors.border);
97 +
98 + let body = theme.typography.body.size;
99 + let pad_x = theme.metrics.dropdown.pad_x;
100 + let label = self.selected_label().unwrap_or("");
101 + ctx.label_left(
102 + rect.x() + pad_x,
103 + rect.center().y,
104 + label,
105 + body,
106 + weight::NORMAL,
107 + colors.text,
108 + );
109 + icon::chevron_down(
110 + ctx,
111 + colors.dim_text,
112 + Point::new(rect.right() - pad_x, rect.center().y),
113 + 4.0,
114 + 2.0,
115 + );
116 + }
117 +}
118 +
119 +#[cfg(test)]
120 +mod tests {
121 + use super::*;
122 +
123 + #[test]
124 + fn selection_and_menu_len() {
125 + let mut dd = DropDown::new(["A", "B", "C"]);
126 + assert_eq!(dd.selected_label(), Some("A"));
127 + dd.set_selected(2);
128 + assert_eq!(dd.selected_label(), Some("C"));
129 + assert_eq!(dd.menu().entries().len(), 3);
130 + }
131 +
132 + #[test]
133 + fn hit_test() {
134 + let dd = DropDown::new(["A"]);
135 + let rect = Rect::from_xywh(0.0, 0.0, 160.0, 34.0);
136 + assert!(dd.click_at(rect, Point::new(10.0, 10.0)));
137 + assert!(!dd.click_at(rect, Point::new(300.0, 10.0)));
138 + }
139 +}
0 140 new file mode 100644
@@ -0,0 +1,279 @@
1 +//! `HeaderBar` — pasek tytułu okna (CSD): tło, tytuł, kontrolki okna i obszar na treść.
2 +//!
3 +//! Widget immediate: liczy rozkład ([`HeaderBar::controls_layout`]), rysuje
4 +//! ([`HeaderBar::paint`]) i podpowiada trafienia ([`HeaderBar::control_at`]). Aplikacja
5 +//! wypełnia zwrócony `content` własnymi widgetami (np. przełącznik paska bocznego, menu).
6 +
7 +use libluna_core::style::weight;
8 +use libluna_core::{Insets, Point, Rect, Theme};
9 +
10 +use crate::ctx::PaintCtx;
11 +use crate::icon;
12 +
13 +/// Kontrolka okna.
14 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15 +pub enum WindowControl {
16 + Minimize,
17 + Maximize,
18 + Close,
19 +}
20 +
21 +/// Po której stronie rysować kontrolki okna.
22 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23 +pub enum ControlsSide {
24 + Left,
25 + #[default]
26 + Right,
27 + None,
28 +}
29 +
30 +/// Wyrównanie tytułu.
31 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32 +pub enum TitleAlign {
33 + #[default]
34 + Center,
35 + Start,
36 +}
37 +
38 +/// Rozkład paska: prostokąty kontrolek i wolny obszar na treść.
39 +#[derive(Debug, Clone, Copy)]
40 +pub struct ControlsLayout {
41 + pub minimize: Option<Rect>,
42 + pub maximize: Option<Rect>,
43 + pub close: Option<Rect>,
44 + /// Wolny obszar (między krawędzią a kontrolkami) na własne widgety aplikacji.
45 + pub content: Rect,
46 +}
47 +
48 +/// Pasek tytułu (HeaderBar).
49 +#[derive(Debug, Clone, Default)]
50 +pub struct HeaderBar {
51 + title: Option<String>,
52 + title_align: TitleAlign,
53 + controls: ControlsSide,
54 +}
55 +
56 +impl HeaderBar {
57 + pub fn new() -> Self {
58 + Self {
59 + title: None,
60 + title_align: TitleAlign::Center,
61 + controls: ControlsSide::Right,
62 + }
63 + }
64 +
65 + pub fn title(mut self, title: impl Into<String>) -> Self {
66 + self.title = Some(title.into());
67 + self
68 + }
69 +
70 + pub fn title_align(mut self, align: TitleAlign) -> Self {
71 + self.title_align = align;
72 + self
73 + }
74 +
75 + pub fn controls(mut self, side: ControlsSide) -> Self {
76 + self.controls = side;
77 + self
78 + }
79 +
80 + /// Wysokość paska (z tokenów motywu — component metrics).
81 + pub fn height(&self, theme: &Theme) -> f64 {
82 + theme.metrics.headerbar.height
83 + }
84 +
85 + /// Rozkłada kontrolki i wolny obszar w prostokącie paska.
86 + pub fn controls_layout(&self, rect: Rect, theme: &Theme) -> ControlsLayout {
87 + let metrics = theme.metrics.headerbar;
88 + let pad = theme.spacing.sm;
89 + let control = metrics.control;
90 + let gap = metrics.control_gap;
91 + let y = rect.center().y - control / 2.0;
92 + let control = |x: f64| Rect::from_xywh(x, y, control, control);
93 +
94 + match self.controls {
95 + ControlsSide::None => ControlsLayout {
96 + minimize: None,
97 + maximize: None,
98 + close: None,
99 + content: rect.deflate(Insets::symmetric(0.0, pad)),
100 + },
101 + ControlsSide::Right => {
102 + let close_x = rect.right() - pad - metrics.control;
103 + let max_x = close_x - gap - metrics.control;
104 + let min_x = max_x - gap - metrics.control;
105 + let content = Rect::from_xywh(
106 + rect.x() + pad,
107 + rect.y(),
108 + (min_x - pad - (rect.x() + pad)).max(0.0),
109 + rect.height(),
110 + );
111 + ControlsLayout {
112 + minimize: Some(control(min_x)),
113 + maximize: Some(control(max_x)),
114 + close: Some(control(close_x)),
115 + content,
116 + }
117 + }
118 + ControlsSide::Left => {
119 + let min_x = rect.x() + pad;
120 + let max_x = min_x + metrics.control + gap;
121 + let close_x = max_x + metrics.control + gap;
122 + let left = close_x + metrics.control + pad;
123 + let content = Rect::from_xywh(
124 + left,
125 + rect.y(),
126 + (rect.right() - pad - left).max(0.0),
127 + rect.height(),
128 + );
129 + ControlsLayout {
130 + minimize: Some(control(min_x)),
131 + maximize: Some(control(max_x)),
132 + close: Some(control(close_x)),
133 + content,
134 + }
135 + }
136 + }
137 + }
138 +
139 + /// Która kontrolka okna jest pod punktem `p` (albo `None`).
140 + pub fn control_at(&self, rect: Rect, theme: &Theme, p: Point) -> Option<WindowControl> {
141 + let layout = self.controls_layout(rect, theme);
142 + for (control, hit) in [
143 + (WindowControl::Minimize, layout.minimize),
144 + (WindowControl::Maximize, layout.maximize),
145 + (WindowControl::Close, layout.close),
146 + ] {
147 + if hit.is_some_and(|r| r.contains(p)) {
148 + return Some(control);
149 + }
150 + }
151 + None
152 + }
153 +
154 + /// Rysuje tło, tytuł i kontrolki okna. `hovered` podświetla wskazaną kontrolkę.
155 + pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect, hovered: Option<WindowControl>) {
156 + let theme = *ctx.theme;
157 + let colors = theme.colors;
158 + let layout = self.controls_layout(rect, &theme);
159 +
160 + ctx.scene.fill_rect(rect, colors.headerbar_bg);
161 + ctx.scene.fill_rect(
162 + Rect::from_xywh(rect.x(), rect.bottom() - 1.0, rect.width(), 1.0),
163 + colors.border,
164 + );
165 +
166 + for (control, hit) in [
167 + (WindowControl::Minimize, layout.minimize),
168 + (WindowControl::Maximize, layout.maximize),
169 + (WindowControl::Close, layout.close),
170 + ] {
171 + let Some(box_) = hit else { continue };
172 + let active = hovered == Some(control);
173 + if active {
174 + let bg = if control == WindowControl::Close {
175 + colors.error
176 + } else {
177 + colors.shade.scale_alpha(1.8)
178 + };
179 + ctx.scene.fill_rounded_rect(box_, theme.radius.small, bg);
180 + }
181 + let fg = if active && control == WindowControl::Close {
182 + colors.accent_fg
183 + } else {
184 + colors.text
185 + };
186 + match control {
187 + WindowControl::Minimize => icon::minimize(ctx, fg, box_, 5.0),
188 + WindowControl::Maximize => icon::maximize(ctx, fg, box_, 5.0),
189 + WindowControl::Close => icon::close(ctx, fg, box_, 5.0),
190 + }
191 + }
192 +
193 + if let Some(title) = &self.title {
194 + let size = theme.typography.body.size;
195 + match self.title_align {
196 + TitleAlign::Center => ctx.label_center(
197 + rect.center().x,
198 + rect.center().y,
199 + title,
200 + size,
201 + weight::BOLD,
202 + colors.text,
203 + ),
204 + TitleAlign::Start => {
205 + let x = layout.content.x();
206 + ctx.label_left(x, rect.center().y, title, size, weight::BOLD, colors.text)
207 + }
208 + }
209 + }
210 + }
211 +}
212 +
213 +#[cfg(test)]
214 +mod tests {
215 + use super::*;
216 +
217 + fn bar() -> Rect {
218 + Rect::from_xywh(0.0, 0.0, 900.0, Theme::light().metrics.headerbar.height)
219 + }
220 +
221 + #[test]
222 + fn right_side_controls_are_at_right_edge() {
223 + let theme = Theme::light();
224 + let hb = HeaderBar::new().controls(ControlsSide::Right);
225 + let layout = hb.controls_layout(bar(), &theme);
226 + let close = layout.close.unwrap();
227 + let minimize = layout.minimize.unwrap();
228 + assert!(close.right() <= bar().right());
229 + assert!(close.x() > minimize.x());
230 + // Treść nie nachodzi na kontrolki.
231 + assert!(layout.content.right() <= minimize.x());
232 + }
233 +
234 + #[test]
235 + fn left_side_controls_are_at_left_edge() {
236 + let theme = Theme::light();
237 + let hb = HeaderBar::new().controls(ControlsSide::Left);
238 + let layout = hb.controls_layout(bar(), &theme);
239 + let minimize = layout.minimize.unwrap();
240 + let close = layout.close.unwrap();
241 + assert!(minimize.x() >= bar().x());
242 + assert!(close.x() > minimize.x());
243 + assert!(layout.content.x() >= close.right());
244 + }
245 +
246 + #[test]
247 + fn control_at_hits_close() {
248 + let theme = Theme::light();
249 + let hb = HeaderBar::new().controls(ControlsSide::Right);
250 + let close = hb.controls_layout(bar(), &theme).close.unwrap();
251 + assert_eq!(
252 + hb.control_at(bar(), &theme, close.center()),
253 + Some(WindowControl::Close)
254 + );
255 + assert_eq!(hb.control_at(bar(), &theme, Point::new(400.0, 23.0)), None);
256 + }
257 +
258 + #[test]
259 + fn no_controls_leaves_full_content() {
260 + let theme = Theme::light();
261 + let hb = HeaderBar::new().controls(ControlsSide::None);
262 + let layout = hb.controls_layout(bar(), &theme);
263 + assert!(layout.close.is_none());
264 + assert!(layout.content.width() > 800.0);
265 + }
266 +
267 + #[test]
268 + fn metrics_drive_bar_and_control_geometry() {
269 + let mut theme = Theme::light();
270 + theme.metrics.headerbar.control = 40.0;
271 + theme.metrics.headerbar.height = 60.0;
272 + let hb = HeaderBar::new();
273 + assert_eq!(hb.height(&theme), 60.0);
274 + let rect = Rect::from_xywh(0.0, 0.0, 900.0, hb.height(&theme));
275 + let close = hb.controls_layout(rect, &theme).close.unwrap();
276 + assert_eq!(close.width(), 40.0);
277 + assert_eq!(close.height(), 40.0);
278 + }
279 +}
0 280 new file mode 100644
@@ -0,0 +1,135 @@
1 +//! Współdzielone ikony rysowane wektorowo (bez zależności od fontu).
2 +
3 +use libluna_core::{Color, Point, Rect};
4 +
5 +use crate::ctx::PaintCtx;
6 +
7 +/// Strzałka w dół (chevron); środek w `center`, rozpiętość `size`, grubość `width`.
8 +pub fn chevron_down(paint: &mut PaintCtx, color: Color, center: Point, size: f64, width: f64) {
9 + paint.scene.stroke_line(
10 + Point::new(center.x - size, center.y - size * 0.5),
11 + Point::new(center.x, center.y + size * 0.5),
12 + width,
13 + color,
14 + );
15 + paint.scene.stroke_line(
16 + Point::new(center.x, center.y + size * 0.5),
17 + Point::new(center.x + size, center.y - size * 0.5),
18 + width,
19 + color,
20 + );
21 +}
22 +
23 +/// Strzałka w prawo (chevron); środek w `center`, rozpiętość `size`, grubość `width`.
24 +pub fn chevron_right(paint: &mut PaintCtx, color: Color, center: Point, size: f64, width: f64) {
25 + paint.scene.stroke_line(
26 + Point::new(center.x - size * 0.45, center.y - size),
27 + Point::new(center.x + size * 0.45, center.y),
28 + width,
29 + color,
30 + );
31 + paint.scene.stroke_line(
32 + Point::new(center.x + size * 0.45, center.y),
33 + Point::new(center.x - size * 0.45, center.y + size),
34 + width,
35 + color,
36 + );
37 +}
38 +
39 +/// Lupka (wyszukiwanie); środek w `center`, bok `size`.
40 +pub fn search(paint: &mut PaintCtx, color: Color, center: Point, size: f64) {
41 + let r = size * 0.34;
42 + let c = Point::new(center.x - size * 0.08, center.y - size * 0.08);
43 + paint.scene.stroke_rounded_rect(
44 + Rect::from_xywh(c.x - r, c.y - r, r * 2.0, r * 2.0),
45 + r,
46 + 1.6,
47 + color,
48 + );
49 + let a = Point::new(c.x + r * 0.75, c.y + r * 0.75);
50 + paint.scene.stroke_line(
51 + a,
52 + Point::new(a.x + size * 0.28, a.y + size * 0.28),
53 + 1.6,
54 + color,
55 + );
56 +}
57 +
58 +/// Ikona katalogu (folder) w prostokącie `box_`.
59 +pub fn folder(paint: &mut PaintCtx, color: Color, box_: Rect) {
60 + let tab_h = box_.height() * 0.24;
61 + let tab_w = box_.width() * 0.46;
62 + paint.scene.fill_rounded_rect(
63 + Rect::from_xywh(box_.x(), box_.y(), tab_w, tab_h * 1.6),
64 + tab_h * 0.4,
65 + color,
66 + );
67 + paint.scene.fill_rounded_rect(
68 + Rect::from_xywh(
69 + box_.x(),
70 + box_.y() + tab_h * 0.9,
71 + box_.width(),
72 + box_.height() - tab_h * 0.9,
73 + ),
74 + box_.width() * 0.1,
75 + color,
76 + );
77 +}
78 +
79 +/// Ikona pliku (kartka z zagiętym rogiem) w prostokącie `box_`.
80 +pub fn file(paint: &mut PaintCtx, color: Color, box_: Rect) {
81 + let w = box_.width();
82 + paint.scene.stroke_rounded_rect(box_, w * 0.12, 1.8, color);
83 + let fold = w * 0.34;
84 + paint.scene.stroke_line(
85 + Point::new(box_.right() - fold, box_.y()),
86 + Point::new(box_.right() - fold, box_.y() + fold),
87 + 1.8,
88 + color,
89 + );
90 + paint.scene.stroke_line(
91 + Point::new(box_.right() - fold, box_.y() + fold),
92 + Point::new(box_.right(), box_.y() + fold),
93 + 1.8,
94 + color,
95 + );
96 +}
97 +
98 +/// Krzyżyk (zamknięcie); środek w `box_`, ramię `arm`.
99 +pub fn close(paint: &mut PaintCtx, color: Color, box_: Rect, arm: f64) {
100 + let c = box_.center();
101 + paint.scene.stroke_line(
102 + Point::new(c.x - arm, c.y - arm),
103 + Point::new(c.x + arm, c.y + arm),
104 + 2.0,
105 + color,
106 + );
107 + paint.scene.stroke_line(
108 + Point::new(c.x + arm, c.y - arm),
109 + Point::new(c.x - arm, c.y + arm),
110 + 2.0,
111 + color,
112 + );
113 +}
114 +
115 +/// Pozioma kreska (minimalizacja).
116 +pub fn minimize(paint: &mut PaintCtx, color: Color, box_: Rect, arm: f64) {
117 + let c = box_.center();
118 + paint.scene.stroke_line(
119 + Point::new(c.x - arm, c.y),
120 + Point::new(c.x + arm, c.y),
121 + 2.0,
122 + color,
123 + );
124 +}
125 +
126 +/// Kwadrat (maksymalizacja).
127 +pub fn maximize(paint: &mut PaintCtx, color: Color, box_: Rect, half: f64) {
128 + let c = box_.center();
129 + paint.scene.stroke_rounded_rect(
130 + Rect::from_xywh(c.x - half, c.y - half, half * 2.0, half * 2.0),
131 + 2.0,
132 + 2.0,
133 + color,
134 + );
135 +}
0 136 new file mode 100644
@@ -0,0 +1,361 @@
1 +//! `ItemView` — widok elementów (pliki/katalogi) w trybie **siatki** lub **listy**.
2 +//!
3 +//! Widget immediate: trzyma listę elementów, tryb wyświetlania, zaznaczenie i podświetlenie.
4 +//! Aplikacja woła [`ItemView::paint`], [`ItemView::index_at`] / [`ItemView::click_at`] oraz
5 +//! [`ItemView::slot_rect`] (np. do rysowania podpisów). Geometria wynika z tokenów motywu
6 +//! ([`ItemViewMetrics`](libluna_core::ItemViewMetrics)).
7 +
8 +use libluna_core::style::weight;
9 +use libluna_core::{Color, Point, Rect, Theme};
10 +
11 +use crate::ctx::PaintCtx;
12 +use crate::icon;
13 +
14 +/// Rodzaj elementu (wpływa na ikonę).
15 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16 +pub enum ItemKind {
17 + Folder,
18 + #[default]
19 + File,
20 +}
21 +
22 +/// Pojedynczy element (plik/katalog).
23 +#[derive(Debug, Clone, PartialEq)]
24 +pub struct Item {
25 + pub label: String,
26 + /// Krótki dopisek (np. typ lub rozmiar).
27 + pub detail: Option<String>,
28 + pub kind: ItemKind,
29 +}
30 +
31 +impl Item {
32 + pub fn new(label: impl Into<String>) -> Self {
33 + Self {
34 + label: label.into(),
35 + detail: None,
36 + kind: ItemKind::File,
37 + }
38 + }
39 +
40 + pub fn folder(label: impl Into<String>) -> Self {
41 + Self {
42 + label: label.into(),
43 + detail: None,
44 + kind: ItemKind::Folder,
45 + }
46 + }
47 +
48 + pub fn detail(mut self, detail: impl Into<String>) -> Self {
49 + self.detail = Some(detail.into());
50 + self
51 + }
52 +
53 + pub fn kind(mut self, kind: ItemKind) -> Self {
54 + self.kind = kind;
55 + self
56 + }
57 +}
58 +
59 +/// Tryb prezentacji.
60 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
61 +pub enum ViewMode {
62 + #[default]
63 + List,
64 + Grid,
65 +}
66 +
67 +/// Widok elementów.
68 +#[derive(Debug, Clone, Default)]
69 +pub struct ItemView {
70 + items: Vec<Item>,
71 + mode: ViewMode,
72 + selected: Option<usize>,
73 + hovered: Option<usize>,
74 + scroll: f64,
75 +}
76 +
77 +impl ItemView {
78 + pub fn new(items: Vec<Item>) -> Self {
79 + Self {
80 + items,
81 + ..Self::default()
82 + }
83 + }
84 +
85 + pub fn items(&self) -> &[Item] {
86 + &self.items
87 + }
88 +
89 + pub fn set_items(&mut self, items: Vec<Item>) {
90 + self.items = items;
91 + self.selected = None;
92 + self.scroll = 0.0;
93 + }
94 +
95 + pub fn len(&self) -> usize {
96 + self.items.len()
97 + }
98 +
99 + pub fn is_empty(&self) -> bool {
100 + self.items.is_empty()
101 + }
102 +
103 + pub fn mode(&self) -> ViewMode {
104 + self.mode
105 + }
106 +
107 + pub fn set_mode(&mut self, mode: ViewMode) {
108 + self.mode = mode;
109 + }
110 +
111 + pub fn toggle_mode(&mut self) {
112 + self.mode = match self.mode {
113 + ViewMode::List => ViewMode::Grid,
114 + ViewMode::Grid => ViewMode::List,
115 + };
116 + }
117 +
118 + pub fn selected(&self) -> Option<usize> {
119 + self.selected
120 + }
121 +
122 + pub fn set_selected(&mut self, selected: Option<usize>) {
123 + self.selected = selected;
124 + }
125 +
126 + pub fn set_hovered(&mut self, hovered: Option<usize>) {
127 + self.hovered = hovered;
128 + }
129 +
130 + pub fn scroll(&self) -> f64 {
131 + self.scroll
132 + }
133 +
134 + pub fn set_scroll(&mut self, scroll: f64) {
135 + self.scroll = scroll.max(0.0);
136 + }
137 +
138 + /// Liczba kolumn w trybie siatki dla zadanej szerokości.
139 + pub fn columns(&self, rect: Rect, theme: &Theme) -> usize {
140 + let m = theme.metrics.itemview;
141 + if m.tile_w + m.gap <= 0.0 {
142 + return 1;
143 + }
144 + (((rect.width() + m.gap) / (m.tile_w + m.gap)).floor() as usize).max(1)
145 + }
146 +
147 + /// Wysokość całej zawartości dla zadanej szerokości (do przycinania przewijania).
148 + pub fn content_height(&self, rect: Rect, theme: &Theme) -> f64 {
149 + let m = theme.metrics.itemview;
150 + if self.items.is_empty() {
151 + return 0.0;
152 + }
153 + match self.mode {
154 + ViewMode::Grid => {
155 + let cols = self.columns(rect, theme);
156 + let rows = self.items.len().div_ceil(cols);
157 + rows as f64 * (m.tile_h + m.gap) - m.gap
158 + }
159 + ViewMode::List => self.items.len() as f64 * m.row_h,
160 + }
161 + }
162 +
163 + /// Maksymalne przewinięcie (0, gdy wszystko się mieści).
164 + pub fn max_scroll(&self, rect: Rect, theme: &Theme) -> f64 {
165 + (self.content_height(rect, theme) - rect.height()).max(0.0)
166 + }
167 +
168 + /// Przytina przewinięcie do dozwolonego zakresu.
169 + pub fn clamp_scroll(&mut self, rect: Rect, theme: &Theme) {
170 + self.scroll = self.scroll.clamp(0.0, self.max_scroll(rect, theme));
171 + }
172 +
173 + /// Prostokąt elementu `i` (z uwzględnieniem trybu i przewinięcia).
174 + pub fn slot_rect(&self, rect: Rect, i: usize, theme: &Theme) -> Rect {
175 + let m = theme.metrics.itemview;
176 + match self.mode {
177 + ViewMode::Grid => {
178 + let cols = self.columns(rect, theme);
179 + let col = i % cols;
180 + let row = i / cols;
181 + Rect::from_xywh(
182 + rect.x() + col as f64 * (m.tile_w + m.gap),
183 + rect.y() + row as f64 * (m.tile_h + m.gap) - self.scroll,
184 + m.tile_w,
185 + m.tile_h,
186 + )
187 + }
188 + ViewMode::List => Rect::from_xywh(
189 + rect.x(),
190 + rect.y() + i as f64 * m.row_h - self.scroll,
191 + rect.width(),
192 + m.row_h,
193 + ),
194 + }
195 + }
196 +
197 + /// Indeks elementu pod punktem `p` (tylko jeśli trafienie jest w widocznym obszarze).
198 + pub fn index_at(&self, rect: Rect, p: Point, theme: &Theme) -> Option<usize> {
199 + if !rect.contains(p) {
200 + return None;
201 + }
202 + (0..self.items.len()).find(|i| self.slot_rect(rect, *i, theme).contains(p))
203 + }
204 +
205 + /// Zaznacza element pod punktem `p`; zwraca jego indeks.
206 + pub fn click_at(&mut self, rect: Rect, p: Point, theme: &Theme) -> Option<usize> {
207 + let i = self.index_at(rect, p, theme)?;
208 + self.selected = Some(i);
209 + Some(i)
210 + }
211 +
212 + pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
213 + let theme = *ctx.theme;
214 + let m = theme.metrics.itemview;
215 + let colors = theme.colors;
216 + let body = theme.typography.body.size;
217 + let caption = theme.typography.caption.size;
218 +
219 + ctx.scene.push_clip(rect);
220 + for (i, item) in self.items.iter().enumerate() {
221 + let s = self.slot_rect(rect, i, &theme);
222 + let selected = self.selected == Some(i);
223 + if selected {
224 + ctx.scene
225 + .fill_rounded_rect(s, theme.radius.small, colors.shade.scale_alpha(1.8));
226 + } else if self.hovered == Some(i) {
227 + ctx.scene
228 + .fill_rounded_rect(s, theme.radius.small, colors.shade);
229 + }
230 +
231 + match self.mode {
232 + ViewMode::Grid => {
233 + let ib =
234 + Rect::from_xywh(s.center().x - m.icon / 2.0, s.y() + 8.0, m.icon, m.icon);
235 + draw_icon(ctx, item.kind, ib, colors.accent, colors.dim_text);
236 + ctx.label_center(
237 + s.center().x,
238 + ib.bottom() + 12.0,
239 + &item.label,
240 + body,
241 + weight::NORMAL,
242 + colors.text,
243 + );
244 + if let Some(detail) = &item.detail {
245 + ctx.label_center(
246 + s.center().x,
247 + s.bottom() - 8.0,
248 + detail,
249 + caption,
250 + weight::NORMAL,
251 + colors.dim_text,
252 + );
253 + }
254 + }
255 + ViewMode::List => {
256 + let ib =
257 + Rect::from_xywh(s.x() + 12.0, s.center().y - m.icon / 2.0, m.icon, m.icon);
258 + draw_icon(ctx, item.kind, ib, colors.accent, colors.dim_text);
259 + let tx = ib.right() + 12.0;
260 + ctx.label_left(
261 + tx,
262 + s.center().y,
263 + &item.label,
264 + body,
265 + weight::NORMAL,
266 + colors.text,
267 + );
268 + if let Some(detail) = &item.detail {
269 + ctx.label_right(
270 + s.right() - 12.0,
271 + s.center().y,
272 + detail,
273 + caption,
274 + weight::NORMAL,
275 + colors.dim_text,
276 + );
277 + }
278 + }
279 + }
280 + }
281 + ctx.scene.pop_clip();
282 + }
283 +}
284 +
285 +fn draw_icon(ctx: &mut PaintCtx, kind: ItemKind, box_: Rect, folder: Color, file: Color) {
286 + match kind {
287 + ItemKind::Folder => icon::folder(ctx, folder, box_),
288 + ItemKind::File => icon::file(ctx, file, box_),
289 + }
290 +}
291 +
292 +#[cfg(test)]
293 +mod tests {
294 + use super::*;
295 +
296 + fn items() -> Vec<Item> {
297 + vec![
298 + Item::folder("Projekty"),
299 + Item::new("Raport").detail("PDF"),
300 + Item::new("Notatki").detail("TXT"),
301 + Item::new("Budżet").detail("XLSX"),
302 + ]
303 + }
304 +
305 + fn rect() -> Rect {
306 + Rect::from_xywh(0.0, 0.0, 400.0, 200.0)
307 + }
308 +
309 + #[test]
310 + fn grid_columns_and_slots() {
311 + let theme = Theme::light();
312 + let mut v = ItemView::new(items());
313 + v.set_mode(ViewMode::Grid);
314 + // (400 + 12) / (100 + 12) = 3 kolumny.
315 + assert_eq!(v.columns(rect(), &theme), 3);
316 + let a = v.slot_rect(rect(), 0, &theme);
317 + let b = v.slot_rect(rect(), 1, &theme);
318 + assert!(b.x() > a.x());
319 + let d = v.slot_rect(rect(), 3, &theme); // 4. element → 2. rząd
320 + assert!(d.y() > a.y());
321 + }
322 +
323 + #[test]
324 + fn list_slots_stack_vertically() {
325 + let theme = Theme::light();
326 + let v = ItemView::new(items());
327 + let r0 = v.slot_rect(rect(), 0, &theme);
328 + let r1 = v.slot_rect(rect(), 1, &theme);
329 + assert_eq!(r0.x(), r1.x());
330 + assert!((r1.y() - r0.y() - theme.metrics.itemview.row_h).abs() < 1e-9);
331 + }
332 +
333 + #[test]
334 + fn click_selects_item() {
335 + let theme = Theme::light();
336 + let mut v = ItemView::new(items());
337 + let center = v.slot_rect(rect(), 2, &theme).center();
338 + assert_eq!(v.click_at(rect(), center, &theme), Some(2));
339 + assert_eq!(v.selected(), Some(2));
340 + // Klik poza widokiem nic nie robi.
341 + assert_eq!(v.click_at(rect(), Point::new(1000.0, 1000.0), &theme), None);
342 + }
343 +
344 + #[test]
345 + fn toggle_mode_switches() {
346 + let mut v = ItemView::new(items());
347 + assert_eq!(v.mode(), ViewMode::List);
348 + v.toggle_mode();
349 + assert_eq!(v.mode(), ViewMode::Grid);
350 + }
351 +
352 + #[test]
353 + fn scroll_is_clamped_to_content() {
354 + let theme = Theme::light();
355 + let mut v = ItemView::new(items());
356 + let short = Rect::from_xywh(0.0, 0.0, 400.0, 100.0);
357 + v.set_scroll(9999.0);
358 + v.clamp_scroll(short, &theme);
359 + assert!(v.scroll() <= v.max_scroll(short, &theme) + 1e-9);
360 + }
361 +}
0 362 new file mode 100644
@@ -0,0 +1,67 @@
1 +//! # libluna-widgets
2 +//!
3 +//! Zestaw widgetów libLuny w modelu **immediate**: widget to zwykła struktura ze stanem,
4 +//! a aplikacja woła dla niego `paint` (rysunek) i `*_at`/`click_at` (trafianie i zmiana
5 +//! stanu). Dzięki temu widgety są łatwe do testowania (hit-test bez okna) i tanie.
6 +//!
7 +//! Docelowo (M2) powstanie nad tym retencyjne drzewo z `measure`/`allocate`; te widgety
8 +//! staną się jego liśćmi, ale kontrakt (`paint`/`hit`) zostaje.
9 +//!
10 +//! Widgety:
11 +//! - [`ViewSwitcher`] — segmentowy przełącznik (`Lista | Siatka`, `Jasny | Ciemny | Auto`).
12 +//! - [`NavList`] — menu nawigacji (sekcje + pozycje).
13 +//! - [`Menu`] — popover z pozycjami, separatorami i skrótami (menu `☰`).
14 +//! - [`Button`] — przycisk z etykietą i wariantami.
15 +//! - [`Switch`] — przełącznik wł./wył.
16 +//! - [`Slider`] — suwak 0..1.
17 +//! - [`DropDown`] — lista rozwijana (rozwinięcie przez [`Menu`]).
18 +//! - [`HeaderBar`] — pasek tytułu okna (CSD) z kontrolkami okna i obszarem na treść.
19 +//! - [`TabBar`] / [`TabView`] — pasek zakładek i model stron (dodawanie, zamykanie, wybór).
20 +//! - [`BottomSheet`] — dolny panel (modalny) z „grabberem” i zamykaniem.
21 +//! - [`Pager`] — karuzela stron (przewijanie w poziomie + kropki-wskaźniki).
22 +//! - [`PathBar`] — pasek ścieżki (okruszki) z separatorami i zwijaniem w wielokropek.
23 +//! - [`SearchEntry`] — pole tekstowe wyszukiwania (lupka, kursor, czyszczenie).
24 +//! - [`ItemView`] — widok elementów (pliki/katalogi) w siatce lub liście.
25 +//!
26 +//! ```no_run
27 +//! use libluna_widgets::{PaintCtx, ViewSwitcher};
28 +//! # fn demo(ctx: &mut PaintCtx, rect: libluna_core::Rect) {
29 +//! let mut switcher = ViewSwitcher::new(["Lista", "Siatka"]);
30 +//! switcher.paint(ctx, rect);
31 +//! # }
32 +//! ```
33 +
34 +pub mod bottom_sheet;
35 +pub mod button;
36 +pub mod ctx;
37 +pub mod dropdown;
38 +pub mod header_bar;
39 +pub mod icon;
40 +pub mod item_view;
41 +pub mod menu;
42 +pub mod nav_list;
43 +pub mod pager;
44 +pub mod path_bar;
45 +pub mod search_entry;
46 +pub mod slider;
47 +pub mod switch;
48 +pub mod tab_bar;
49 +pub mod tab_view;
50 +pub mod view_switcher;
51 +
52 +pub use bottom_sheet::BottomSheet;
53 +pub use button::{Button, ButtonVariant};
54 +pub use ctx::PaintCtx;
55 +pub use dropdown::DropDown;
56 +pub use header_bar::{ControlsLayout, ControlsSide, HeaderBar, TitleAlign, WindowControl};
57 +pub use item_view::{Item, ItemKind, ItemView, ViewMode};
58 +pub use menu::{Menu, MenuEntry, MenuRow};
59 +pub use nav_list::{NavEntry, NavList, NavRow};
60 +pub use pager::Pager;
61 +pub use path_bar::PathBar;
62 +pub use search_entry::SearchEntry;
63 +pub use slider::Slider;
64 +pub use switch::Switch;
65 +pub use tab_bar::{Tab, TabBar};
66 +pub use tab_view::TabView;
67 +pub use view_switcher::ViewSwitcher;
0 68 new file mode 100644
@@ -0,0 +1,326 @@
1 +//! `Menu` — popover z listą pozycji (akcje, separatory, sekcje, skróty).
2 +//!
3 +//! Widget immediate: trzyma zawartość i stan podświetlenia. Aplikacja wywołuje
4 +//! [`Menu::paint`] oraz [`Menu::index_at`] / [`Menu::click_at`] i sama decyduje o
5 +//! zamknięciu (klik poza panelem, `Esc`). Widget liczy też własny rozmiar i potrafi
6 +//! **umieścić się** pod kotwicą, przycinając do krawędzi okna.
7 +
8 +use libluna_core::style::weight;
9 +use libluna_core::{Insets, Point, Rect, Theme};
10 +use libluna_render::TextEngine;
11 +
12 +use crate::ctx::PaintCtx;
13 +
14 +/// Pozycja menu.
15 +#[derive(Debug, Clone)]
16 +pub enum MenuEntry {
17 + /// Akcja z etykietą, opcjonalnym skrótem i stanem aktywności.
18 + Action {
19 + label: String,
20 + shortcut: Option<String>,
21 + enabled: bool,
22 + },
23 + /// Nagłówek sekcji.
24 + Section(String),
25 + /// Poziomy separator.
26 + Separator,
27 +}
28 +
29 +impl MenuEntry {
30 + pub fn action(label: impl Into<String>) -> Self {
31 + MenuEntry::Action {
32 + label: label.into(),
33 + shortcut: None,
34 + enabled: true,
35 + }
36 + }
37 +
38 + pub fn shortcut(mut self, text: impl Into<String>) -> Self {
39 + if let MenuEntry::Action { shortcut, .. } = &mut self {
40 + *shortcut = Some(text.into());
41 + }
42 + self
43 + }
44 +
45 + pub fn disabled(mut self) -> Self {
46 + if let MenuEntry::Action { enabled, .. } = &mut self {
47 + *enabled = false;
48 + }
49 + self
50 + }
51 +
52 + pub fn section(title: impl Into<String>) -> Self {
53 + MenuEntry::Section(title.into())
54 + }
55 +
56 + pub fn separator() -> Self {
57 + MenuEntry::Separator
58 + }
59 +}
60 +
61 +/// Wiersz rozłożony w prostokącie menu (wynik [`Menu::rows`]).
62 +#[derive(Debug, Clone, Copy, PartialEq)]
63 +pub struct MenuRow {
64 + pub entry: usize,
65 + pub rect: Rect,
66 + pub selectable: bool,
67 +}
68 +
69 +/// Popover z pozycjami.
70 +#[derive(Debug, Clone, Default)]
71 +pub struct Menu {
72 + entries: Vec<MenuEntry>,
73 + hovered: Option<usize>,
74 +}
75 +
76 +impl Menu {
77 + pub fn new(entries: Vec<MenuEntry>) -> Self {
78 + Self {
79 + entries,
80 + hovered: None,
81 + }
82 + }
83 +
84 + pub fn entries(&self) -> &[MenuEntry] {
85 + &self.entries
86 + }
87 +
88 + pub fn set_hovered(&mut self, hovered: Option<usize>) {
89 + self.hovered = hovered;
90 + }
91 +
92 + /// Wysokość panelu.
93 + pub fn height(&self, theme: &Theme) -> f64 {
94 + let m = theme.metrics.menu;
95 + let body: f64 = self
96 + .entries
97 + .iter()
98 + .map(|e| match e {
99 + MenuEntry::Action { .. } => m.item_h,
100 + MenuEntry::Section(_) => m.section_h,
101 + MenuEntry::Separator => m.separator_h,
102 + })
103 + .sum();
104 + m.pad_v * 2.0 + body
105 + }
106 +
107 + /// Sugerowana szerokość — dopasowana do najdłuższej pozycji (z etykietą i skrótem).
108 + pub fn width(&self, text: &TextEngine, theme: &Theme) -> f64 {
109 + let m = theme.metrics.menu;
110 + let body = theme.typography.body.size;
111 + let caption = theme.typography.caption.size;
112 + let mut w = m.min_width;
113 + for entry in &self.entries {
114 + if let MenuEntry::Action {
115 + label, shortcut, ..
116 + } = entry
117 + {
118 + let mut row = text.measure(label, body, weight::NORMAL).width + 2.0 * m.pad_h;
119 + if let Some(shortcut) = shortcut {
120 + row += m.label_gap + text.measure(shortcut, caption, weight::NORMAL).width;
121 + }
122 + w = w.max(row);
123 + }
124 + }
125 + w
126 + }
127 +
128 + /// Umieszcza panel pod `anchor`, przycinając do `viewport`
129 + /// (przy prawej krawędzi wyrównuje do prawej, przy dolnej otwiera do góry).
130 + pub fn place(&self, anchor: Rect, width: f64, viewport: Rect, theme: &Theme) -> Rect {
131 + let m = theme.metrics.menu;
132 + let height = self.height(theme);
133 + let margin = m.margin;
134 +
135 + let mut x = anchor.x();
136 + if x + width > viewport.right() - margin {
137 + x = viewport.right() - margin - width;
138 + }
139 + x = x.max(viewport.x() + margin);
140 +
141 + let mut y = anchor.bottom() + m.anchor_gap;
142 + if y + height > viewport.bottom() - margin {
143 + y = anchor.y() - height - m.anchor_gap;
144 + }
145 + y = y.max(viewport.y() + margin);
146 +
147 + Rect::from_xywh(x, y, width, height)
148 + }
149 +
150 + /// Rozkłada wiersze od góry panelu.
151 + pub fn rows(&self, panel: Rect, theme: &Theme) -> Vec<MenuRow> {
152 + let m = theme.metrics.menu;
153 + let mut rows = Vec::with_capacity(self.entries.len());
154 + let mut y = panel.y() + m.pad_v;
155 + for (i, entry) in self.entries.iter().enumerate() {
156 + let (h, selectable) = match entry {
157 + MenuEntry::Action { enabled, .. } => (m.item_h, *enabled),
158 + MenuEntry::Section(_) => (m.section_h, false),
159 + MenuEntry::Separator => (m.separator_h, false),
160 + };
161 + rows.push(MenuRow {
162 + entry: i,
163 + rect: Rect::from_xywh(panel.x(), y, panel.width(), h),
164 + selectable,
165 + });
166 + y += h;
167 + }
168 + rows
169 + }
170 +
171 + /// Indeks akcji pod punktem `p` (sekcje, separatory i akcje wyłączone → `None`).
172 + pub fn index_at(&self, panel: Rect, p: Point, theme: &Theme) -> Option<usize> {
173 + self.rows(panel, theme)
174 + .into_iter()
175 + .find(|row| row.selectable && row.rect.contains(p))
176 + .map(|row| row.entry)
177 + }
178 +
179 + /// Obsługa kliknięcia: zwraca indeks wybranej akcji (decyzję o zamknięciu podejmuje aplikacja).
180 + pub fn click_at(&mut self, panel: Rect, p: Point, theme: &Theme) -> Option<usize> {
181 + self.index_at(panel, p, theme)
182 + }
183 +
184 + /// Podświetlenie z pozycji wskaźnika.
185 + pub fn hover_at(&mut self, panel: Rect, p: Option<Point>, theme: &Theme) {
186 + self.hovered = p.and_then(|p| self.index_at(panel, p, theme));
187 + }
188 +
189 + /// Rysuje panel.
190 + pub fn paint(&self, ctx: &mut PaintCtx, panel: Rect) {
191 + let theme = *ctx.theme;
192 + let colors = theme.colors;
193 + let radius = theme.radius;
194 +
195 + // Tło panelu + obramowanie.
196 + ctx.scene
197 + .fill_rounded_rect(panel, radius.medium, colors.card_bg);
198 + ctx.scene
199 + .stroke_rounded_rect(panel, radius.medium, 1.0, colors.border);
200 +
201 + let body = theme.typography.body.size;
202 + let caption = theme.typography.caption.size;
203 + let m = theme.metrics.menu;
204 +
205 + for row in self.rows(panel, &theme) {
206 + match &self.entries[row.entry] {
207 + MenuEntry::Separator => {
208 + let y = row.rect.center().y;
209 + ctx.scene.fill_rect(
210 + Rect::from_xywh(
211 + panel.x() + m.pad_h,
212 + y,
213 + (panel.width() - 2.0 * m.pad_h).max(0.0),
214 + 1.0,
215 + ),
216 + colors.border,
217 + );
218 + }
219 + MenuEntry::Section(title) => {
220 + ctx.label_left(
221 + panel.x() + m.pad_h,
222 + row.rect.center().y,
223 + title,
224 + caption,
225 + weight::BOLD,
226 + colors.dim_text,
227 + );
228 + }
229 + MenuEntry::Action {
230 + label,
231 + shortcut,
232 + enabled,
233 + } => {
234 + let highlighted = self.hovered == Some(row.entry) && *enabled;
235 + if highlighted {
236 + let hl =
237 + row.rect
238 + .deflate(Insets::new(0.0, m.pad_h - 2.0, 0.0, m.pad_h - 2.0));
239 + ctx.scene.fill_rounded_rect(hl, radius.small, colors.shade);
240 + }
241 + let fg = if *enabled {
242 + colors.text
243 + } else {
244 + colors.dim_text
245 + };
246 + ctx.label_left(
247 + panel.x() + m.pad_h,
248 + row.rect.center().y,
249 + label,
250 + body,
251 + weight::NORMAL,
252 + fg,
253 + );
254 + if let Some(shortcut) = shortcut {
255 + ctx.label_right(
256 + panel.right() - m.pad_h,
257 + row.rect.center().y,
258 + shortcut,
259 + caption,
260 + weight::NORMAL,
261 + colors.dim_text,
262 + );
263 + }
264 + }
265 + }
266 + }
267 + }
268 +}
269 +
270 +#[cfg(test)]
271 +mod tests {
272 + use super::*;
273 +
274 + fn menu() -> Menu {
275 + Menu::new(vec![
276 + MenuEntry::action("New Window").shortcut("Ctrl+N"),
277 + MenuEntry::action("Shortcuts"),
278 + MenuEntry::separator(),
279 + MenuEntry::action("Settings"),
280 + MenuEntry::action("Disabled").disabled(),
281 + ])
282 + }
283 +
284 + fn panel(theme: &Theme) -> Rect {
285 + Rect::from_xywh(100.0, 100.0, 200.0, menu().height(theme))
286 + }
287 +
288 + #[test]
289 + fn height_counts_separators_and_items() {
290 + let m = Theme::light().metrics.menu;
291 + let h = menu().height(&Theme::light());
292 + assert_eq!(h, m.pad_v * 2.0 + 4.0 * m.item_h + m.separator_h);
293 + }
294 +
295 + #[test]
296 + fn index_at_skips_separator_and_disabled() {
297 + let theme = Theme::light();
298 + let m = menu();
299 + let p = panel(&theme);
300 + let rows = m.rows(p, &theme);
301 + assert_eq!(m.index_at(p, rows[0].rect.center(), &theme), Some(0));
302 + assert_eq!(m.index_at(p, rows[2].rect.center(), &theme), None); // separator
303 + assert_eq!(m.index_at(p, rows[4].rect.center(), &theme), None); // wyłączona
304 + }
305 +
306 + #[test]
307 + fn place_shifts_left_near_right_edge() {
308 + let theme = Theme::light();
309 + let m = menu();
310 + let viewport = Rect::from_xywh(0.0, 0.0, 300.0, 400.0);
311 + let anchor = Rect::from_xywh(280.0, 0.0, 20.0, 20.0);
312 + let placed = m.place(anchor, 200.0, viewport, &theme);
313 + assert!(placed.right() <= viewport.right() - theme.metrics.menu.margin + 0.001);
314 + assert!(placed.x() >= 0.0);
315 + }
316 +
317 + #[test]
318 + fn place_flips_up_near_bottom_edge() {
319 + let theme = Theme::light();
320 + let m = menu();
321 + let viewport = Rect::from_xywh(0.0, 0.0, 300.0, 400.0);
322 + let anchor = Rect::from_xywh(10.0, 380.0, 20.0, 20.0);
323 + let placed = m.place(anchor, 200.0, viewport, &theme);
324 + assert!(placed.bottom() <= anchor.y());
325 + }
326 +}
0 327 new file mode 100644
@@ -0,0 +1,249 @@
1 +//! `NavList` — menu nawigacji (sekcje + wybieralne pozycje) do panelu bocznego.
2 +//!
3 +//! Widget immediate. Pozycje są wybieralne; nagłówki sekcji pełnią rolę podpisów.
4 +//! Etykiety podaje aplikacja (już przetłumaczone) — widget nie zna i18n.
5 +
6 +use libluna_core::style::weight;
7 +use libluna_core::{Point, Rect, Theme};
8 +
9 +use crate::ctx::PaintCtx;
10 +
11 +/// Element menu nawigacji.
12 +#[derive(Debug, Clone)]
13 +pub enum NavEntry {
14 + /// Nagłówek sekcji (nieklikalny).
15 + Section(String),
16 + /// Pozycja wybieralna.
17 + Item(String),
18 +}
19 +
20 +/// Wiersz rozłożony w prostokącie listy (wynik [`NavList::rows`]).
21 +#[derive(Debug, Clone, Copy, PartialEq)]
22 +pub struct NavRow {
23 + /// Indeks elementu w [`NavList::entries`].
24 + pub entry: usize,
25 + /// Prostokąt wiersza.
26 + pub rect: Rect,
27 + /// Czy pozycja jest wybieralna (a więc klikalna).
28 + pub selectable: bool,
29 +}
30 +
31 +/// Menu nawigacji.
32 +#[derive(Debug, Clone, Default)]
33 +pub struct NavList {
34 + entries: Vec<NavEntry>,
35 + selected: Option<usize>,
36 + hovered: Option<usize>,
37 +}
38 +
39 +impl NavList {
40 + pub fn new(entries: Vec<NavEntry>) -> Self {
41 + Self {
42 + entries,
43 + selected: None,
44 + hovered: None,
45 + }
46 + }
47 +
48 + pub fn entries(&self) -> &[NavEntry] {
49 + &self.entries
50 + }
51 +
52 + /// Indeks wybranej pozycji (albo `None`).
53 + pub fn selected(&self) -> Option<usize> {
54 + self.selected
55 + }
56 +
57 + pub fn selected_label(&self) -> Option<&str> {
58 + match self.selected.and_then(|i| self.entries.get(i)) {
59 + Some(NavEntry::Item(label)) => Some(label),
60 + _ => None,
61 + }
62 + }
63 +
64 + /// Ustawia wybór; ignoruje indeksy wskazujące na nagłówki sekcji.
65 + pub fn set_selected(&mut self, index: Option<usize>) {
66 + self.selected = match index {
67 + Some(i) if matches!(self.entries.get(i), Some(NavEntry::Item(_))) => Some(i),
68 + _ => None,
69 + };
70 + }
71 +
72 + pub fn set_hovered(&mut self, hovered: Option<usize>) {
73 + self.hovered = hovered;
74 + }
75 +
76 + /// Wysokość potrzebna na całą listę (piksele logiczne).
77 + pub fn height(&self, theme: &Theme) -> f64 {
78 + let m = theme.metrics.nav;
79 + let body: f64 = self
80 + .entries
81 + .iter()
82 + .map(|e| match e {
83 + NavEntry::Section(_) => m.section_h,
84 + NavEntry::Item(_) => m.item_h,
85 + })
86 + .sum();
87 + m.pad_v + body + m.pad_v
88 + }
89 +
90 + /// Rozkłada wiersze od góry `rect`.
91 + pub fn rows(&self, rect: Rect, theme: &Theme) -> Vec<NavRow> {
92 + let m = theme.metrics.nav;
93 + let mut rows = Vec::with_capacity(self.entries.len());
94 + let mut y = rect.y() + m.pad_v;
95 + for (i, entry) in self.entries.iter().enumerate() {
96 + let (h, selectable) = match entry {
97 + NavEntry::Section(_) => (m.section_h, false),
98 + NavEntry::Item(_) => (m.item_h, true),
99 + };
100 + rows.push(NavRow {
101 + entry: i,
102 + rect: Rect::from_xywh(rect.x(), y, rect.width(), h),
103 + selectable,
104 + });
105 + y += h;
106 + }
107 + rows
108 + }
109 +
110 + /// Indeks wybieralnej pozycji pod punktem `p` (nagłówki zwracają `None`).
111 + pub fn index_at(&self, rect: Rect, p: Point, theme: &Theme) -> Option<usize> {
112 + self.rows(rect, theme)
113 + .into_iter()
114 + .find(|row| row.selectable && row.rect.contains(p))
115 + .map(|row| row.entry)
116 + }
117 +
118 + /// Obsługuje klik: ustawia wybór. Zwraca `true`, jeśli wybór się zmienił.
119 + pub fn click_at(&mut self, rect: Rect, p: Point, theme: &Theme) -> bool {
120 + match self.index_at(rect, p, theme) {
121 + Some(i) if self.selected != Some(i) => {
122 + self.selected = Some(i);
123 + true
124 + }
125 + _ => false,
126 + }
127 + }
128 +
129 + /// Rysuje listę w prostokącie `rect`.
130 + pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
131 + let theme = *ctx.theme;
132 + let radius = theme.radius;
133 + let colors = theme.colors;
134 + let caption = theme.typography.caption.size;
135 + let body = theme.typography.body.size;
136 + let pad_x = theme.metrics.nav.pad_x;
137 +
138 + for row in self.rows(rect, ctx.theme) {
139 + match &self.entries[row.entry] {
140 + NavEntry::Section(title) => {
141 + ctx.label_left(
142 + row.rect.x() + pad_x + 8.0,
143 + row.rect.center().y,
144 + title,
145 + caption,
146 + weight::BOLD,
147 + colors.dim_text,
148 + );
149 + }
150 + NavEntry::Item(label) => {
151 + let item = Rect::from_xywh(
152 + row.rect.x() + pad_x,
153 + row.rect.y(),
154 + (row.rect.width() - 2.0 * pad_x).max(0.0),
155 + row.rect.height(),
156 + );
157 + let selected = self.selected == Some(row.entry);
158 + if selected || self.hovered == Some(row.entry) {
159 + ctx.scene
160 + .fill_rounded_rect(item, radius.small, colors.shade);
161 + }
162 + let dot = Rect::from_xywh(item.x() + 8.0, item.center().y - 4.0, 8.0, 8.0);
163 + let dot_color = if selected {
164 + colors.accent
165 + } else {
166 + colors.dim_text
167 + };
168 + ctx.scene.fill_rounded_rect(dot, 4.0, dot_color);
169 +
170 + let fg = colors.text;
171 + let weight = if selected {
172 + weight::MEDIUM
173 + } else {
174 + weight::NORMAL
175 + };
176 + ctx.label_left(item.x() + 26.0, item.center().y, label, body, weight, fg);
177 + }
178 + }
179 + }
180 + }
181 +
182 + /// Ustawia podświetlenie na podstawie pozycji wskaźnika (wygodne w `PointerMotion`).
183 + pub fn hover_at(&mut self, rect: Rect, p: Option<Point>, theme: &Theme) {
184 + self.hovered = p.and_then(|p| self.index_at(rect, p, theme));
185 + }
186 +}
187 +
188 +#[cfg(test)]
189 +mod tests {
190 + use super::*;
191 +
192 + fn list() -> NavList {
193 + NavList::new(vec![
194 + NavEntry::Item("Overview".into()),
195 + NavEntry::Item("Account".into()),
196 + NavEntry::Section("Advanced".into()),
197 + NavEntry::Item("Network".into()),
198 + ])
199 + }
200 +
201 + fn area() -> Rect {
202 + Rect::from_xywh(0.0, 0.0, 200.0, 400.0)
203 + }
204 +
205 + #[test]
206 + fn rows_have_expected_heights() {
207 + let theme = Theme::light();
208 + let m = theme.metrics.nav;
209 + let l = list();
210 + let rows = l.rows(area(), &theme);
211 + assert_eq!(rows.len(), 4);
212 + assert_eq!(rows[0].rect.height(), m.item_h);
213 + assert!(!rows[2].selectable);
214 + assert_eq!(
215 + l.height(&theme),
216 + m.pad_v * 2.0 + 3.0 * m.item_h + m.section_h
217 + );
218 + }
219 +
220 + #[test]
221 + fn index_at_skips_sections() {
222 + let theme = Theme::light();
223 + let l = list();
224 + let rows = l.rows(area(), &theme);
225 + // Środek nagłówka sekcji → brak trafienia.
226 + let section_p = rows[2].rect.center();
227 + assert_eq!(l.index_at(area(), section_p, &theme), None);
228 + // Środek pierwszej pozycji → trafienie w indeks 0.
229 + assert_eq!(l.index_at(area(), rows[0].rect.center(), &theme), Some(0));
230 + }
231 +
232 + #[test]
233 + fn click_sets_selection_and_reports_change() {
234 + let theme = Theme::light();
235 + let mut l = list();
236 + let rows = l.rows(area(), &theme);
237 + assert!(l.click_at(area(), rows[3].rect.center(), &theme));
238 + assert_eq!(l.selected(), Some(3));
239 + assert_eq!(l.selected_label(), Some("Network"));
240 + assert!(!l.click_at(area(), rows[3].rect.center(), &theme));
241 + }
242 +
243 + #[test]
244 + fn cannot_select_a_section() {
245 + let mut l = list();
246 + l.set_selected(Some(2));
247 + assert_eq!(l.selected(), None);
248 + }
249 +}
0 250 new file mode 100644
@@ -0,0 +1,205 @@
1 +//! `Pager` — karuzela stron (przewijanie/„swipe” w poziomie z kropkami-wskaźnikami).
2 +//!
3 +//! Widget immediate: trzyma liczbę stron i bieżącą pozycję (ułamkową, w jednostkach stron).
4 +//! Zawartość stron rysuje aplikacja, wyliczając prostokąty przez [`Pager::page_rect`];
5 +//! sam widget rysuje tylko kropki-wskaźniki ([`Pager::paint`]), obsługuje ich klik
6 +//! ([`Pager::indicator_at`]) oraz przeciąganie ([`Pager::begin_drag`] →
7 +//! [`Pager::update_drag`] → [`Pager::end_drag`], które „przyciąga” do najbliższej strony).
8 +
9 +use libluna_core::{Insets, Point, Rect, Theme};
10 +
11 +use crate::ctx::PaintCtx;
12 +
13 +/// Karuzela stron.
14 +#[derive(Debug, Clone, Default)]
15 +pub struct Pager {
16 + count: usize,
17 + /// Bieżąca pozycja: 0..count-1 (ułamkowa podczas przeciągania).
18 + position: f64,
19 + /// Pozycja wskaźnika (x) na początku przeciągania.
20 + drag_from: Option<f64>,
21 + /// Pozycja karuzeli na początku przeciągania.
22 + drag_pos: f64,
23 +}
24 +
25 +impl Pager {
26 + pub fn new(count: usize) -> Self {
27 + Self {
28 + count,
29 + position: 0.0,
30 + drag_from: None,
31 + drag_pos: 0.0,
32 + }
33 + }
34 +
35 + pub fn count(&self) -> usize {
36 + self.count
37 + }
38 +
39 + pub fn position(&self) -> f64 {
40 + self.position
41 + }
42 +
43 + /// Ustawia pozycję (przycinaną do zakresu stron).
44 + pub fn set_position(&mut self, position: f64) {
45 + self.position = position.clamp(0.0, self.max_position());
46 + }
47 +
48 + /// Aktywna strona (najbliższa pozycji).
49 + pub fn active(&self) -> usize {
50 + self.position.round().clamp(0.0, self.max_position()) as usize
51 + }
52 +
53 + /// Ustawia aktywną stronę.
54 + pub fn set_active(&mut self, index: usize) {
55 + let max = self.max_position();
56 + self.position = (index as f64).clamp(0.0, max);
57 + }
58 +
59 + pub fn next(&mut self) {
60 + self.set_active(self.active() + 1);
61 + }
62 +
63 + pub fn prev(&mut self) {
64 + self.set_active(self.active().saturating_sub(1));
65 + }
66 +
67 + /// Prostokąt strony `i` przy bieżącej pozycji (strony sąsiadujące są poza obszarem).
68 + pub fn page_rect(&self, rect: Rect, i: usize) -> Rect {
69 + let x = rect.x() + (i as f64 - self.position) * rect.width();
70 + Rect::from_xywh(x, rect.y(), rect.width(), rect.height())
71 + }
72 +
73 + /// Prostokąty kropek-wskaźników (wyśrodkowane przy dolnej krawędzi).
74 + pub fn indicator_rects(&self, rect: Rect, theme: &Theme) -> Vec<Rect> {
75 + let m = theme.metrics.pager;
76 + let n = self.count;
77 + if n == 0 {
78 + return Vec::new();
79 + }
80 + let total = n as f64 * m.dot + (n as f64 - 1.0) * m.gap;
81 + let mut x = rect.center().x - total / 2.0;
82 + let y = rect.bottom() - m.pad - m.dot;
83 + let mut out = Vec::with_capacity(n);
84 + for _ in 0..n {
85 + out.push(Rect::from_xywh(x, y, m.dot, m.dot));
86 + x += m.dot + m.gap;
87 + }
88 + out
89 + }
90 +
91 + /// Indeks kropki pod punktem `p` (z tolerancją trafienia).
92 + pub fn indicator_at(&self, rect: Rect, theme: &Theme, p: Point) -> Option<usize> {
93 + self.indicator_rects(rect, theme).into_iter().position(|r| {
94 + r.inflate(Insets::splat(theme.metrics.pager.gap / 2.0))
95 + .contains(p)
96 + })
97 + }
98 +
99 + /// Klik w kropkę: ustawia stronę. Zwraca `true`, jeśli trafiono.
100 + pub fn click_indicator(&mut self, rect: Rect, theme: &Theme, p: Point) -> bool {
101 + match self.indicator_at(rect, theme, p) {
102 + Some(i) => {
103 + self.set_active(i);
104 + true
105 + }
106 + None => false,
107 + }
108 + }
109 +
110 + /// Rozpoczyna przeciąganie od pozycji wskaźnika `x`.
111 + pub fn begin_drag(&mut self, x: f64) {
112 + self.drag_from = Some(x);
113 + self.drag_pos = self.position;
114 + }
115 +
116 + /// Aktualizuje przeciągnięcie w `rect`.
117 + pub fn update_drag(&mut self, x: f64, rect: Rect) {
118 + if let Some(from) = self.drag_from {
119 + if rect.width() > 0.0 {
120 + self.set_position(self.drag_pos - (x - from) / rect.width());
121 + }
122 + }
123 + }
124 +
125 + /// Kończy przeciąganie, „przyciągając” do najbliższej strony. Zwraca aktywny indeks.
126 + pub fn end_drag(&mut self) -> usize {
127 + self.drag_from = None;
128 + let active = self.active();
129 + self.set_active(active);
130 + active
131 + }
132 +
133 + /// Rysuje kropki-wskaźniki.
134 + pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
135 + let theme = *ctx.theme;
136 + let active = self.active();
137 + for (i, r) in self.indicator_rects(rect, &theme).iter().enumerate() {
138 + let color = if i == active {
139 + ctx.theme.colors.accent
140 + } else {
141 + ctx.theme.colors.dim_text.scale_alpha(0.6)
142 + };
143 + ctx.scene.fill_rounded_rect(*r, r.width() / 2.0, color);
144 + }
145 + }
146 +
147 + fn max_position(&self) -> f64 {
148 + self.count.saturating_sub(1) as f64
149 + }
150 +}
151 +
152 +#[cfg(test)]
153 +mod tests {
154 + use super::*;
155 +
156 + fn rect() -> Rect {
157 + Rect::from_xywh(0.0, 0.0, 300.0, 200.0)
158 + }
159 +
160 + #[test]
161 + fn pages_are_offset_by_position() {
162 + let p = Pager::new(3);
163 + assert_eq!(p.page_rect(rect(), 0), rect());
164 + let second = p.page_rect(rect(), 1);
165 + assert!((second.x() - 300.0).abs() < 1e-9);
166 + let mut p = Pager::new(3);
167 + p.set_active(1);
168 + let first = p.page_rect(rect(), 0);
169 + assert!((first.x() + 300.0).abs() < 1e-9);
170 + }
171 +
172 + #[test]
173 + fn position_and_active_clamp() {
174 + let mut p = Pager::new(3);
175 + p.set_position(9.0);
176 + assert_eq!(p.active(), 2);
177 + p.set_active(0);
178 + assert_eq!(p.active(), 0);
179 + p.prev();
180 + assert_eq!(p.active(), 0); // nie schodzi poniżej zera
181 + p.next();
182 + assert_eq!(p.active(), 1);
183 + }
184 +
185 + #[test]
186 + fn indicator_hit_selects_page() {
187 + let theme = Theme::light();
188 + let mut p = Pager::new(3);
189 + let dots = p.indicator_rects(rect(), &theme);
190 + assert_eq!(dots.len(), 3);
191 + assert!(p.click_indicator(rect(), &theme, dots[2].center()));
192 + assert_eq!(p.active(), 2);
193 + }
194 +
195 + #[test]
196 + fn drag_snaps_to_nearest_page() {
197 + let mut p = Pager::new(3);
198 + p.begin_drag(0.0);
199 + // Przeciągnięcie w lewo o 1.4 szerokości → pozycja 1.4 → snap do 1.
200 + p.update_drag(-420.0, rect());
201 + assert!((p.position() - 1.4).abs() < 1e-9);
202 + assert_eq!(p.end_drag(), 1);
203 + assert!((p.position() - 1.0).abs() < 1e-9);
204 + }
205 +}
0 206 new file mode 100644
@@ -0,0 +1,303 @@
1 +//! `PathBar` — pasek ścieżki (okruszki): `Domowy › Dokumenty › Projekty ▾`.
2 +//!
3 +//! Widget immediate: trzyma listę elementów ścieżki (etykiety podaje aplikacja, już
4 +//! przetłumaczone). Aplikacja woła [`PathBar::paint`] oraz [`PathBar::index_at`] /
5 +//! [`PathBar::click_at`] i sama decyduje o nawigacji. Gdy ścieżka nie mieści się w szerokości,
6 +//! środkowe elementy zastępuje wielokropek `…` (zostaje początek i bieżący katalog).
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::icon;
14 +
15 +/// Rozpiętość separatora „›” i wskaźnika „▾”.
16 +const CHEVRON: f64 = 8.0;
17 +
18 +/// Pasek ścieżki.
19 +#[derive(Debug, Clone, Default)]
20 +pub struct PathBar {
21 + components: Vec<String>,
22 + hovered: Option<usize>,
23 +}
24 +
25 +impl PathBar {
26 + /// Tworzy pasek z listy etykiet (ostatnia to bieżący katalog).
27 + pub fn new<I, S>(components: I) -> Self
28 + where
29 + I: IntoIterator<Item = S>,
30 + S: Into<String>,
31 + {
32 + Self {
33 + components: components.into_iter().map(Into::into).collect(),
34 + hovered: None,
35 + }
36 + }
37 +
38 + pub fn components(&self) -> &[String] {
39 + &self.components
40 + }
41 +
42 + pub fn len(&self) -> usize {
43 + self.components.len()
44 + }
45 +
46 + pub fn is_empty(&self) -> bool {
47 + self.components.is_empty()
48 + }
49 +
50 + /// Dokłada element na końcu (np. po wejściu do katalogu).
51 + pub fn push(&mut self, label: impl Into<String>) {
52 + self.components.push(label.into());
53 + }
54 +
55 + /// Skraca ścieżkę do `len` elementów (nawigacja „w górę”).
56 + pub fn truncate(&mut self, len: usize) {
57 + self.components.truncate(len);
58 + if self.hovered.is_some_and(|h| h >= self.components.len()) {
59 + self.hovered = None;
60 + }
61 + }
62 +
63 + /// Indeks bieżącego katalogu (ostatni element) albo `None` dla pustego paska.
64 + pub fn active(&self) -> Option<usize> {
65 + self.components.len().checked_sub(1)
66 + }
67 +
68 + pub fn set_hovered(&mut self, hovered: Option<usize>) {
69 + self.hovered = hovered;
70 + }
71 +
72 + /// Wysokość paska (z tokenów motywu).
73 + pub fn height(&self, theme: &Theme) -> f64 {
74 + theme.metrics.pathbar.height
75 + }
76 +
77 + fn seg_width(&self, index: usize, text: &TextEngine, theme: &Theme) -> f64 {
78 + let m = theme.metrics.pathbar;
79 + let body = theme.typography.body.size;
80 + let mut w = text
81 + .measure(&self.components[index], body, weight::NORMAL)
82 + .width
83 + + 2.0 * m.pad_x;
84 + if Some(index) == self.active() {
85 + w += CHEVRON + m.gap; // miejsce na wskaźnik „▾”.
86 + }
87 + w
88 + }
89 +
90 + /// Prostokąty widocznych elementów; `None` oznacza wielokropek `…`.
91 + pub fn segments(
92 + &self,
93 + rect: Rect,
94 + text: &TextEngine,
95 + theme: &Theme,
96 + ) -> Vec<(Option<usize>, Rect)> {
97 + let m = theme.metrics.pathbar;
98 + let n = self.components.len();
99 + if n == 0 || rect.width() <= 0.0 {
100 + return Vec::new();
101 + }
102 + let sep = 2.0 * m.gap + CHEVRON;
103 + let total: f64 =
104 + (0..n).map(|i| self.seg_width(i, text, theme)).sum::<f64>() + (n as f64 - 1.0) * sep;
105 +
106 + let picks: Vec<Option<usize>> = if total <= rect.width() {
107 + (0..n).map(Some).collect()
108 + } else {
109 + // Zostaw początek i bieżący katalog; w środku wielokropek.
110 + let body = theme.typography.body.size;
111 + let ell = text.measure("…", body, weight::NORMAL).width + 2.0 * m.pad_x;
112 + let mut rev = vec![n - 1];
113 + let mut used = self.seg_width(n - 1, text, theme);
114 + for i in (0..n - 1).rev() {
115 + let w = self.seg_width(i, text, theme) + sep;
116 + if used + w + sep + ell <= rect.width() {
117 + used += w;
118 + rev.push(i);
119 + } else {
120 + break;
121 + }
122 + }
123 + rev.reverse();
124 + let mut v: Vec<Option<usize>> = vec![None];
125 + v.extend(rev.into_iter().map(Some));
126 + v
127 + };
128 +
129 + let mut x = rect.x();
130 + let mut out = Vec::with_capacity(picks.len());
131 + for (k, pick) in picks.iter().enumerate() {
132 + if k > 0 {
133 + x += sep;
134 + }
135 + let w = match pick {
136 + Some(i) => self.seg_width(*i, text, theme),
137 + None => {
138 + text.measure("…", theme.typography.body.size, weight::NORMAL)
139 + .width
140 + + 2.0 * m.pad_x
141 + }
142 + };
143 + out.push((*pick, Rect::from_xywh(x, rect.y(), w, rect.height())));
144 + x += w;
145 + }
146 + out
147 + }
148 +
149 + /// Indeks elementu pod punktem `p` (wielokropek i separatory zwracają `None`).
150 + pub fn index_at(
151 + &self,
152 + rect: Rect,
153 + text: &TextEngine,
154 + theme: &Theme,
155 + p: Point,
156 + ) -> Option<usize> {
157 + self.segments(rect, text, theme)
158 + .into_iter()
159 + .find(|(pick, r)| pick.is_some() && r.contains(p))
160 + .and_then(|(pick, _)| pick)
161 + }
162 +
163 + /// Obsługuje klik: zwraca indeks wybranego elementu (nawigację robi aplikacja).
164 + pub fn click_at(
165 + &self,
166 + rect: Rect,
167 + text: &TextEngine,
168 + theme: &Theme,
169 + p: Point,
170 + ) -> Option<usize> {
171 + self.index_at(rect, text, theme, p)
172 + }
173 +
174 + /// Rysuje pasek ścieżki w prostokącie `rect`.
175 + pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
176 + let theme = *ctx.theme;
177 + let m = theme.metrics.pathbar;
178 + let colors = theme.colors;
179 + let body = theme.typography.body.size;
180 + let active = self.active();
181 + let text = ctx.text;
182 + let segs = self.segments(rect, text, &theme);
183 +
184 + for (k, (pick, seg)) in segs.iter().enumerate() {
185 + if k > 0 {
186 + let cx = seg.x() - m.gap - CHEVRON / 2.0;
187 + icon::chevron_right(
188 + ctx,
189 + colors.dim_text,
190 + Point::new(cx, seg.center().y),
191 + 4.0,
192 + 1.6,
193 + );
194 + }
195 + match pick {
196 + Some(i) => {
197 + let is_active = Some(*i) == active;
198 + if self.hovered == Some(*i) && !is_active {
199 + ctx.scene
200 + .fill_rounded_rect(*seg, theme.radius.small, colors.shade);
201 + }
202 + let color = if is_active {
203 + colors.text
204 + } else {
205 + colors.dim_text
206 + };
207 + let weight = if is_active {
208 + weight::MEDIUM
209 + } else {
210 + weight::NORMAL
211 + };
212 + let shift = if is_active {
213 + (CHEVRON + m.gap) / 2.0
214 + } else {
215 + 0.0
216 + };
217 + ctx.label_center(
218 + seg.center().x - shift,
219 + seg.center().y,
220 + &self.components[*i],
221 + body,
222 + weight,
223 + color,
224 + );
225 + if is_active {
226 + icon::chevron_down(
227 + ctx,
228 + colors.dim_text,
229 + Point::new(seg.right() - m.pad_x - CHEVRON / 2.0, seg.center().y),
230 + 4.0,
231 + 1.6,
232 + );
233 + }
234 + }
235 + None => ctx.label_center(
236 + seg.center().x,
237 + seg.center().y,
238 + "…",
239 + body,
240 + weight::NORMAL,
241 + colors.dim_text,
242 + ),
243 + }
244 + }
245 + }
246 +}
247 +
248 +#[cfg(test)]
249 +mod tests {
250 + use super::*;
251 +
252 + fn rect() -> Rect {
253 + Rect::from_xywh(0.0, 0.0, 400.0, 34.0)
254 + }
255 +
256 + #[test]
257 + fn all_components_when_they_fit() {
258 + let text = TextEngine::system();
259 + let theme = Theme::light();
260 + let bar = PathBar::new(["Dom", "Dokumenty", "Projekty"]);
261 + let segs = bar.segments(rect(), &text, &theme);
262 + assert_eq!(segs.len(), 3);
263 + assert!(segs.iter().all(|(p, _)| p.is_some()));
264 + assert_eq!(bar.active(), Some(2));
265 + }
266 +
267 + #[test]
268 + fn index_at_maps_components() {
269 + let text = TextEngine::system();
270 + let theme = Theme::light();
271 + let bar = PathBar::new(["Dom", "Dokumenty", "Projekty"]);
272 + let segs = bar.segments(rect(), &text, &theme);
273 + assert_eq!(
274 + bar.index_at(rect(), &text, &theme, segs[0].1.center()),
275 + Some(0)
276 + );
277 + assert_eq!(
278 + bar.index_at(rect(), &text, &theme, segs[2].1.center()),
279 + Some(2)
280 + );
281 + }
282 +
283 + #[test]
284 + fn overflow_inserts_ellipsis_and_keeps_last() {
285 + let text = TextEngine::system();
286 + let theme = Theme::light();
287 + let narrow = Rect::from_xywh(0.0, 0.0, 150.0, 34.0);
288 + let bar = PathBar::new(["Katalog domowy", "Dokumenty", "Projekty", "libLuna"]);
289 + let segs = bar.segments(narrow, &text, &theme);
290 + // Pierwszy widoczny to wielokropek, ostatni to bieżący katalog.
291 + assert_eq!(segs[0].0, None);
292 + assert_eq!(segs.last().unwrap().0, Some(3));
293 + }
294 +
295 + #[test]
296 + fn empty_bar_has_no_segments() {
297 + let text = TextEngine::system();
298 + let theme = Theme::light();
299 + let bar = PathBar::default();
300 + assert!(bar.segments(rect(), &text, &theme).is_empty());
301 + assert_eq!(bar.active(), None);
302 + }
303 +}
0 304 new file mode 100644
@@ -0,0 +1,292 @@
1 +//! `SearchEntry` — pole tekstowe wyszukiwania (lupka, tekst, kursor, „×” czyszczenia).
2 +//!
3 +//! Widget immediate: trzyma treść i pozycję kursora. Aplikacja dostarcza zdarzenia klawiatury
4 +//! jako wywołania metod ([`SearchEntry::insert_str`], [`SearchEntry::backspace`],
5 +//! [`SearchEntry::move_left`]…), a sama decyduje o fokusie ([`SearchEntry::set_focused`]).
6 +
7 +use libluna_core::style::weight;
8 +use libluna_core::{Point, Rect, Theme};
9 +
10 +use crate::ctx::PaintCtx;
11 +use crate::icon;
12 +
13 +/// Pole wyszukiwania.
14 +#[derive(Debug, Clone, Default)]
15 +pub struct SearchEntry {
16 + text: String,
17 + /// Pozycja kursora jako indeks bajtowy (zawsze na granicy znaku).
18 + cursor: usize,
19 + placeholder: String,
20 + focused: bool,
21 + hover_clear: bool,
22 +}
23 +
24 +impl SearchEntry {
25 + pub fn new(placeholder: impl Into<String>) -> Self {
26 + Self {
27 + placeholder: placeholder.into(),
28 + ..Self::default()
29 + }
30 + }
31 +
32 + pub fn text(&self) -> &str {
33 + &self.text
34 + }
35 +
36 + pub fn is_empty(&self) -> bool {
37 + self.text.is_empty()
38 + }
39 +
40 + pub fn cursor(&self) -> usize {
41 + self.cursor
42 + }
43 +
44 + pub fn is_focused(&self) -> bool {
45 + self.focused
46 + }
47 +
48 + pub fn set_text(&mut self, text: impl Into<String>) {
49 + self.text = text.into();
50 + self.cursor = self.text.len();
51 + }
52 +
53 + pub fn set_focused(&mut self, focused: bool) {
54 + self.focused = focused;
55 + }
56 +
57 + pub fn set_placeholder(&mut self, placeholder: impl Into<String>) {
58 + self.placeholder = placeholder.into();
59 + }
60 +
61 + pub fn set_hover_clear(&mut self, hovered: bool) {
62 + self.hover_clear = hovered;
63 + }
64 +
65 + pub fn clear(&mut self) {
66 + self.text.clear();
67 + self.cursor = 0;
68 + }
69 +
70 + /// Wstawia tekst w miejscu kursora.
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 + /// Usuwa znak przed kursorem.
82 + pub fn backspace(&mut self) {
83 + if self.cursor > 0 {
84 + let prev = prev_boundary(&self.text, self.cursor);
85 + self.text.replace_range(prev..self.cursor, "");
86 + self.cursor = prev;
87 + }
88 + }
89 +
90 + /// Usuwa znak za kursorem.
91 + pub fn delete_forward(&mut self) {
92 + if self.cursor < self.text.len() {
93 + let next = next_boundary(&self.text, self.cursor);
94 + self.text.replace_range(self.cursor..next, "");
95 + }
96 + }
97 +
98 + pub fn move_left(&mut self) {
99 + self.cursor = prev_boundary(&self.text, self.cursor);
100 + }
101 +
102 + pub fn move_right(&mut self) {
103 + self.cursor = next_boundary(&self.text, self.cursor);
104 + }
105 +
106 + pub fn home(&mut self) {
107 + self.cursor = 0;
108 + }
109 +
110 + pub fn end(&mut self) {
111 + self.cursor = self.text.len();
112 + }
113 +
114 + pub fn height(&self, theme: &Theme) -> f64 {
115 + theme.metrics.entry.height
116 + }
117 +
118 + pub fn contains(&self, rect: Rect, p: Point) -> bool {
119 + rect.contains(p)
120 + }
121 +
122 + /// Prostokąt przycisku czyszczenia („×”).
123 + pub fn clear_rect(&self, rect: Rect, theme: &Theme) -> Rect {
124 + let m = theme.metrics.entry;
125 + Rect::from_xywh(
126 + rect.right() - m.pad_x - m.icon,
127 + rect.center().y - m.icon / 2.0,
128 + m.icon,
129 + m.icon,
130 + )
131 + }
132 +
133 + /// Czy punkt trafia w przycisk czyszczenia (tylko gdy pole niepuste).
134 + pub fn clear_at(&self, rect: Rect, theme: &Theme, p: Point) -> bool {
135 + !self.text.is_empty() && self.clear_rect(rect, theme).contains(p)
136 + }
137 +
138 + pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
139 + let theme = *ctx.theme;
140 + let m = theme.metrics.entry;
141 + let colors = theme.colors;
142 + let radius = theme.radius.medium;
143 +
144 + ctx.scene.fill_rounded_rect(rect, radius, colors.shade);
145 + let (border, width) = if self.focused {
146 + (colors.accent, 2.0)
147 + } else {
148 + (colors.border, 1.0)
149 + };
150 + ctx.scene.stroke_rounded_rect(rect, radius, width, border);
151 +
152 + let cy = rect.center().y;
153 + icon::search(
154 + ctx,
155 + colors.dim_text,
156 + Point::new(rect.x() + m.pad_x + m.icon / 2.0, cy),
157 + m.icon,
158 + );
159 + let text_x = rect.x() + m.pad_x + m.icon + 8.0;
160 +
161 + if self.text.is_empty() {
162 + ctx.label_left(
163 + text_x,
164 + cy,
165 + &self.placeholder,
166 + theme.typography.body.size,
167 + weight::NORMAL,
168 + colors.dim_text,
169 + );
170 + } else {
171 + let clear_space = if self.text.is_empty() {
172 + 0.0
173 + } else {
174 + m.icon + 8.0
175 + };
176 + let text_right = rect.right() - m.pad_x - clear_space;
177 + ctx.scene.push_clip(Rect::from_xywh(
178 + text_x,
179 + rect.y(),
180 + (text_right - text_x).max(0.0),
181 + rect.height(),
182 + ));
183 + ctx.label_left(
184 + text_x,
185 + cy,
186 + &self.text,
187 + theme.typography.body.size,
188 + weight::NORMAL,
189 + colors.text,
190 + );
191 + if self.focused {
192 + let cx = text_x
193 + + ctx.measure(
194 + &self.text[..self.cursor],
195 + theme.typography.body.size,
196 + weight::NORMAL,
197 + );
198 + ctx.scene.fill_rect(
199 + Rect::from_xywh(cx, rect.y() + 6.0, 1.5, (rect.height() - 12.0).max(0.0)),
200 + colors.text,
201 + );
202 + }
203 + ctx.scene.pop_clip();
204 + }
205 +
206 + if !self.text.is_empty() {
207 + let cb = self.clear_rect(rect, &theme);
208 + if self.hover_clear {
209 + ctx.scene
210 + .fill_rounded_rect(cb, cb.width() / 2.0, colors.shade.scale_alpha(1.8));
211 + }
212 + icon::close(ctx, colors.dim_text, cb, 4.0);
213 + }
214 + }
215 +}
216 +
217 +fn prev_boundary(s: &str, i: usize) -> usize {
218 + s[..i]
219 + .char_indices()
220 + .next_back()
221 + .map(|(j, _)| j)
222 + .unwrap_or(0)
223 +}
224 +
225 +fn next_boundary(s: &str, i: usize) -> usize {
226 + s[i..].chars().next().map(|c| i + c.len_utf8()).unwrap_or(i)
227 +}
228 +
229 +#[cfg(test)]
230 +mod tests {
231 + use super::*;
232 +
233 + #[test]
234 + fn typing_and_cursor() {
235 + let mut e = SearchEntry::new("Szukaj");
236 + e.insert_str("abc");
237 + assert_eq!(e.text(), "abc");
238 + assert_eq!(e.cursor(), 3);
239 + e.move_left();
240 + e.insert_char('X');
241 + assert_eq!(e.text(), "abXc");
242 + e.backspace();
243 + assert_eq!(e.text(), "abc");
244 + assert_eq!(e.cursor(), 2);
245 + e.delete_forward(); // usuwa 'c' za kursorem
246 + assert_eq!(e.text(), "ab");
247 + e.home();
248 + e.delete_forward(); // usuwa 'a'
249 + assert_eq!(e.text(), "b");
250 + }
251 +
252 + #[test]
253 + fn multibyte_is_char_aware() {
254 + let mut e = SearchEntry::new("");
255 + e.insert_str("ząb");
256 + assert_eq!(e.text(), "ząb");
257 + e.backspace();
258 + assert_eq!(e.text(), "zą");
259 + e.move_left();
260 + e.backspace();
261 + assert_eq!(e.text(), "ą");
262 + }
263 +
264 + #[test]
265 + fn clear_resets_state() {
266 + let mut e = SearchEntry::new("");
267 + e.insert_str("x");
268 + e.clear();
269 + assert!(e.is_empty());
270 + assert_eq!(e.cursor(), 0);
271 + }
272 +
273 + #[test]
274 + fn clear_hit_only_when_non_empty() {
275 + let theme = Theme::light();
276 + let rect = Rect::from_xywh(0.0, 0.0, 200.0, 34.0);
277 + let mut e = SearchEntry::new("");
278 + let c = e.clear_rect(rect, &theme).center();
279 + assert!(!e.clear_at(rect, &theme, c));
280 + e.insert_str("abc");
281 + assert!(e.clear_at(rect, &theme, c));
282 + }
283 +
284 + #[test]
285 + fn height_from_metrics() {
286 + let theme = Theme::light();
287 + assert_eq!(
288 + SearchEntry::new("").height(&theme),
289 + theme.metrics.entry.height
290 + );
291 + }
292 +}
0 293 new file mode 100644
@@ -0,0 +1,91 @@
1 +//! `Slider` — suwak (zakres 0..1). Przeciąganie obsługuje aplikacja, wołając
2 +//! [`Slider::value_at`] na zdarzeniach ruchu; widget odpowiada za wygląd i geometrię.
3 +
4 +use libluna_core::{Color, Insets, Point, Rect, Theme};
5 +
6 +use crate::ctx::PaintCtx;
7 +
8 +/// Suwak; `rect` oznacza tor (cienki pasek), a uchwyt rysowany jest ponad niego.
9 +#[derive(Debug, Clone, Copy, Default)]
10 +pub struct Slider {
11 + value: f64,
12 +}
13 +
14 +impl Slider {
15 + pub fn new(value: f64) -> Self {
16 + Self {
17 + value: value.clamp(0.0, 1.0),
18 + }
19 + }
20 +
21 + pub fn value(&self) -> f64 {
22 + self.value
23 + }
24 +
25 + pub fn set_value(&mut self, value: f64) {
26 + self.value = value.clamp(0.0, 1.0);
27 + }
28 +
29 + /// Wartość dla pozycji wskaźnika (0..1) względem toru.
30 + pub fn value_at(&self, track: Rect, p: Point) -> f64 {
31 + if track.width() <= 0.0 {
32 + return 0.0;
33 + }
34 + ((p.x - track.x()) / track.width()).clamp(0.0, 1.0)
35 + }
36 +
37 + /// Czy punkt trafia w obszar suwaka (z tolerancją z metryk motywu).
38 + pub fn hit(&self, track: Rect, p: Point, theme: &Theme) -> bool {
39 + let m = theme.metrics.slider;
40 + let area = track.inflate(Insets::new(m.hit_pad, m.knob_r, m.hit_pad, m.knob_r));
41 + area.contains(p)
42 + }
43 +
44 + pub fn paint(&self, ctx: &mut PaintCtx, track: Rect) {
45 + let theme = *ctx.theme;
46 + let colors = theme.colors;
47 + let knob_r = theme.metrics.slider.knob_r;
48 + ctx.scene
49 + .fill_rounded_rect(track, track.height() / 2.0, colors.shade);
50 +
51 + let filled_w = track.width() * self.value;
52 + ctx.scene.fill_rounded_rect(
53 + Rect::from_xywh(track.x(), track.y(), filled_w, track.height()),
54 + track.height() / 2.0,
55 + colors.accent,
56 + );
57 +
58 + let cy = track.center().y;
59 + let knob_cx = track.x() + filled_w;
60 + let knob = Rect::from_xywh(knob_cx - knob_r, cy - knob_r, knob_r * 2.0, knob_r * 2.0);
61 + ctx.scene.fill_rounded_rect(knob, knob_r, Color::WHITE);
62 + ctx.scene
63 + .stroke_rounded_rect(knob, knob_r, 1.0, colors.border);
64 + }
65 +}
66 +
67 +#[cfg(test)]
68 +mod tests {
69 + use super::*;
70 +
71 + fn track() -> Rect {
72 + Rect::from_xywh(100.0, 50.0, 200.0, 6.0)
73 + }
74 +
75 + #[test]
76 + fn value_at_maps_and_clamps() {
77 + let s = Slider::new(0.0);
78 + assert!((s.value_at(track(), Point::new(100.0, 53.0)) - 0.0).abs() < 1e-9);
79 + assert!((s.value_at(track(), Point::new(200.0, 53.0)) - 0.5).abs() < 1e-9);
80 + assert!((s.value_at(track(), Point::new(500.0, 53.0)) - 1.0).abs() < 1e-9);
81 + }
82 +
83 + #[test]
84 + fn hit_has_vertical_tolerance() {
85 + let theme = Theme::light();
86 + let s = Slider::new(0.5);
87 + assert!(s.hit(track(), Point::new(150.0, 53.0), &theme));
88 + assert!(s.hit(track(), Point::new(150.0, 40.0), &theme)); // nad torem, w tolerancji
89 + assert!(!s.hit(track(), Point::new(50.0, 53.0), &theme)); // poza torem w poziomie
90 + }
91 +}
0 92 new file mode 100644
@@ -0,0 +1,95 @@
1 +//! `Switch` — przełącznik (włącz/wyłącz).
2 +
3 +use libluna_core::{Color, Point, Rect, Theme};
4 +
5 +use crate::ctx::PaintCtx;
6 +
7 +/// Przełącznik. Widget immediate: trzyma stan „wł.” i podświetlenie.
8 +#[derive(Debug, Clone, Copy, Default)]
9 +pub struct Switch {
10 + on: bool,
11 + hovered: bool,
12 +}
13 +
14 +impl Switch {
15 + pub fn new(on: bool) -> Self {
16 + Self { on, hovered: false }
17 + }
18 +
19 + pub fn is_on(&self) -> bool {
20 + self.on
21 + }
22 +
23 + pub fn set_on(&mut self, on: bool) {
24 + self.on = on;
25 + }
26 +
27 + pub fn set_hovered(&mut self, hovered: bool) {
28 + self.hovered = hovered;
29 + }
30 +
31 + pub fn size(&self, theme: &Theme) -> (f64, f64) {
32 + (theme.metrics.switch.width, theme.metrics.switch.height)
33 + }
34 +
35 + pub fn hit(&self, rect: Rect, p: Point) -> bool {
36 + rect.contains(p)
37 + }
38 +
39 + /// Zwraca `true`, jeśli klik trafił w przełącznik (zmianę stanu robi aplikacja).
40 + pub fn click_at(&self, rect: Rect, p: Point) -> bool {
41 + rect.contains(p)
42 + }
43 +
44 + pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
45 + let theme = *ctx.theme;
46 + let colors = theme.colors;
47 + let bg = if self.on {
48 + colors.accent
49 + } else if self.hovered {
50 + colors.shade.scale_alpha(1.8)
51 + } else {
52 + colors.shade
53 + };
54 + ctx.scene.fill_rounded_rect(rect, rect.height() / 2.0, bg);
55 +
56 + // Promień i wcięcie jako proporcje wysokości, więc geometria skaluje się z metrykami.
57 + let knob_r = rect.height() * 0.375;
58 + let inset = rect.height() * 0.125;
59 + let cy = rect.center().y;
60 + let knob_x = if self.on {
61 + rect.right() - inset - knob_r
62 + } else {
63 + rect.x() + inset + knob_r
64 + };
65 + ctx.scene.fill_rounded_rect(
66 + Rect::from_xywh(knob_x - knob_r, cy - knob_r, knob_r * 2.0, knob_r * 2.0),
67 + knob_r,
68 + Color::WHITE,
69 + );
70 + }
71 +}
72 +
73 +#[cfg(test)]
74 +mod tests {
75 + use super::*;
76 +
77 + #[test]
78 + fn hit_test() {
79 + let theme = Theme::light();
80 + let (w, h) = Switch::new(true).size(&theme);
81 + let rect = Rect::from_xywh(0.0, 0.0, w, h);
82 + let s = Switch::new(true);
83 + assert!(s.click_at(rect, Point::new(10.0, 10.0)));
84 + assert!(!s.click_at(rect, Point::new(100.0, 10.0)));
85 + assert!(s.is_on());
86 + }
87 +
88 + #[test]
89 + fn metrics_drive_size() {
90 + let mut theme = Theme::light();
91 + theme.metrics.switch.width = 60.0;
92 + theme.metrics.switch.height = 30.0;
93 + assert_eq!(Switch::new(false).size(&theme), (60.0, 30.0));
94 + }
95 +}
0 96 new file mode 100644
@@ -0,0 +1,339 @@
1 +//! `TabBar` — pasek zakładek (np. w HeaderBarze).
2 +//!
3 +//! Widget immediate: trzyma zakładki, bieżący wybór i podświetlenie. Aplikacja wywołuje
4 +//! [`TabBar::paint`] oraz [`TabBar::index_at`] / [`TabBar::click_at`]. Szerokości zakładek
5 +//! wynikają z tokenów motywu ([`Metrics::tabs`](luna_core::TabsMetrics)) — w trybie
6 +//! `expand` są równe, a bez niego dopasowane do etykiety.
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::icon;
14 +
15 +/// Pojedyncza zakładka.
16 +#[derive(Debug, Clone, PartialEq)]
17 +pub struct Tab {
18 + /// Etykieta (już przetłumaczona) — albo klucz, zależnie od aplikacji.
19 + pub title: String,
20 + /// Czy pokazywać przycisk zamknięcia.
21 + pub closable: bool,
22 +}
23 +
24 +impl Tab {
25 + pub fn new(title: impl Into<String>) -> Self {
26 + Self {
27 + title: title.into(),
28 + closable: false,
29 + }
30 + }
31 +
32 + pub fn closable(mut self, closable: bool) -> Self {
33 + self.closable = closable;
34 + self
35 + }
36 +}
37 +
38 +/// Pasek zakładek.
39 +#[derive(Debug, Clone, Default)]
40 +pub struct TabBar {
41 + tabs: Vec<Tab>,
42 + selected: usize,
43 + hovered: Option<usize>,
44 + expand: bool,
45 +}
46 +
47 +impl TabBar {
48 + /// Tworzy pasek z listą etykiet (pierwsza zaznaczona, bez przycisków zamknięcia).
49 + pub fn new<I, S>(titles: I) -> Self
50 + where
51 + I: IntoIterator<Item = S>,
52 + S: Into<String>,
53 + {
54 + Self {
55 + tabs: titles.into_iter().map(Tab::new).collect(),
56 + selected: 0,
57 + hovered: None,
58 + expand: true,
59 + }
60 + }
61 +
62 + /// Tworzy pasek z gotowych zakładek.
63 + pub fn with_tabs(tabs: Vec<Tab>) -> Self {
64 + Self {
65 + tabs,
66 + selected: 0,
67 + hovered: None,
68 + expand: true,
69 + }
70 + }
71 +
72 + /// Czy zakładki rozciągają się równo na całą szerokość (domyślnie tak).
73 + pub fn expand(mut self, expand: bool) -> Self {
74 + self.expand = expand;
75 + self
76 + }
77 +
78 + pub fn tabs(&self) -> &[Tab] {
79 + &self.tabs
80 + }
81 +
82 + pub fn len(&self) -> usize {
83 + self.tabs.len()
84 + }
85 +
86 + pub fn is_empty(&self) -> bool {
87 + self.tabs.is_empty()
88 + }
89 +
90 + pub fn selected(&self) -> usize {
91 + self.selected
92 + }
93 +
94 + pub fn selected_title(&self) -> Option<&str> {
95 + self.tabs.get(self.selected).map(|t| t.title.as_str())
96 + }
97 +
98 + /// Ustawia wybór (poza zakresem jest przycinany).
99 + pub fn set_selected(&mut self, index: usize) {
100 + if !self.tabs.is_empty() {
101 + self.selected = index.min(self.tabs.len() - 1);
102 + }
103 + }
104 +
105 + pub fn set_hovered(&mut self, hovered: Option<usize>) {
106 + self.hovered = hovered;
107 + }
108 +
109 + /// Wysokość paska (z tokenów motywu).
110 + pub fn height(&self, theme: &Theme) -> f64 {
111 + theme.metrics.tabs.height
112 + }
113 +
114 + /// Prostokąty kolejnych zakładek (wyrównane do środka paska).
115 + pub fn segments(&self, rect: Rect, text: &TextEngine, theme: &Theme) -> Vec<Rect> {
116 + let m = theme.metrics.tabs;
117 + let n = self.tabs.len();
118 + if n == 0 || rect.width() <= 0.0 {
119 + return Vec::new();
120 + }
121 + let gaps = m.gap * (n as f64 - 1.0);
122 + let avail = (rect.width() - gaps).max(0.0);
123 +
124 + let widths: Vec<f64> = if self.expand {
125 + vec![(avail / n as f64).clamp(m.min_width, m.max_width); n]
126 + } else {
127 + self.tabs
128 + .iter()
129 + .map(|t| {
130 + let mut w = text
131 + .measure(&t.title, theme.typography.body.size, weight::NORMAL)
132 + .width
133 + + 2.0 * m.pad_x;
134 + if t.closable {
135 + w += m.close + m.pad_x / 2.0;
136 + }
137 + w.clamp(m.min_width, m.max_width)
138 + })
139 + .collect()
140 + };
141 +
142 + let total: f64 = widths.iter().sum::<f64>() + gaps;
143 + let mut x = rect.x() + ((rect.width() - total) / 2.0).max(0.0);
144 + let mut out = Vec::with_capacity(n);
145 + for w in widths {
146 + out.push(Rect::from_xywh(x, rect.y(), w, rect.height()));
147 + x += w + m.gap;
148 + }
149 + out
150 + }
151 +
152 + /// Indeks zakładki pod punktem `p` (lub `None`).
153 + pub fn index_at(
154 + &self,
155 + rect: Rect,
156 + text: &TextEngine,
157 + theme: &Theme,
158 + p: Point,
159 + ) -> Option<usize> {
160 + self.segments(rect, text, theme)
161 + .into_iter()
162 + .position(|r| r.contains(p))
163 + }
164 +
165 + /// Indeks zakładki, której przycisk zamknięcia trafiono (tylko zakładki `closable`).
166 + pub fn close_at(
167 + &self,
168 + rect: Rect,
169 + text: &TextEngine,
170 + theme: &Theme,
171 + p: Point,
172 + ) -> Option<usize> {
173 + let m = theme.metrics.tabs;
174 + for (i, seg) in self.segments(rect, text, theme).into_iter().enumerate() {
175 + if !self.tabs[i].closable {
176 + continue;
177 + }
178 + let c = close_center(seg, m.close, m.pad_x);
179 + let box_ = Rect::from_xywh(c.x - m.close / 2.0, c.y - m.close / 2.0, m.close, m.close);
180 + if box_.contains(p) {
181 + return Some(i);
182 + }
183 + }
184 + None
185 + }
186 +
187 + /// Obsługuje klik: ustawia wybór. Zwraca `true`, jeśli wybór się zmienił.
188 + pub fn click_at(&mut self, rect: Rect, text: &TextEngine, theme: &Theme, p: Point) -> bool {
189 + match self.index_at(rect, text, theme, p) {
190 + Some(i) if i != self.selected => {
191 + self.selected = i;
192 + true
193 + }
194 + _ => false,
195 + }
196 + }
197 +
198 + /// Rysuje pasek zakładek w prostokącie `rect`.
199 + pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
200 + let theme = *ctx.theme;
201 + let colors = theme.colors;
202 + let radius = theme.radius.small;
203 + let size = theme.typography.body.size;
204 + let m = theme.metrics.tabs;
205 + let text = ctx.text;
206 +
207 + for (i, seg) in self.segments(rect, text, &theme).iter().enumerate() {
208 + let tab = &self.tabs[i];
209 + let selected = i == self.selected;
210 + let hovered = self.hovered == Some(i) && !selected;
211 +
212 + if selected {
213 + ctx.scene
214 + .fill_rounded_rect(*seg, radius, colors.shade.scale_alpha(1.8));
215 + } else if hovered {
216 + ctx.scene.fill_rounded_rect(*seg, radius, colors.shade);
217 + }
218 +
219 + let fg = if selected {
220 + colors.text
221 + } else {
222 + colors.dim_text
223 + };
224 + let weight = if selected {
225 + weight::MEDIUM
226 + } else {
227 + weight::NORMAL
228 + };
229 +
230 + // Etykieta: wyśrodkowana, z miejscem na przycisk zamknięcia gdy jest widoczny.
231 + let close_shown = tab.closable && (selected || hovered);
232 + let offset = if tab.closable { m.close / 2.0 } else { 0.0 };
233 + let cx = seg.center().x - offset;
234 + ctx.label_center(cx, seg.center().y, &tab.title, size, weight, fg);
235 +
236 + if close_shown {
237 + let c = close_center(*seg, m.close, m.pad_x);
238 + icon::close(
239 + ctx,
240 + colors.dim_text,
241 + Rect::from_xywh(c.x - m.close / 2.0, c.y - m.close / 2.0, m.close, m.close),
242 + m.close * 0.28,
243 + );
244 + }
245 + }
246 + }
247 +}
248 +
249 +/// Środek przycisku zamknięcia w zakładce (wspólny dla rysowania i trafień).
250 +fn close_center(seg: Rect, close: f64, pad_x: f64) -> Point {
251 + Point::new(seg.right() - pad_x / 2.0 - close / 2.0, seg.center().y)
252 +}
253 +
254 +#[cfg(test)]
255 +mod tests {
256 + use super::*;
257 +
258 + fn rect() -> Rect {
259 + Rect::from_xywh(0.0, 0.0, 420.0, 34.0)
260 + }
261 +
262 + fn theme() -> Theme {
263 + Theme::light()
264 + }
265 +
266 + #[test]
267 + fn expand_partitions_and_centers() {
268 + let text = TextEngine::system();
269 + let bar = TabBar::new(["A", "B", "C"]);
270 + let segs = bar.segments(rect(), &text, &theme());
271 + assert_eq!(segs.len(), 3);
272 + // Równe szerokości, bez wychodzenia poza obszar.
273 + assert!((segs[0].width() - segs[1].width()).abs() < 1e-9);
274 + assert!(segs[2].right() <= rect().right() + 1e-9);
275 + }
276 +
277 + #[test]
278 + fn index_at_maps_points() {
279 + let text = TextEngine::system();
280 + let bar = TabBar::new(["A", "B", "C"]);
281 + let r = rect();
282 + let segs = bar.segments(r, &text, &theme());
283 + assert_eq!(bar.index_at(r, &text, &theme(), segs[0].center()), Some(0));
284 + assert_eq!(bar.index_at(r, &text, &theme(), segs[2].center()), Some(2));
285 + assert_eq!(
286 + bar.index_at(r, &text, &theme(), Point::new(10.0, 100.0)),
287 + None
288 + );
289 + }
290 +
291 + #[test]
292 + fn click_changes_selection_once() {
293 + let text = TextEngine::system();
294 + let mut bar = TabBar::new(["A", "B", "C"]);
295 + let r = rect();
296 + let segs = bar.segments(r, &text, &theme());
297 + assert!(bar.click_at(r, &text, &theme(), segs[1].center()));
298 + assert_eq!(bar.selected(), 1);
299 + assert!(!bar.click_at(r, &text, &theme(), segs[1].center()));
300 + }
301 +
302 + #[test]
303 + fn close_hit_only_for_closable_tabs() {
304 + let text = TextEngine::system();
305 + let theme = theme();
306 + let r = rect();
307 + let plain = TabBar::new(["A", "B"]);
308 + let seg0 = plain.segments(r, &text, &theme)[0];
309 + // Bez `closable` brak trafień w „×”.
310 + let c = close_center(seg0, theme.metrics.tabs.close, theme.metrics.tabs.pad_x);
311 + assert_eq!(plain.close_at(r, &text, &theme, c), None);
312 +
313 + let closable = TabBar::with_tabs(vec![Tab::new("A").closable(true), Tab::new("B")]);
314 + let seg0 = closable.segments(r, &text, &theme)[0];
315 + let c = close_center(seg0, theme.metrics.tabs.close, theme.metrics.tabs.pad_x);
316 + assert_eq!(closable.close_at(r, &text, &theme, c), Some(0));
317 + }
318 +
319 + #[test]
320 + fn set_selected_clamps() {
321 + let mut bar = TabBar::new(["A", "B"]);
322 + bar.set_selected(9);
323 + assert_eq!(bar.selected(), 1);
324 + assert_eq!(bar.selected_title(), Some("B"));
325 + }
326 +
327 + #[test]
328 + fn natural_width_clamps_to_bounds() {
329 + let text = TextEngine::system();
330 + let theme = theme();
331 + let m = theme.metrics.tabs;
332 + let bar = TabBar::new(["bardzo długa etykieta zakładki", "B"]).expand(false);
333 + let segs = bar.segments(rect(), &text, &theme);
334 + for s in &segs {
335 + assert!(s.width() >= m.min_width - 1e-9);
336 + assert!(s.width() <= m.max_width + 1e-9);
337 + }
338 + }
339 +}
0 340 new file mode 100644
@@ -0,0 +1,175 @@
1 +//! `TabView` — model stron zakładek: dodawanie, zamykanie i wybór aktywnej.
2 +//!
3 +//! Sam nic nie rysuje — zawartość aktywnej strony rysuje aplikacja. Do paska zakładek
4 +//! służy [`TabView::tab_bar`], który zwraca gotowy [`TabBar`] z bieżącym wyborem.
5 +
6 +use crate::tab_bar::{Tab, TabBar};
7 +
8 +/// Zbiór stron (zakładek) z aktywną stroną.
9 +#[derive(Debug, Clone, Default)]
10 +pub struct TabView {
11 + tabs: Vec<Tab>,
12 + selected: usize,
13 +}
14 +
15 +impl TabView {
16 + pub fn new() -> Self {
17 + Self::default()
18 + }
19 +
20 + pub fn tabs(&self) -> &[Tab] {
21 + &self.tabs
22 + }
23 +
24 + pub fn len(&self) -> usize {
25 + self.tabs.len()
26 + }
27 +
28 + pub fn is_empty(&self) -> bool {
29 + self.tabs.is_empty()
30 + }
31 +
32 + pub fn selected(&self) -> usize {
33 + self.selected
34 + }
35 +
36 + pub fn selected_tab(&self) -> Option<&Tab> {
37 + self.tabs.get(self.selected)
38 + }
39 +
40 + pub fn selected_title(&self) -> Option<&str> {
41 + self.selected_tab().map(|t| t.title.as_str())
42 + }
43 +
44 + /// Dodaje zakładkę i zwraca jej indeks (pierwsza staje się aktywna).
45 + pub fn add(&mut self, tab: Tab) -> usize {
46 + self.tabs.push(tab);
47 + self.tabs.len() - 1
48 + }
49 +
50 + /// Dodaje zakładkę z etykietą.
51 + pub fn add_titled(&mut self, title: impl Into<String>) -> usize {
52 + self.add(Tab::new(title))
53 + }
54 +
55 + /// Wybiera zakładkę (poza zakresem jest przycinana).
56 + pub fn select(&mut self, index: usize) {
57 + if !self.tabs.is_empty() {
58 + self.selected = index.min(self.tabs.len() - 1);
59 + }
60 + }
61 +
62 + /// Zakładka następna (z zawijaniem).
63 + pub fn select_next(&mut self) {
64 + if !self.tabs.is_empty() {
65 + self.selected = (self.selected + 1) % self.tabs.len();
66 + }
67 + }
68 +
69 + /// Zakładka poprzednia (z zawijaniem).
70 + pub fn select_prev(&mut self) {
71 + if !self.tabs.is_empty() {
72 + self.selected = (self.selected + self.tabs.len() - 1) % self.tabs.len();
73 + }
74 + }
75 +
76 + /// Zamyka zakładkę; pilnuje, żeby wybór pozostał poprawny. Zwraca `true`, jeśli zamknięto.
77 + pub fn close(&mut self, index: usize) -> bool {
78 + if index >= self.tabs.len() {
79 + return false;
80 + }
81 + self.tabs.remove(index);
82 + if self.tabs.is_empty() {
83 + self.selected = 0;
84 + } else if index < self.selected {
85 + self.selected -= 1;
86 + } else if index == self.selected {
87 + self.selected = self.selected.min(self.tabs.len() - 1);
88 + }
89 + true
90 + }
91 +
92 + /// Pasek zakładek do narysowania (z bieżącym wyborem).
93 + pub fn tab_bar(&self) -> TabBar {
94 + let mut bar = TabBar::with_tabs(self.tabs.clone());
95 + bar.set_selected(self.selected);
96 + bar
97 + }
98 +}
99 +
100 +#[cfg(test)]
101 +mod tests {
102 + use super::*;
103 +
104 + #[test]
105 + fn add_and_select() {
106 + let mut v = TabView::new();
107 + assert_eq!(v.add_titled("A"), 0);
108 + assert_eq!(v.add_titled("B"), 1);
109 + assert_eq!(v.selected(), 0);
110 + v.select(1);
111 + assert_eq!(v.selected_title(), Some("B"));
112 + v.select(99);
113 + assert_eq!(v.selected(), 1); // przycięte
114 + }
115 +
116 + #[test]
117 + fn next_prev_wrap() {
118 + let mut v = TabView::new();
119 + v.add_titled("A");
120 + v.add_titled("B");
121 + v.add_titled("C");
122 + v.select(2);
123 + v.select_next();
124 + assert_eq!(v.selected(), 0);
125 + v.select_prev();
126 + assert_eq!(v.selected(), 2);
127 + }
128 +
129 + #[test]
130 + fn close_before_selection_shifts_it() {
131 + let mut v = TabView::new();
132 + v.add_titled("A");
133 + v.add_titled("B");
134 + v.add_titled("C");
135 + v.select(2);
136 + assert!(v.close(0));
137 + assert_eq!(v.selected(), 1);
138 + assert_eq!(v.selected_title(), Some("C"));
139 + }
140 +
141 + #[test]
142 + fn close_selected_picks_neighbour() {
143 + let mut v = TabView::new();
144 + v.add_titled("A");
145 + v.add_titled("B");
146 + v.add_titled("C");
147 + v.select(2); // ostatnia
148 + v.close(2);
149 + assert_eq!(v.selected(), 1); // cofnięcie do sąsiada
150 + assert_eq!(v.selected_title(), Some("B"));
151 + }
152 +
153 + #[test]
154 + fn close_last_empties() {
155 + let mut v = TabView::new();
156 + v.add_titled("A");
157 + assert!(v.close(0));
158 + assert!(v.is_empty());
159 + assert_eq!(v.selected(), 0);
160 + assert_eq!(v.selected_title(), None);
161 + assert!(!v.close(0)); // już pusto
162 + }
163 +
164 + #[test]
165 + fn tab_bar_reflects_state() {
166 + let mut v = TabView::new();
167 + v.add(Tab::new("A").closable(true));
168 + v.add_titled("B");
169 + v.select(1);
170 + let bar = v.tab_bar();
171 + assert_eq!(bar.len(), 2);
172 + assert_eq!(bar.selected(), 1);
173 + assert!(bar.tabs()[0].closable);
174 + }
175 +}
0 176 new file mode 100644
@@ -0,0 +1,172 @@
1 +//! `ViewSwitcher` — segmentowy przełącznik (np. `Lista | Siatka`, `Jasny | Ciemny | Auto`).
2 +//!
3 +//! Widget immediate: trzyma etykiety i bieżący wybór. Aplikacja wywołuje [`ViewSwitcher::paint`]
4 +//! oraz [`ViewSwitcher::click_at`] / [`ViewSwitcher::index_at`].
5 +
6 +use libluna_core::style::weight;
7 +use libluna_core::{Insets, Point, Rect};
8 +
9 +use crate::ctx::PaintCtx;
10 +
11 +/// Segmentowy przełącznik widoku.
12 +#[derive(Debug, Clone)]
13 +pub struct ViewSwitcher {
14 + labels: Vec<String>,
15 + selected: usize,
16 + hovered: Option<usize>,
17 +}
18 +
19 +impl ViewSwitcher {
20 + /// Tworzy przełącznik z listą etykiet (pierwsza zaznaczona).
21 + pub fn new<I, S>(labels: I) -> Self
22 + where
23 + I: IntoIterator<Item = S>,
24 + S: Into<String>,
25 + {
26 + Self {
27 + labels: labels.into_iter().map(Into::into).collect(),
28 + selected: 0,
29 + hovered: None,
30 + }
31 + }
32 +
33 + pub fn labels(&self) -> &[String] {
34 + &self.labels
35 + }
36 +
37 + pub fn count(&self) -> usize {
38 + self.labels.len()
39 + }
40 +
41 + pub fn selected(&self) -> usize {
42 + self.selected
43 + }
44 +
45 + pub fn selected_label(&self) -> Option<&str> {
46 + self.labels.get(self.selected).map(String::as_str)
47 + }
48 +
49 + /// Ustawia wybór (poza zakresem jest przycinany).
50 + pub fn set_selected(&mut self, index: usize) {
51 + if !self.labels.is_empty() {
52 + self.selected = index.min(self.labels.len() - 1);
53 + }
54 + }
55 +
56 + /// Ustawia podświetlenie (np. z pozycji wskaźnika).
57 + pub fn set_hovered(&mut self, hovered: Option<usize>) {
58 + self.hovered = hovered;
59 + }
60 +
61 + /// Prostokąty kolejnych segmentów (równy podział szerokości `rect`).
62 + pub fn segments(&self, rect: Rect) -> Vec<Rect> {
63 + let n = self.labels.len();
64 + if n == 0 || rect.width() <= 0.0 {
65 + return Vec::new();
66 + }
67 + let w = rect.width() / n as f64;
68 + (0..n)
69 + .map(|i| Rect::from_xywh(rect.x() + i as f64 * w, rect.y(), w, rect.height()))
70 + .collect()
71 + }
72 +
73 + /// Indeks segmentu pod punktem `p` (lub `None`).
74 + pub fn index_at(&self, rect: Rect, p: Point) -> Option<usize> {
75 + if !rect.contains(p) {
76 + return None;
77 + }
78 + let n = self.labels.len();
79 + if n == 0 {
80 + return None;
81 + }
82 + let rel = (p.x - rect.x()) / rect.width();
83 + Some(((rel * n as f64) as usize).min(n - 1))
84 + }
85 +
86 + /// Obsługuje klik: ustawia wybór. Zwraca `true`, jeśli wybór się zmienił.
87 + pub fn click_at(&mut self, rect: Rect, p: Point) -> bool {
88 + match self.index_at(rect, p) {
89 + Some(i) if i != self.selected => {
90 + self.selected = i;
91 + true
92 + }
93 + _ => false,
94 + }
95 + }
96 +
97 + /// Rysuje przełącznik w prostokącie `rect`.
98 + pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
99 + let theme = ctx.theme;
100 + let radius = theme.radius;
101 + let colors = theme.colors;
102 + let size = theme.typography.body.size;
103 +
104 + // Tło-segment „szyny”.
105 + let track = rect.inflate(Insets::splat(theme.metrics.segmented.track_pad));
106 + ctx.scene
107 + .fill_rounded_rect(track, radius.medium, colors.shade);
108 +
109 + for (i, seg) in self.segments(rect).iter().enumerate() {
110 + let selected = i == self.selected;
111 + if selected {
112 + ctx.scene
113 + .fill_rounded_rect(*seg, radius.small, colors.accent);
114 + } else if self.hovered == Some(i) {
115 + ctx.scene
116 + .fill_rounded_rect(*seg, radius.small, colors.shade);
117 + }
118 + let fg = if selected {
119 + colors.accent_fg
120 + } else {
121 + colors.text
122 + };
123 + let label = &self.labels[i];
124 + ctx.label_center(
125 + seg.center().x,
126 + seg.center().y,
127 + label,
128 + size,
129 + weight::MEDIUM,
130 + fg,
131 + );
132 + }
133 + }
134 +}
135 +
136 +#[cfg(test)]
137 +mod tests {
138 + use super::*;
139 +
140 + fn rect() -> Rect {
141 + Rect::from_xywh(0.0, 0.0, 240.0, 32.0)
142 + }
143 +
144 + #[test]
145 + fn segments_partition_width() {
146 + let vs = ViewSwitcher::new(["A", "B", "C"]);
147 + let segs = vs.segments(rect());
148 + assert_eq!(segs.len(), 3);
149 + assert_eq!(segs[0], Rect::from_xywh(0.0, 0.0, 80.0, 32.0));
150 + assert_eq!(segs[2].right(), 240.0);
151 + }
152 +
153 + #[test]
154 + fn index_at_maps_points() {
155 + let vs = ViewSwitcher::new(["A", "B", "C"]);
156 + let r = rect();
157 + assert_eq!(vs.index_at(r, Point::new(10.0, 16.0)), Some(0));
158 + assert_eq!(vs.index_at(r, Point::new(90.0, 16.0)), Some(1));
159 + assert_eq!(vs.index_at(r, Point::new(200.0, 16.0)), Some(2));
160 + assert_eq!(vs.index_at(r, Point::new(300.0, 16.0)), None);
161 + }
162 +
163 + #[test]
164 + fn click_changes_selection_only_once() {
165 + let mut vs = ViewSwitcher::new(["A", "B", "C"]);
166 + assert!(vs.click_at(rect(), Point::new(90.0, 16.0)));
167 + assert_eq!(vs.selected(), 1);
168 + assert_eq!(vs.selected_label(), Some("B"));
169 + // Klik w już wybrany segment nic nie zmienia.
170 + assert!(!vs.click_at(rect(), Point::new(90.0, 16.0)));
171 + }
172 +}
0 173 new file mode 100644
@@ -0,0 +1,15 @@
1 +[package]
2 +name = "libluna"
3 +version.workspace = true
4 +edition.workspace = true
5 +license.workspace = true
6 +rust-version.workspace = true
7 +authors.workspace = true
8 +description = "Fasada libLuny — jedno wejście do całego toolkitu (core + render + platform + i18n)."
9 +
10 +[dependencies]
11 +libluna-core.workspace = true
12 +libluna-i18n.workspace = true
13 +libluna-render.workspace = true
14 +libluna-platform.workspace = true
15 +libluna-widgets.workspace = true
0 16 new file mode 100644
@@ -0,0 +1,59 @@
1 +//! Najmniejsza sensowna aplikacja na libLunie: okno, reakcja na klik, motyw i i18n.
2 +//!
3 +//! Uruchom: `cargo run -p libluna --example minimal` (wymaga kompozytora Wayland).
4 +
5 +use libluna::prelude::*;
6 +
7 +fn main() -> Result<(), Box<dyn std::error::Error>> {
8 + // Język dobrany ze środowiska (LANG/LC_*); dołóż własne katalogi, jeśli chcesz.
9 + set_locale(Locale::from_env());
10 +
11 + let config = AppConfig::new("Hello libLuna").initial_size(640, 400);
12 + let mut highlighted = true;
13 +
14 + run(config, move |frame: &mut Frame| {
15 + // 1. Zdarzenia wejścia od poprzedniej klatki.
16 + for event in frame.events {
17 + if let Event::PointerButton(pe) = event {
18 + if pe.button == PointerButton::Left && pe.state == ButtonState::Pressed {
19 + highlighted = !highlighted;
20 + }
21 + }
22 + }
23 +
24 + // 2. Rysowanie sceny (w pikselach logicznych).
25 + let theme = frame.theme;
26 + let (w, h) = (frame.size.width, frame.size.height);
27 + let scene = &mut *frame.scene;
28 +
29 + scene.fill_rect(Rect::from_xywh(0.0, 0.0, w, h), theme.colors.window_bg);
30 + scene.fill_rounded_rect(
31 + Rect::from_xywh(24.0, 24.0, w - 48.0, 96.0),
32 + theme.radius.large,
33 + if highlighted {
34 + theme.colors.accent
35 + } else {
36 + theme.colors.card_bg
37 + },
38 + );
39 +
40 + let fg = if highlighted {
41 + theme.colors.accent_fg
42 + } else {
43 + theme.colors.text
44 + };
45 + scene.text(
46 + TextRun::new(Point::new(44.0, 82.0), tr("Application"), 20.0)
47 + .with_weight(700)
48 + .with_color(fg),
49 + );
50 + scene.text(
51 + TextRun::new(
52 + Point::new(24.0, 168.0),
53 + "Kliknij gdziekolwiek, aby przełączyć",
54 + 14.0,
55 + )
56 + .with_color(theme.colors.dim_text),
57 + );
58 + })
59 +}
0 60 new file mode 100644
@@ -0,0 +1,46 @@
1 +//! # libluna
2 +//!
3 +//! **Fasada** całego toolkitu — jedno wejście dla aplikacji. Zamiast dodawać cztery
4 +//! zależności i pamiętać o ścieżkach re-eksportów, wystarczy:
5 +//!
6 +//! ```toml
7 +//! [dependencies]
8 +//! libluna = { path = "../libluna/crates/libluna" } # albo { git = "…" }
9 +//! ```
10 +//!
11 +//! i w kodzie:
12 +//!
13 +//! ```
14 +//! use libluna::prelude::*;
15 +//! ```
16 +//!
17 +//! ## Warstwy (dostępne też pod pełnymi nazwami)
18 +//! - [`core`] — geometria, kolor, zdarzenia, tokeny motywu, ustawienia wyglądu.
19 +//! - [`render`] — graf sceny i backend rysowania (CPU).
20 +//! - [`platform`] — okna Wayland, wejście, pętla zdarzeń.
21 +//! - [`i18n`] — wielojęzyczność oraz makro [`t!`](crate::t).
22 +//!
23 +//! ## Najszybszy start
24 +//! Zobacz [`prelude`] oraz przykład `examples/minimal.rs`
25 +//! (`cargo run -p libluna --example minimal`).
26 +
27 +pub use libluna_core as core;
28 +pub use libluna_i18n as i18n;
29 +pub use libluna_platform as platform;
30 +pub use libluna_render as render;
31 +pub use libluna_widgets as widgets;
32 +
33 +/// Makro tłumaczeń z `libluna-i18n` (np. `t!("Application")`).
34 +pub use libluna_i18n::t;
35 +
36 +/// Najczęściej używane typy — wystarczy `use libluna::prelude::*;`.
37 +pub mod prelude {
38 + pub use crate::core::event::{ButtonState, Event, KeyEvent, Modifiers, PointerButton};
39 + pub use crate::core::{
40 + Accent, Color, Insets, Point, Rect, Size, Theme, ThemePreference, ThemeSettings,
41 + };
42 + pub use crate::i18n::{set_locale, tr, Locale};
43 + pub use crate::platform::{run, AppConfig, Frame};
44 + pub use crate::render::{Scene, SoftwareRenderer, TextEngine, TextRun};
45 + pub use crate::widgets::{NavEntry, NavList, PaintCtx, ViewSwitcher};
46 +}
0 47 new file mode 100644
@@ -0,0 +1,89 @@
1 +# Architektura libLuny
2 +
3 +Nazewnictwo projektu (analogia do GTK / GNOME / Mutter):
4 +
5 +| Warstwa | Odpowiednik GNOME | Odpowiednik KDE | Nasza nazwa |
6 +|---|---|---|---|
7 +| Toolkit (biblioteka) | GTK | Qt | **libLuna** |
8 +| Środowisko / powłoka | GNOME | KDE Plasma | **Luna** |
9 +| Kompozytor Wayland | Mutter | KWin | **Selene** |
10 +
11 +## 1. Warstwy (mapowanie pojęć GTK/GDK/Gsk/libadwaita → libLuna)
12 +
13 +| Rola | Odpowiednik w GTK/Adw | Moduł libLuny | Stan |
14 +|---|---|---|---|
15 +| Prymitywy: geometria, kolor, skala, zdarzenia, tokeny | grunt/pango types | `libluna-core` | ✅ M0 |
16 +| Rysowanie 2D, buforowanie, clip | Gsk (+Cairo) | `libluna-render` | ✅ M0 |
17 +| Tekst: shaping, metryki, rasteryzacja | Pango | `libluna-render::text` (fontdue) | ✅ M0 (docelowo cosmic-text) |
18 +| Warstwa platformy / okna / wejście | GDK | `libluna-platform` | ✅ M0 |
19 +| Silnik układu (measure/allocate) | GtkLayoutManager | `libluna-layout` | ⏳ M2 |
20 +| Widgety bazowe | Gtk* | `libluna-widgets` | ⏳ M2–M3 |
21 +| Zestaw „libadwaita” (Card, HeaderBar, Sidebar…) | libadwaita | `libluna-adwaita` | ⏳ M4–M5 |
22 +| Pętla zdarzeń i aplikacja | GtkApplication / GMainLoop | `libluna-app` | ⏳ M4 |
23 +
24 +Fasada `libluna` (crate `crates/libluna`) zbiera warstwy w jedno wejście dla aplikacji.
25 +
26 +## 2. Decyzja (podjęta): czysty Rust
27 +
28 +Wybraliśmy **czysty Rust**, bez GObject/glib i bez GTK/Qt. Uzasadnienie:
29 +
30 +- **Lekkość**: brak runtime glib/gobject; statycznie linkowany, mały zestaw zależności.
31 +- **Bezpieczeństwo**: zarządzanie pamięcią przez kompilator zamiast ręcznego refcountingu.
32 +- **Nowoczesny toolchain**: `cargo` + `clippy` + `rustfmt`.
33 +- **Integracja**: kompozytor Selene jest zwykłym serwerem Wayland, więc wystarczy standardowy
34 + protokół (`xdg-shell`, a dla paneli `wlr-layer-shell`).
35 +
36 +Rozważane, odrzucone alternatywy:
37 +
38 +- **C + GObject + Cairo/Pango** — natywne dla obecnego KartONDE i najtańsze w migracji, ale
39 + zostaje w świecie glib i wciąga ciężki GObject.
40 +- **Rust + C ABI / mostek GObject** — daje nowoczesny rdzeń, lecz dodaje dwie granice FFI.
41 +
42 +## 3. Rysowanie i tekst
43 +
44 +- **Graf sceny** (`libluna-render::scene`) jest niezależny od backendu; budują go widgety.
45 +- **Backend M0**: CPU (`tiny-skia`) z antyaliasingiem — kształty, zaokrąglenia, obramowania,
46 + linie, clip. Bufor RGBA8888 trafia w `wl_shm` (konwersja do BGRA/`Argb8888`).
47 +- **Tekst M0**: `fontdue` (font Adwaita Sans z systemu). To świadome uproszczenie — bez
48 + kerningu i pism złożonych. Docelowo `cosmic-text` (shaping, fallback fontów), bez zmiany
49 + kontraktu `measure`/`draw`.
50 +- Później: opcjonalny backend GPU i śledzenie uszkodzeń (damage) do minimalizacji odświeżeń.
51 +
52 +## 4. Model widgetu (wspólny dla wszystkich warstw)
53 +
54 +```
55 +Widget:
56 + measure(constraints) -> Size // faza 1: ile chce
57 + allocate(rect) // faza 2: gdzie trafia
58 + draw(scene, theme) // rysowanie sceny
59 + handle(event) -> Handled | Propagate // wejście
60 + focus/grab/hover state
61 +```
62 +
63 +- Layout: dwuprzebiegowy **measure/allocate** (jak GTK/Flutter) — przewidywalny i prosty.
64 +- Wejście: jedno drzewo zdarzeń, propagacja od liści wzwyż, fokus klawiatury, grab wskaźnika.
65 +
66 +## 5. Komunikacja z kompozytorem (Selene)
67 +
68 +- Okna aplikacji: `xdg-shell` z dekoracjami po stronie klienta (CSD) — sami rysujemy HeaderBar.
69 +- Panele/powłoka: `wlr-layer-shell` (jak `karton-shell`).
70 +- Do rozważenia później: `xdg-decoration`, `fractional-scale-v1`, `viewporter`,
71 + `foreign-toplevel-management` (integracja z panelem zadań), `idle-inhibit`.
72 +- Buforowanie: start od `wl_shm` (CPU), później opcjonalny backend EGL/`wl_drm`.
73 +
74 +## 6. Motywowanie
75 +
76 +- Tokeny: `spacing` (3/6/12/18/24), `radius` (6/9/12/pill), `typography`
77 + (title/heading/body/caption), **metryki kontrolek** (`Metrics`) oraz palety `light`/`dark` + `accent`.
78 +- Warstwa „adwaita-look”: karty, listy, nagłówki, przyciski, przełączniki — zgodne
79 + wizualnie z makietami.
80 +- Geometria kontrolek nie jest już stałą w widgetach — wynika z `Theme::metrics`, więc styl
81 + steruje też rozmiarami (HeaderBar, Switch, Button…).
82 +- Opcjonalnie później: mini-podzbiór CSS po klasach stylu; tokeny mają pierwszeństwo.
83 +
84 +## 7. Świadomie odłożone (poza MVP)
85 +
86 +- Dostępność: AT-SPI (przez D-Bus) — niezbędna dla realnego pulpitu, ale faza późniejsza.
87 +- IME / `text-input-v3`, klawiatura wirtualna.
88 +- Schowek (`wl_data_device`), drag & drop.
89 +- Animacje i easing, backend GPU.
0 90 new file mode 100644
1 91 Binary files /dev/null and b/docs/images/demo-montage.png differ
2 92 new file mode 100644
3 93 Binary files /dev/null and b/docs/images/demo-step-1.png differ
4 94 new file mode 100644
5 95 Binary files /dev/null and b/docs/images/demo-step-10.png differ
6 96 new file mode 100644
7 97 Binary files /dev/null and b/docs/images/demo-step-2.png differ
8 98 new file mode 100644
9 99 Binary files /dev/null and b/docs/images/demo-step-3.png differ
10 100 new file mode 100644
11 101 Binary files /dev/null and b/docs/images/demo-step-4.png differ
12 102 new file mode 100644
13 103 Binary files /dev/null and b/docs/images/demo-step-5.png differ
14 104 new file mode 100644
15 105 Binary files /dev/null and b/docs/images/demo-step-6.png differ
16 106 new file mode 100644
17 107 Binary files /dev/null and b/docs/images/demo-step-7.png differ
18 108 new file mode 100644
19 109 Binary files /dev/null and b/docs/images/demo-step-8.png differ
20 110 new file mode 100644
21 111 Binary files /dev/null and b/docs/images/demo-step-9.png differ
22 112 new file mode 100644
23 113 Binary files /dev/null and b/docs/images/settings-dark.png differ
24 114 new file mode 100644
25 115 Binary files /dev/null and b/docs/images/settings-de-dark.png differ
26 116 new file mode 100644
27 117 Binary files /dev/null and b/docs/images/settings-fr.png differ
28 118 new file mode 100644
29 119 Binary files /dev/null and b/docs/images/settings-light.png differ
30 120 new file mode 100644
@@ -0,0 +1,114 @@
1 +# Roadmap libLuny
2 +
3 +Kamienie milowe są ułożone tak, aby **każdy kolejny etap dał się uruchomić i zobaczyć**,
4 +a na końcu odtworzył realne aplikacje środowiska Luna z makiet.
5 +
6 +## M0 — Fundament (szkielet + pierwsze okno) — ✅ UKOŃCZONE
7 +- Struktura workspace (cargo), build, `clippy`, `rustfmt`, testy.
8 +- `libluna-core`: geometria (`Point/Size/Rect/Insets`), kolor, skala ekranu, typy zdarzeń,
9 + tokeny stylu (spacing/radius/typografia/paleta light+dark).
10 +- `libluna-render`: graf sceny + backend CPU (`tiny-skia`), tekst (`fontdue`), clip, linie.
11 +- `libluna-platform`: połączenie Wayland, `xdg-shell` (CSD), bufor `wl_shm`, `calloop`,
12 + wejście (`seat`, `xkbcommon`), resize/skala, zamknięcie okna.
13 +- **Efekt:** okno z HeaderBar-em (tytuł + `[—] [□] [✕]` + „burger”), rysowane CSD, oraz scena
14 + makiety Ustawień. Render offline do PNG w obu motywach: `docs/images/settings-*.png`.
15 +- Do weryfikacji na żywo: uruchomienie na kompozytorze Selene (`cargo run -p luna-demo`).
16 +
17 +## M0.5 — Wielojęzyczność i wygląd — ✅ UKOŃCZONE
18 +- `libluna-i18n`: system tłumaczeń bez gettext (klucz = angielski napis). **Każdy język w osobnym
19 + pliku** `i18n/<kod>.lang` (wbudowane pl/en/de przez `include_str!`) oraz doładowywanie
20 + w runtime z `~/.config/luna/i18n`, `~/.local/share/luna/i18n`, `/usr/share/luna/i18n`;
21 + wykrywanie z `LANG`/`LC_*`, globalne przełączanie, `languages()`/`language_name()`, makro `t!`.
22 +- `libluna-core`: `ThemePreference` (Auto/Jasny/Ciemny), `Accent` (9 kolorów), `ThemeSettings`
23 + z rozwiązywaniem motywu i zapisem do `~/.config/luna/theme.conf`.
24 +- `libluna-platform`: zdarzenia wejścia (wskaźnik, klawiatura, scroll) przekazywane do `Frame`;
25 + sterowanie motywem z aplikacji (`set_preference`/`set_accent`) z natychmiastowym przerysowaniem.
26 +- `luna-demo`: interaktywny panel wyglądu (segmenty trybu, paleta akcentów, wybór języka),
27 + chowanie **paska dolnego** i **paska bocznego** oraz przeciąganie jego krawędzi (drag, 150–460 px);
28 + skróty `l`/`a`/`g`/`b`/`s`; zrzuty w kilku językach/motywach w `docs/images/`.
29 +
30 +## M1 — Render
31 +- Graf sceny: prostokąty, zaokrąglone prostokąty, obcinanie (clip), cień, obrazy/ikony SVG.
32 +- `libluna-text`: shaping/układanie (Pango albo cosmic-text), fallback fontów, ellipsis.
33 +- `damage`/częściowe odświeżanie.
34 +
35 +## M2.5 — Widgety (start) — ⏳ W TOKU
36 +- `libluna-widgets`: widgety wielokrotnego użytku w modelu immediate — `ViewSwitcher`, `NavList`,
37 + `Menu` (popover `☰`), `Button` (warianty + chevron), `Switch`, `Slider`, `DropDown`
38 + (rozwijany przez `Menu`), `HeaderBar` (pasek tytułu z kontrolkami okna), `TabBar`/`TabView`
39 + (zakładki: pasek + model stron), `BottomSheet` (dolny panel modalny), `Pager` (karuzela stron),
40 + `PathBar` (okruszki), `SearchEntry` (pole wyszukiwania) i `ItemView` (pliki/katalogi: siatka
41 + lub lista), oraz wspólny `PaintCtx`; testy hit-testingu bez okna.
42 +- `libluna-platform`: realne akcje okna z `Frame` (`close`/`minimize`/`toggle_maximize`).
43 +- Demo używa widgetów zamiast ręcznego rysowania (pasek boczny, wybór motywu/widoku, menu `☰`,
44 + przycisk „Opcje”, przełączniki, suwak, wybór języka, pasek tytułu, zakładki `TabBar`/`TabView`).
45 +- Motywy: wariant (light/dark/auto) + akcent, z trwałością. **Component metrics**: rozmiary
46 + i paddingi kontrolek wyniesione z widgetów do tokenów (`Theme::metrics`), więc styl steruje
47 + też geometrią (README, „Własny wygląd”).
48 +- Dalej: **style instalowane jako pliki** (jak w GNOME/KDE) z **oknem podglądu** przy wyborze,
49 + a potem scroll/overflow w `TabBar`, retencyjne drzewo `measure`/`allocate`.
50 +
51 +## M2 — Układ + widgety bazowe
52 +- Dwuprzebiegowy silnik measure/allocate.
53 +- `Box` (h/v), `Label`, `Icon`, `Button`, `Separator`, `Spacer`.
54 +- Stany: hover/pressed/disabled/focus + pierścień fokusu klawiatury.
55 +
56 +## M3 — Sterowanie i listy
57 +- `Switch`, `Slider`, `CheckButton`, `RadioButton`, `Entry`, `DropDown`, `Stepper`.
58 +- `ScrollView`, `ListView`, `GridView` (siatka kafelków) z zaznaczaniem i aktywacją.
59 +- `Sidebar` (sekcje + wiersze z ikonami, grupy rozwijane).
60 +
61 +## M4 — „Chrome” okna
62 +- `Window`, `HeaderBar` (slot start/center/end), `WindowControls` (`— □ ✕`).
63 +- `MenuButton` + `Popover` (menu „burger”: pozycje, separatory, skróty, podmenu).
64 +- `TabBar` / `ViewSwitcher` (segmentowy: Lista | Siatka).
65 +- `PathBar` (okruszki z `>` i `▾`), `SearchBar`/`SearchEntry`.
66 +- Bottom bar / pasek statusu.
67 +
68 +## M5 — Zestaw „adwaita”
69 +- `Card` (nagłówek sekcji + wiersze), `ActionRow` (tytuł/podtytuł + suffix).
70 +- `PreferencesGroup`, `Banner`, `Toast`, `StatusPage`.
71 +- `Spinner`, `Tooltip`, ikony symboliczne motywowane.
72 +
73 +## M6 — Odtworzenie makiet (cel!)
74 +- **Luna Settings** — panel boczny (Przegląd/Zaawansowane), karty z `Switch`/`DropDown`/
75 + `Slider`, pasek akcji, pasek statusu.
76 +- **Luna Files** — HeaderBar z `PathBar` + wyszukiwarką + przełącznikiem widoku + kontrolkami
77 + okna, panel boczny (Miejsca/Urządzenia/Sieć), widok siatki, popover opcji/sortowania,
78 + bottom bar z wolnym miejscem.
79 +
80 +## M7 — Migracja z GTK4
81 +- Najpierw `karton-settings` (mniejszy, `window.c` ~590 linii + strony) — strona po stronie.
82 +- Potem `karton-files` (`window.c` ~5100 linii) — etapami.
83 +- Opcjonalnie ujednolicenie `karton-shell` na libLunę (dziś cairo/pango bezpośrednio).
84 +
85 +## Później (poza MVP)
86 +- Dostępność AT-SPI, IME, schowek, drag & drop, animacje, backend GPU, mini-CSS dla motywów.
87 +
88 +## Inwentarz widgetów (z makiet → moduł docelowy)
89 +
90 +| Widget | Moduł | Makieta |
91 +|---|---|---|
92 +| Window + CSD HeaderBar | `libluna-adwaita` | obie |
93 +| WindowControls `— □ ✕` | `libluna-adwaita` | obie |
94 +| MenuButton + Popover (burger) | `libluna-widgets` | obie |
95 +| TabBar / ViewSwitcher | `libluna-widgets` | Settings, Files |
96 +| BottomSheet | `libluna-widgets` | Settings, Files |
97 +| Pager (karuzela stron) | `libluna-widgets` | (przeglądarka, onboarding) |
98 +| Sidebar (sekcje, wiersze) | `libluna-adwaita` | obie |
99 +| PathBar (okruszki) | `libluna-widgets` | Files |
100 +| SearchEntry / SearchBar | `libluna-widgets` | Files |
101 +| GridView / ListView | `libluna-widgets` (`ItemView`) | Files |
102 +| Card + ActionRow | `libluna-adwaita` | Settings |
103 +| Switch | `libluna-widgets` | Settings |
104 +| Slider (+ etykieta %) | `libluna-widgets` | Settings |
105 +| DropDown | `libluna-widgets` | Settings |
106 +| Button (pill/suggested/flat) | `libluna-widgets` | Settings |
107 +| Stepper / Check / Radio | `libluna-widgets` | Files (popover) |
108 +| StatusBar / bottom info | `libluna-widgets` | obie |
109 +| StatusPage | `libluna-adwaita` | (docelowo) |
110 +
111 +## Kryterium „gotowca” (Definition of Done dla MVP)
112 +Odtworzenie **obu makiet** (Settings i Files) wyłącznie na libLunie, bez GTK4, z jasnym i
113 +ciemnym motywem, uruchamialne na kompozytorze Selene, w rozsądnym rozmiarze binarki
114 +i zużyciu pamięci względem odpowiedników GTK4.
0 115 new file mode 100644
@@ -0,0 +1,46 @@
1 +# Français — plik doładowywany w runtime (bez rekompilacji).
2 +# Trzymaj go np. w ~/.config/luna/i18n/fr.lang albo /usr/share/luna/i18n/fr.lang.
3 +# Celowo pomijamy część kluczy — brakujące spadają do języka bazowego (angielskiego).
4 +
5 +__name__ = Français
6 +
7 +Application = Application
8 +Options = Options
9 +Overview = Aperçu
10 +Account = Compte
11 +Appearance = Apparence
12 +Notifications = Notifications
13 +Advanced = Avancé
14 +Network = Réseau
15 +System = Système
16 +Settings = Paramètres
17 +Option A = OPTION A
18 +Volume level = Volume
19 +Choose… = Choisir…
20 +Status: Ready = État : Prêt
21 +Theme = Thème
22 +Light = Clair
23 +Dark = Sombre
24 +Auto = Automatique
25 +Accent color = Couleur d'accent
26 +Language = Langue
27 +Bottom bar = Barre inférieure
28 +Sidebar = Barre latérale
29 +View = Affichage
30 +List = Liste
31 +Grid = Grille
32 +New Window = Nouvelle fenêtre
33 +Keyboard Shortcuts = Raccourcis clavier
34 +About = À propos
35 +General = Général
36 +Tabs = Onglets
37 +Active tab = Onglet actif
38 +License = Licence
39 +Home = Dossier personnel
40 +Documents = Documents
41 +Projects = Projets
42 +Search = Rechercher
43 +Files = Fichiers
44 +Report = Rapport
45 +Notes = Notes
46 +Budget = Budget
0 47 new file mode 100644
@@ -0,0 +1,3 @@
1 +[toolchain]
2 +channel = "stable"
3 +components = ["rustfmt", "clippy"]
0 4 new file mode 100644
@@ -0,0 +1,8 @@
1 +[package]
2 +name = "__APP_NAME__"
3 +version = "0.1.0"
4 +edition = "2021"
5 +
6 +[dependencies]
7 +# Ścieżkę podmienia scaffolder (tools/libluna-new.sh) albo wpisz własną.
8 +libluna = { path = "__LIBLUNA_PATH__" }
0 9 new file mode 100644
@@ -0,0 +1,46 @@
1 +# Szablon aplikacji środowiska Luna (libLuna)
2 +
3 +Minimalny start: jedno okno Wayland, motyw jasny/ciemny i gotowy `prelude`.
4 +
5 +## Szybki start (scaffolder)
6 +
7 +Z katalogu głównego libLuny:
8 +
9 +```bash
10 +./tools/libluna-new.sh ~/projekty/moja-apka
11 +cd ~/projekty/moja-apka
12 +cargo run
13 +```
14 +
15 +Skrypt utworzy katalog, wygeneruje `Cargo.toml` z poprawną ścieżką do libLuny
16 +i skopiuje `src/main.rs`.
17 +
18 +## Ręcznie
19 +
20 +1. Skopiuj ten katalog jako bazę swojej aplikacji.
21 +2. W `Cargo.toml` podmień `__LIBLUNA_PATH__` na ścieżkę do `crates/libluna`
22 + (albo użyj zależności `git`/rejestrowej):
23 +
24 + ```toml
25 + [dependencies]
26 + libluna = { path = "../libluna/crates/libluna" }
27 + # lub:
28 + # libluna = { git = "https://github.com/mijsys/libLuna.git" }
29 + ```
30 +3. Podmień `__APP_NAME__` na nazwę pakietu.
31 +
32 +## Struktura
33 +
34 +```rust
35 +use libluna::prelude::*;
36 +
37 +fn main() -> Result<(), Box<dyn std::error::Error>> {
38 + let config = AppConfig::new("Moja apka").initial_size(800, 600);
39 + run(config, |frame: &mut Frame| {
40 + // frame.events — zdarzenia wejścia
41 + // frame.scene — graf sceny do wypełnienia
42 + // frame.theme — rozwiązany motyw (jasny/ciemny + akcent)
43 + // frame.text — pomiar tekstu (frame.measure_text)
44 + })
45 +}
46 +```
0 47 new file mode 100644
@@ -0,0 +1,22 @@
1 +//! __APP_NAME__ — aplikacja środowiska Luna na libLunie (szablon startowy).
2 +
3 +use libluna::prelude::*;
4 +
5 +fn main() -> Result<(), Box<dyn std::error::Error>> {
6 + set_locale(Locale::from_env());
7 +
8 + let config = AppConfig::new("__APP_NAME__").initial_size(800, 600);
9 +
10 + run(config, |frame: &mut Frame| {
11 + let theme = frame.theme;
12 + let (w, h) = (frame.size.width, frame.size.height);
13 + let scene = &mut *frame.scene;
14 +
15 + scene.fill_rect(Rect::from_xywh(0.0, 0.0, w, h), theme.colors.window_bg);
16 + scene.text(
17 + TextRun::new(Point::new(24.0, 48.0), &tr("Application"), 20.0)
18 + .with_weight(700)
19 + .with_color(theme.colors.text),
20 + );
21 + })
22 +}
0 23 new file mode 100755
@@ -0,0 +1,40 @@
1 +#!/bin/sh
2 +# Scaffolder aplikacji na libLunie (środowisko Luna).
3 +#
4 +# Użycie:
5 +# ./tools/libluna-new.sh <katalog-aplikacji> [nazwa-pakietu]
6 +#
7 +# Tworzy katalog z Cargo.toml (zależność `libluna` wskazująca na to drzewo)
8 +# oraz src/main.rs z szablonu templates/minimal-app.
9 +
10 +set -eu
11 +
12 +if [ "$#" -lt 1 ]; then
13 + echo "użycie: libluna-new.sh <katalog-aplikacji> [nazwa-pakietu]" >&2
14 + exit 1
15 +fi
16 +
17 +target="$1"
18 +name="${2:-$(basename "$target")}"
19 +
20 +here="$(cd "$(dirname "$0")" && pwd)"
21 +root="$(cd "$here/.." && pwd)"
22 +libluna_path="$root/crates/libluna"
23 +template="$root/templates/minimal-app"
24 +
25 +if [ ! -d "$template" ]; then
26 + echo "nie znaleziono szablonu: $template" >&2
27 + exit 1
28 +fi
29 +
30 +mkdir -p "$target/src"
31 +
32 +sed "s#__APP_NAME__#$name#g; s#__LIBLUNA_PATH__#$libluna_path#g" \
33 + "$template/Cargo.toml" > "$target/Cargo.toml"
34 +sed "s#__APP_NAME__#$name#g" \
35 + "$template/src/main.rs" > "$target/src/main.rs"
36 +
37 +echo "utworzono aplikację: $target"
38 +echo " pakiet: $name"
39 +echo " libluna: $libluna_path"
40 +echo "uruchom: (cd $target && cargo run)"