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
+
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
+
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
+
317
+
318
+
319
+
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)"