gardesk/garfield / 587b538

Browse files

add xdg-desktop-portal backend and picker mode UI

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
587b53800df203edf5a817a409c42add09f1fa6c
Parents
b121406
Tree
67004a7

22 changed files

StatusFile+-
M Cargo.lock 506 10
M Cargo.toml 7 0
A garfield-portal/Cargo.toml 28 0
A garfield-portal/README.md 146 0
A garfield-portal/data/gar-portals.conf 15 0
A garfield-portal/data/garfield-portal.service 12 0
A garfield-portal/data/garfield.portal 4 0
A garfield-portal/install.sh 296 0
A garfield-portal/src/file_chooser.rs 319 0
A garfield-portal/src/main.rs 56 0
A garfield-portal/src/request.rs 117 0
A garfield-portal/test-portal.sh 76 0
M garfield/Cargo.toml 3 0
M garfield/src/app.rs 289 54
M garfield/src/core/entry.rs 65 0
M garfield/src/core/mod.rs 1 1
M garfield/src/main.rs 147 13
M garfield/src/ui/mod.rs 4 2
M garfield/src/ui/pane.rs 219 12
A garfield/src/ui/picker_toolbar.rs 241 0
M garfield/src/ui/tab.rs 10 5
M garfield/src/ui/tab_bar.rs 114 11
Cargo.lockmodified
@@ -88,6 +88,40 @@ version = "1.0.1"
88
 source = "registry+https://github.com/rust-lang/crates.io-index"
88
 source = "registry+https://github.com/rust-lang/crates.io-index"
89
 checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
89
 checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
90
 
90
 
91
+[[package]]
92
+name = "async-broadcast"
93
+version = "0.7.2"
94
+source = "registry+https://github.com/rust-lang/crates.io-index"
95
+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
96
+dependencies = [
97
+ "event-listener",
98
+ "event-listener-strategy",
99
+ "futures-core",
100
+ "pin-project-lite",
101
+]
102
+
103
+[[package]]
104
+name = "async-recursion"
105
+version = "1.1.1"
106
+source = "registry+https://github.com/rust-lang/crates.io-index"
107
+checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
108
+dependencies = [
109
+ "proc-macro2",
110
+ "quote",
111
+ "syn",
112
+]
113
+
114
+[[package]]
115
+name = "async-trait"
116
+version = "0.1.89"
117
+source = "registry+https://github.com/rust-lang/crates.io-index"
118
+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
119
+dependencies = [
120
+ "proc-macro2",
121
+ "quote",
122
+ "syn",
123
+]
124
+
91
 [[package]]
125
 [[package]]
92
 name = "autocfg"
126
 name = "autocfg"
93
 version = "1.5.0"
127
 version = "1.5.0"
@@ -118,6 +152,12 @@ version = "0.1.0"
118
 source = "registry+https://github.com/rust-lang/crates.io-index"
152
 source = "registry+https://github.com/rust-lang/crates.io-index"
119
 checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
153
 checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
120
 
154
 
155
+[[package]]
156
+name = "bytes"
157
+version = "1.11.0"
158
+source = "registry+https://github.com/rust-lang/crates.io-index"
159
+checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
160
+
121
 [[package]]
161
 [[package]]
122
 name = "cairo-rs"
162
 name = "cairo-rs"
123
 version = "0.20.12"
163
 version = "0.20.12"
@@ -255,6 +295,15 @@ version = "1.0.4"
255
 source = "registry+https://github.com/rust-lang/crates.io-index"
295
 source = "registry+https://github.com/rust-lang/crates.io-index"
256
 checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
296
 checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
257
 
297
 
298
+[[package]]
299
+name = "concurrent-queue"
300
+version = "2.5.0"
301
+source = "registry+https://github.com/rust-lang/crates.io-index"
302
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
303
+dependencies = [
304
+ "crossbeam-utils",
305
+]
306
+
258
 [[package]]
307
 [[package]]
259
 name = "core-foundation-sys"
308
 name = "core-foundation-sys"
260
 version = "0.8.7"
309
 version = "0.8.7"
@@ -270,6 +319,12 @@ dependencies = [
270
  "cfg-if",
319
  "cfg-if",
271
 ]
320
 ]
272
 
321
 
322
+[[package]]
323
+name = "crossbeam-utils"
324
+version = "0.8.21"
325
+source = "registry+https://github.com/rust-lang/crates.io-index"
326
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
327
+
273
 [[package]]
328
 [[package]]
274
 name = "dirs"
329
 name = "dirs"
275
 version = "6.0.0"
330
 version = "6.0.0"
@@ -291,6 +346,33 @@ dependencies = [
291
  "windows-sys 0.61.2",
346
  "windows-sys 0.61.2",
292
 ]
347
 ]
293
 
348
 
349
+[[package]]
350
+name = "endi"
351
+version = "1.1.1"
352
+source = "registry+https://github.com/rust-lang/crates.io-index"
353
+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
354
+
355
+[[package]]
356
+name = "enumflags2"
357
+version = "0.7.12"
358
+source = "registry+https://github.com/rust-lang/crates.io-index"
359
+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
360
+dependencies = [
361
+ "enumflags2_derive",
362
+ "serde",
363
+]
364
+
365
+[[package]]
366
+name = "enumflags2_derive"
367
+version = "0.7.12"
368
+source = "registry+https://github.com/rust-lang/crates.io-index"
369
+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
370
+dependencies = [
371
+ "proc-macro2",
372
+ "quote",
373
+ "syn",
374
+]
375
+
294
 [[package]]
376
 [[package]]
295
 name = "equivalent"
377
 name = "equivalent"
296
 version = "1.0.2"
378
 version = "1.0.2"
@@ -307,6 +389,33 @@ dependencies = [
307
  "windows-sys 0.61.2",
389
  "windows-sys 0.61.2",
308
 ]
390
 ]
309
 
391
 
392
+[[package]]
393
+name = "event-listener"
394
+version = "5.4.1"
395
+source = "registry+https://github.com/rust-lang/crates.io-index"
396
+checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
397
+dependencies = [
398
+ "concurrent-queue",
399
+ "parking",
400
+ "pin-project-lite",
401
+]
402
+
403
+[[package]]
404
+name = "event-listener-strategy"
405
+version = "0.5.4"
406
+source = "registry+https://github.com/rust-lang/crates.io-index"
407
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
408
+dependencies = [
409
+ "event-listener",
410
+ "pin-project-lite",
411
+]
412
+
413
+[[package]]
414
+name = "fastrand"
415
+version = "2.3.0"
416
+source = "registry+https://github.com/rust-lang/crates.io-index"
417
+checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
418
+
310
 [[package]]
419
 [[package]]
311
 name = "fdeflate"
420
 name = "fdeflate"
312
 version = "0.3.7"
421
 version = "0.3.7"
@@ -374,6 +483,19 @@ version = "0.3.31"
374
 source = "registry+https://github.com/rust-lang/crates.io-index"
483
 source = "registry+https://github.com/rust-lang/crates.io-index"
375
 checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
484
 checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
376
 
485
 
486
+[[package]]
487
+name = "futures-lite"
488
+version = "2.6.1"
489
+source = "registry+https://github.com/rust-lang/crates.io-index"
490
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
491
+dependencies = [
492
+ "fastrand",
493
+ "futures-core",
494
+ "futures-io",
495
+ "parking",
496
+ "pin-project-lite",
497
+]
498
+
377
 [[package]]
499
 [[package]]
378
 name = "futures-macro"
500
 name = "futures-macro"
379
 version = "0.3.31"
501
 version = "0.3.31"
@@ -412,6 +534,7 @@ dependencies = [
412
  "anyhow",
534
  "anyhow",
413
  "cairo-rs 0.21.5",
535
  "cairo-rs 0.21.5",
414
  "chrono",
536
  "chrono",
537
+ "clap",
415
  "dirs",
538
  "dirs",
416
  "freedesktop_entry_parser",
539
  "freedesktop_entry_parser",
417
  "garfield-ipc",
540
  "garfield-ipc",
@@ -440,6 +563,18 @@ dependencies = [
440
  "thiserror 2.0.18",
563
  "thiserror 2.0.18",
441
 ]
564
 ]
442
 
565
 
566
+[[package]]
567
+name = "garfield-portal"
568
+version = "0.1.0"
569
+dependencies = [
570
+ "anyhow",
571
+ "clap",
572
+ "tokio",
573
+ "tracing",
574
+ "tracing-subscriber",
575
+ "zbus",
576
+]
577
+
443
 [[package]]
578
 [[package]]
444
 name = "garfieldctl"
579
 name = "garfieldctl"
445
 version = "0.1.0"
580
 version = "0.1.0"
@@ -504,6 +639,18 @@ dependencies = [
504
  "wasi",
639
  "wasi",
505
 ]
640
 ]
506
 
641
 
642
+[[package]]
643
+name = "getrandom"
644
+version = "0.3.4"
645
+source = "registry+https://github.com/rust-lang/crates.io-index"
646
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
647
+dependencies = [
648
+ "cfg-if",
649
+ "libc",
650
+ "r-efi",
651
+ "wasip2",
652
+]
653
+
507
 [[package]]
654
 [[package]]
508
 name = "gif"
655
 name = "gif"
509
 version = "0.14.1"
656
 version = "0.14.1"
@@ -696,6 +843,12 @@ version = "0.5.0"
696
 source = "registry+https://github.com/rust-lang/crates.io-index"
843
 source = "registry+https://github.com/rust-lang/crates.io-index"
697
 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
844
 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
698
 
845
 
846
+[[package]]
847
+name = "hex"
848
+version = "0.4.3"
849
+source = "registry+https://github.com/rust-lang/crates.io-index"
850
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
851
+
699
 [[package]]
852
 [[package]]
700
 name = "iana-time-zone"
853
 name = "iana-time-zone"
701
 version = "0.1.64"
854
 version = "0.1.64"
@@ -829,6 +982,15 @@ version = "2.7.6"
829
 source = "registry+https://github.com/rust-lang/crates.io-index"
982
 source = "registry+https://github.com/rust-lang/crates.io-index"
830
 checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
983
 checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
831
 
984
 
985
+[[package]]
986
+name = "memoffset"
987
+version = "0.9.1"
988
+source = "registry+https://github.com/rust-lang/crates.io-index"
989
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
990
+dependencies = [
991
+ "autocfg",
992
+]
993
+
832
 [[package]]
994
 [[package]]
833
 name = "minimal-lexical"
995
 name = "minimal-lexical"
834
 version = "0.2.1"
996
 version = "0.2.1"
@@ -845,6 +1007,17 @@ dependencies = [
845
  "simd-adler32",
1007
  "simd-adler32",
846
 ]
1008
 ]
847
 
1009
 
1010
+[[package]]
1011
+name = "mio"
1012
+version = "1.1.1"
1013
+source = "registry+https://github.com/rust-lang/crates.io-index"
1014
+checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
1015
+dependencies = [
1016
+ "libc",
1017
+ "wasi",
1018
+ "windows-sys 0.61.2",
1019
+]
1020
+
848
 [[package]]
1021
 [[package]]
849
 name = "moxcms"
1022
 name = "moxcms"
850
 version = "0.7.11"
1023
 version = "0.7.11"
@@ -911,6 +1084,16 @@ version = "0.2.0"
911
 source = "registry+https://github.com/rust-lang/crates.io-index"
1084
 source = "registry+https://github.com/rust-lang/crates.io-index"
912
 checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
1085
 checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
913
 
1086
 
1087
+[[package]]
1088
+name = "ordered-stream"
1089
+version = "0.2.0"
1090
+source = "registry+https://github.com/rust-lang/crates.io-index"
1091
+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
1092
+dependencies = [
1093
+ "futures-core",
1094
+ "pin-project-lite",
1095
+]
1096
+
914
 [[package]]
1097
 [[package]]
915
 name = "pango"
1098
 name = "pango"
916
 version = "0.20.12"
1099
 version = "0.20.12"
@@ -961,6 +1144,12 @@ dependencies = [
961
  "system-deps",
1144
  "system-deps",
962
 ]
1145
 ]
963
 
1146
 
1147
+[[package]]
1148
+name = "parking"
1149
+version = "2.2.1"
1150
+source = "registry+https://github.com/rust-lang/crates.io-index"
1151
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
1152
+
964
 [[package]]
1153
 [[package]]
965
 name = "pin-project-lite"
1154
 name = "pin-project-lite"
966
 version = "0.2.16"
1155
 version = "0.2.16"
@@ -1061,13 +1250,19 @@ dependencies = [
1061
  "proc-macro2",
1250
  "proc-macro2",
1062
 ]
1251
 ]
1063
 
1252
 
1253
+[[package]]
1254
+name = "r-efi"
1255
+version = "5.3.0"
1256
+source = "registry+https://github.com/rust-lang/crates.io-index"
1257
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
1258
+
1064
 [[package]]
1259
 [[package]]
1065
 name = "redox_users"
1260
 name = "redox_users"
1066
 version = "0.5.2"
1261
 version = "0.5.2"
1067
 source = "registry+https://github.com/rust-lang/crates.io-index"
1262
 source = "registry+https://github.com/rust-lang/crates.io-index"
1068
 checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
1263
 checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
1069
 dependencies = [
1264
 dependencies = [
1070
- "getrandom",
1265
+ "getrandom 0.2.17",
1071
  "libredox",
1266
  "libredox",
1072
  "thiserror 2.0.18",
1267
  "thiserror 2.0.18",
1073
 ]
1268
 ]
@@ -1160,6 +1355,17 @@ dependencies = [
1160
  "zmij",
1355
  "zmij",
1161
 ]
1356
 ]
1162
 
1357
 
1358
+[[package]]
1359
+name = "serde_repr"
1360
+version = "0.1.20"
1361
+source = "registry+https://github.com/rust-lang/crates.io-index"
1362
+checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
1363
+dependencies = [
1364
+ "proc-macro2",
1365
+ "quote",
1366
+ "syn",
1367
+]
1368
+
1163
 [[package]]
1369
 [[package]]
1164
 name = "serde_spanned"
1370
 name = "serde_spanned"
1165
 version = "1.0.4"
1371
 version = "1.0.4"
@@ -1184,6 +1390,16 @@ version = "1.3.0"
1184
 source = "registry+https://github.com/rust-lang/crates.io-index"
1390
 source = "registry+https://github.com/rust-lang/crates.io-index"
1185
 checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
1391
 checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
1186
 
1392
 
1393
+[[package]]
1394
+name = "signal-hook-registry"
1395
+version = "1.4.8"
1396
+source = "registry+https://github.com/rust-lang/crates.io-index"
1397
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
1398
+dependencies = [
1399
+ "errno",
1400
+ "libc",
1401
+]
1402
+
1187
 [[package]]
1403
 [[package]]
1188
 name = "simd-adler32"
1404
 name = "simd-adler32"
1189
 version = "0.3.8"
1405
 version = "0.3.8"
@@ -1202,6 +1418,16 @@ version = "1.15.1"
1202
 source = "registry+https://github.com/rust-lang/crates.io-index"
1418
 source = "registry+https://github.com/rust-lang/crates.io-index"
1203
 checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
1419
 checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
1204
 
1420
 
1421
+[[package]]
1422
+name = "socket2"
1423
+version = "0.6.2"
1424
+source = "registry+https://github.com/rust-lang/crates.io-index"
1425
+checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
1426
+dependencies = [
1427
+ "libc",
1428
+ "windows-sys 0.60.2",
1429
+]
1430
+
1205
 [[package]]
1431
 [[package]]
1206
 name = "strsim"
1432
 name = "strsim"
1207
 version = "0.11.1"
1433
 version = "0.11.1"
@@ -1238,6 +1464,19 @@ version = "0.13.3"
1238
 source = "registry+https://github.com/rust-lang/crates.io-index"
1464
 source = "registry+https://github.com/rust-lang/crates.io-index"
1239
 checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c"
1465
 checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c"
1240
 
1466
 
1467
+[[package]]
1468
+name = "tempfile"
1469
+version = "3.24.0"
1470
+source = "registry+https://github.com/rust-lang/crates.io-index"
1471
+checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
1472
+dependencies = [
1473
+ "fastrand",
1474
+ "getrandom 0.3.4",
1475
+ "once_cell",
1476
+ "rustix",
1477
+ "windows-sys 0.61.2",
1478
+]
1479
+
1241
 [[package]]
1480
 [[package]]
1242
 name = "thiserror"
1481
 name = "thiserror"
1243
 version = "1.0.69"
1482
 version = "1.0.69"
@@ -1287,6 +1526,34 @@ dependencies = [
1287
  "cfg-if",
1526
  "cfg-if",
1288
 ]
1527
 ]
1289
 
1528
 
1529
+[[package]]
1530
+name = "tokio"
1531
+version = "1.49.0"
1532
+source = "registry+https://github.com/rust-lang/crates.io-index"
1533
+checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
1534
+dependencies = [
1535
+ "bytes",
1536
+ "libc",
1537
+ "mio",
1538
+ "pin-project-lite",
1539
+ "signal-hook-registry",
1540
+ "socket2",
1541
+ "tokio-macros",
1542
+ "tracing",
1543
+ "windows-sys 0.61.2",
1544
+]
1545
+
1546
+[[package]]
1547
+name = "tokio-macros"
1548
+version = "2.6.0"
1549
+source = "registry+https://github.com/rust-lang/crates.io-index"
1550
+checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
1551
+dependencies = [
1552
+ "proc-macro2",
1553
+ "quote",
1554
+ "syn",
1555
+]
1556
+
1290
 [[package]]
1557
 [[package]]
1291
 name = "toml"
1558
 name = "toml"
1292
 version = "0.9.11+spec-1.1.0"
1559
 version = "0.9.11+spec-1.1.0"
@@ -1399,6 +1666,17 @@ dependencies = [
1399
  "tracing-log",
1666
  "tracing-log",
1400
 ]
1667
 ]
1401
 
1668
 
1669
+[[package]]
1670
+name = "uds_windows"
1671
+version = "1.1.0"
1672
+source = "registry+https://github.com/rust-lang/crates.io-index"
1673
+checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9"
1674
+dependencies = [
1675
+ "memoffset",
1676
+ "tempfile",
1677
+ "winapi",
1678
+]
1679
+
1402
 [[package]]
1680
 [[package]]
1403
 name = "unicode-ident"
1681
 name = "unicode-ident"
1404
 version = "1.0.22"
1682
 version = "1.0.22"
@@ -1417,6 +1695,17 @@ version = "0.2.2"
1417
 source = "registry+https://github.com/rust-lang/crates.io-index"
1695
 source = "registry+https://github.com/rust-lang/crates.io-index"
1418
 checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
1696
 checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
1419
 
1697
 
1698
+[[package]]
1699
+name = "uuid"
1700
+version = "1.20.0"
1701
+source = "registry+https://github.com/rust-lang/crates.io-index"
1702
+checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f"
1703
+dependencies = [
1704
+ "js-sys",
1705
+ "serde_core",
1706
+ "wasm-bindgen",
1707
+]
1708
+
1420
 [[package]]
1709
 [[package]]
1421
 name = "valuable"
1710
 name = "valuable"
1422
 version = "0.1.1"
1711
 version = "0.1.1"
@@ -1445,6 +1734,15 @@ version = "0.11.1+wasi-snapshot-preview1"
1445
 source = "registry+https://github.com/rust-lang/crates.io-index"
1734
 source = "registry+https://github.com/rust-lang/crates.io-index"
1446
 checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
1735
 checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
1447
 
1736
 
1737
+[[package]]
1738
+name = "wasip2"
1739
+version = "1.0.2+wasi-0.2.9"
1740
+source = "registry+https://github.com/rust-lang/crates.io-index"
1741
+checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
1742
+dependencies = [
1743
+ "wit-bindgen",
1744
+]
1745
+
1448
 [[package]]
1746
 [[package]]
1449
 name = "wasm-bindgen"
1747
 name = "wasm-bindgen"
1450
 version = "0.2.108"
1748
 version = "0.2.108"
@@ -1496,6 +1794,22 @@ version = "0.1.12"
1496
 source = "registry+https://github.com/rust-lang/crates.io-index"
1794
 source = "registry+https://github.com/rust-lang/crates.io-index"
1497
 checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
1795
 checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
1498
 
1796
 
1797
+[[package]]
1798
+name = "winapi"
1799
+version = "0.3.9"
1800
+source = "registry+https://github.com/rust-lang/crates.io-index"
1801
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
1802
+dependencies = [
1803
+ "winapi-i686-pc-windows-gnu",
1804
+ "winapi-x86_64-pc-windows-gnu",
1805
+]
1806
+
1807
+[[package]]
1808
+name = "winapi-i686-pc-windows-gnu"
1809
+version = "0.4.0"
1810
+source = "registry+https://github.com/rust-lang/crates.io-index"
1811
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
1812
+
1499
 [[package]]
1813
 [[package]]
1500
 name = "winapi-util"
1814
 name = "winapi-util"
1501
 version = "0.1.11"
1815
 version = "0.1.11"
@@ -1505,6 +1819,12 @@ dependencies = [
1505
  "windows-sys 0.61.2",
1819
  "windows-sys 0.61.2",
1506
 ]
1820
 ]
1507
 
1821
 
1822
+[[package]]
1823
+name = "winapi-x86_64-pc-windows-gnu"
1824
+version = "0.4.0"
1825
+source = "registry+https://github.com/rust-lang/crates.io-index"
1826
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
1827
+
1508
 [[package]]
1828
 [[package]]
1509
 name = "windows-core"
1829
 name = "windows-core"
1510
 version = "0.62.2"
1830
 version = "0.62.2"
@@ -1570,7 +1890,16 @@ version = "0.59.0"
1570
 source = "registry+https://github.com/rust-lang/crates.io-index"
1890
 source = "registry+https://github.com/rust-lang/crates.io-index"
1571
 checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
1891
 checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
1572
 dependencies = [
1892
 dependencies = [
1573
- "windows-targets",
1893
+ "windows-targets 0.52.6",
1894
+]
1895
+
1896
+[[package]]
1897
+name = "windows-sys"
1898
+version = "0.60.2"
1899
+source = "registry+https://github.com/rust-lang/crates.io-index"
1900
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
1901
+dependencies = [
1902
+ "windows-targets 0.53.5",
1574
 ]
1903
 ]
1575
 
1904
 
1576
 [[package]]
1905
 [[package]]
@@ -1588,14 +1917,31 @@ version = "0.52.6"
1588
 source = "registry+https://github.com/rust-lang/crates.io-index"
1917
 source = "registry+https://github.com/rust-lang/crates.io-index"
1589
 checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
1918
 checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
1590
 dependencies = [
1919
 dependencies = [
1591
- "windows_aarch64_gnullvm",
1920
+ "windows_aarch64_gnullvm 0.52.6",
1592
- "windows_aarch64_msvc",
1921
+ "windows_aarch64_msvc 0.52.6",
1593
- "windows_i686_gnu",
1922
+ "windows_i686_gnu 0.52.6",
1594
- "windows_i686_gnullvm",
1923
+ "windows_i686_gnullvm 0.52.6",
1595
- "windows_i686_msvc",
1924
+ "windows_i686_msvc 0.52.6",
1596
- "windows_x86_64_gnu",
1925
+ "windows_x86_64_gnu 0.52.6",
1597
- "windows_x86_64_gnullvm",
1926
+ "windows_x86_64_gnullvm 0.52.6",
1598
- "windows_x86_64_msvc",
1927
+ "windows_x86_64_msvc 0.52.6",
1928
+]
1929
+
1930
+[[package]]
1931
+name = "windows-targets"
1932
+version = "0.53.5"
1933
+source = "registry+https://github.com/rust-lang/crates.io-index"
1934
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
1935
+dependencies = [
1936
+ "windows-link",
1937
+ "windows_aarch64_gnullvm 0.53.1",
1938
+ "windows_aarch64_msvc 0.53.1",
1939
+ "windows_i686_gnu 0.53.1",
1940
+ "windows_i686_gnullvm 0.53.1",
1941
+ "windows_i686_msvc 0.53.1",
1942
+ "windows_x86_64_gnu 0.53.1",
1943
+ "windows_x86_64_gnullvm 0.53.1",
1944
+ "windows_x86_64_msvc 0.53.1",
1599
 ]
1945
 ]
1600
 
1946
 
1601
 [[package]]
1947
 [[package]]
@@ -1604,48 +1950,96 @@ version = "0.52.6"
1604
 source = "registry+https://github.com/rust-lang/crates.io-index"
1950
 source = "registry+https://github.com/rust-lang/crates.io-index"
1605
 checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
1951
 checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
1606
 
1952
 
1953
+[[package]]
1954
+name = "windows_aarch64_gnullvm"
1955
+version = "0.53.1"
1956
+source = "registry+https://github.com/rust-lang/crates.io-index"
1957
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
1958
+
1607
 [[package]]
1959
 [[package]]
1608
 name = "windows_aarch64_msvc"
1960
 name = "windows_aarch64_msvc"
1609
 version = "0.52.6"
1961
 version = "0.52.6"
1610
 source = "registry+https://github.com/rust-lang/crates.io-index"
1962
 source = "registry+https://github.com/rust-lang/crates.io-index"
1611
 checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
1963
 checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
1612
 
1964
 
1965
+[[package]]
1966
+name = "windows_aarch64_msvc"
1967
+version = "0.53.1"
1968
+source = "registry+https://github.com/rust-lang/crates.io-index"
1969
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
1970
+
1613
 [[package]]
1971
 [[package]]
1614
 name = "windows_i686_gnu"
1972
 name = "windows_i686_gnu"
1615
 version = "0.52.6"
1973
 version = "0.52.6"
1616
 source = "registry+https://github.com/rust-lang/crates.io-index"
1974
 source = "registry+https://github.com/rust-lang/crates.io-index"
1617
 checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
1975
 checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
1618
 
1976
 
1977
+[[package]]
1978
+name = "windows_i686_gnu"
1979
+version = "0.53.1"
1980
+source = "registry+https://github.com/rust-lang/crates.io-index"
1981
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
1982
+
1619
 [[package]]
1983
 [[package]]
1620
 name = "windows_i686_gnullvm"
1984
 name = "windows_i686_gnullvm"
1621
 version = "0.52.6"
1985
 version = "0.52.6"
1622
 source = "registry+https://github.com/rust-lang/crates.io-index"
1986
 source = "registry+https://github.com/rust-lang/crates.io-index"
1623
 checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
1987
 checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
1624
 
1988
 
1989
+[[package]]
1990
+name = "windows_i686_gnullvm"
1991
+version = "0.53.1"
1992
+source = "registry+https://github.com/rust-lang/crates.io-index"
1993
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
1994
+
1625
 [[package]]
1995
 [[package]]
1626
 name = "windows_i686_msvc"
1996
 name = "windows_i686_msvc"
1627
 version = "0.52.6"
1997
 version = "0.52.6"
1628
 source = "registry+https://github.com/rust-lang/crates.io-index"
1998
 source = "registry+https://github.com/rust-lang/crates.io-index"
1629
 checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
1999
 checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
1630
 
2000
 
2001
+[[package]]
2002
+name = "windows_i686_msvc"
2003
+version = "0.53.1"
2004
+source = "registry+https://github.com/rust-lang/crates.io-index"
2005
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
2006
+
1631
 [[package]]
2007
 [[package]]
1632
 name = "windows_x86_64_gnu"
2008
 name = "windows_x86_64_gnu"
1633
 version = "0.52.6"
2009
 version = "0.52.6"
1634
 source = "registry+https://github.com/rust-lang/crates.io-index"
2010
 source = "registry+https://github.com/rust-lang/crates.io-index"
1635
 checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
2011
 checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
1636
 
2012
 
2013
+[[package]]
2014
+name = "windows_x86_64_gnu"
2015
+version = "0.53.1"
2016
+source = "registry+https://github.com/rust-lang/crates.io-index"
2017
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
2018
+
1637
 [[package]]
2019
 [[package]]
1638
 name = "windows_x86_64_gnullvm"
2020
 name = "windows_x86_64_gnullvm"
1639
 version = "0.52.6"
2021
 version = "0.52.6"
1640
 source = "registry+https://github.com/rust-lang/crates.io-index"
2022
 source = "registry+https://github.com/rust-lang/crates.io-index"
1641
 checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
2023
 checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
1642
 
2024
 
2025
+[[package]]
2026
+name = "windows_x86_64_gnullvm"
2027
+version = "0.53.1"
2028
+source = "registry+https://github.com/rust-lang/crates.io-index"
2029
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
2030
+
1643
 [[package]]
2031
 [[package]]
1644
 name = "windows_x86_64_msvc"
2032
 name = "windows_x86_64_msvc"
1645
 version = "0.52.6"
2033
 version = "0.52.6"
1646
 source = "registry+https://github.com/rust-lang/crates.io-index"
2034
 source = "registry+https://github.com/rust-lang/crates.io-index"
1647
 checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
2035
 checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
1648
 
2036
 
2037
+[[package]]
2038
+name = "windows_x86_64_msvc"
2039
+version = "0.53.1"
2040
+source = "registry+https://github.com/rust-lang/crates.io-index"
2041
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
2042
+
1649
 [[package]]
2043
 [[package]]
1650
 name = "winnow"
2044
 name = "winnow"
1651
 version = "0.7.14"
2045
 version = "0.7.14"
@@ -1655,6 +2049,12 @@ dependencies = [
1655
  "memchr",
2049
  "memchr",
1656
 ]
2050
 ]
1657
 
2051
 
2052
+[[package]]
2053
+name = "wit-bindgen"
2054
+version = "0.51.0"
2055
+source = "registry+https://github.com/rust-lang/crates.io-index"
2056
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
2057
+
1658
 [[package]]
2058
 [[package]]
1659
 name = "x11rb"
2059
 name = "x11rb"
1660
 version = "0.13.2"
2060
 version = "0.13.2"
@@ -1681,6 +2081,62 @@ version = "0.3.10"
1681
 source = "registry+https://github.com/rust-lang/crates.io-index"
2081
 source = "registry+https://github.com/rust-lang/crates.io-index"
1682
 checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b"
2082
 checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b"
1683
 
2083
 
2084
+[[package]]
2085
+name = "zbus"
2086
+version = "5.13.2"
2087
+source = "registry+https://github.com/rust-lang/crates.io-index"
2088
+checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1"
2089
+dependencies = [
2090
+ "async-broadcast",
2091
+ "async-recursion",
2092
+ "async-trait",
2093
+ "enumflags2",
2094
+ "event-listener",
2095
+ "futures-core",
2096
+ "futures-lite",
2097
+ "hex",
2098
+ "libc",
2099
+ "ordered-stream",
2100
+ "rustix",
2101
+ "serde",
2102
+ "serde_repr",
2103
+ "tokio",
2104
+ "tracing",
2105
+ "uds_windows",
2106
+ "uuid",
2107
+ "windows-sys 0.61.2",
2108
+ "winnow",
2109
+ "zbus_macros",
2110
+ "zbus_names",
2111
+ "zvariant",
2112
+]
2113
+
2114
+[[package]]
2115
+name = "zbus_macros"
2116
+version = "5.13.2"
2117
+source = "registry+https://github.com/rust-lang/crates.io-index"
2118
+checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1"
2119
+dependencies = [
2120
+ "proc-macro-crate",
2121
+ "proc-macro2",
2122
+ "quote",
2123
+ "syn",
2124
+ "zbus_names",
2125
+ "zvariant",
2126
+ "zvariant_utils",
2127
+]
2128
+
2129
+[[package]]
2130
+name = "zbus_names"
2131
+version = "4.3.1"
2132
+source = "registry+https://github.com/rust-lang/crates.io-index"
2133
+checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f"
2134
+dependencies = [
2135
+ "serde",
2136
+ "winnow",
2137
+ "zvariant",
2138
+]
2139
+
1684
 [[package]]
2140
 [[package]]
1685
 name = "zmij"
2141
 name = "zmij"
1686
 version = "1.0.16"
2142
 version = "1.0.16"
@@ -1701,3 +2157,43 @@ checksum = "2959ca473aae96a14ecedf501d20b3608d2825ba280d5adb57d651721885b0c2"
1701
 dependencies = [
2157
 dependencies = [
1702
  "zune-core",
2158
  "zune-core",
1703
 ]
2159
 ]
2160
+
2161
+[[package]]
2162
+name = "zvariant"
2163
+version = "5.9.2"
2164
+source = "registry+https://github.com/rust-lang/crates.io-index"
2165
+checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4"
2166
+dependencies = [
2167
+ "endi",
2168
+ "enumflags2",
2169
+ "serde",
2170
+ "winnow",
2171
+ "zvariant_derive",
2172
+ "zvariant_utils",
2173
+]
2174
+
2175
+[[package]]
2176
+name = "zvariant_derive"
2177
+version = "5.9.2"
2178
+source = "registry+https://github.com/rust-lang/crates.io-index"
2179
+checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c"
2180
+dependencies = [
2181
+ "proc-macro-crate",
2182
+ "proc-macro2",
2183
+ "quote",
2184
+ "syn",
2185
+ "zvariant_utils",
2186
+]
2187
+
2188
+[[package]]
2189
+name = "zvariant_utils"
2190
+version = "3.3.0"
2191
+source = "registry+https://github.com/rust-lang/crates.io-index"
2192
+checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9"
2193
+dependencies = [
2194
+ "proc-macro2",
2195
+ "quote",
2196
+ "serde",
2197
+ "syn",
2198
+ "winnow",
2199
+]
Cargo.tomlmodified
@@ -4,6 +4,7 @@ members = [
4
     "garfield",
4
     "garfield",
5
     "garfieldctl",
5
     "garfieldctl",
6
     "garfield-ipc",
6
     "garfield-ipc",
7
+    "garfield-portal",
7
 ]
8
 ]
8
 
9
 
9
 [workspace.package]
10
 [workspace.package]
@@ -58,5 +59,11 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png",
58
 poppler-rs = "0.25"
59
 poppler-rs = "0.25"
59
 cairo-rs = { version = "0.21", features = ["png"] }
60
 cairo-rs = { version = "0.21", features = ["png"] }
60
 
61
 
62
+# Async runtime
63
+tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "io-util", "sync"] }
64
+
65
+# D-Bus
66
+zbus = { version = "5", default-features = false, features = ["tokio"] }
67
+
61
 # IPC types (shared between crates)
68
 # IPC types (shared between crates)
62
 garfield-ipc = { path = "garfield-ipc" }
69
 garfield-ipc = { path = "garfield-ipc" }
garfield-portal/Cargo.tomladded
@@ -0,0 +1,28 @@
1
+[package]
2
+name = "garfield-portal"
3
+version.workspace = true
4
+edition.workspace = true
5
+authors.workspace = true
6
+license.workspace = true
7
+description = "XDG Desktop Portal backend for garfield file picker"
8
+
9
+[[bin]]
10
+name = "garfield-portal"
11
+path = "src/main.rs"
12
+
13
+[dependencies]
14
+# Async runtime
15
+tokio.workspace = true
16
+
17
+# D-Bus
18
+zbus.workspace = true
19
+
20
+# Logging
21
+tracing.workspace = true
22
+tracing-subscriber.workspace = true
23
+
24
+# Error handling
25
+anyhow.workspace = true
26
+
27
+# CLI
28
+clap.workspace = true
garfield-portal/README.mdadded
@@ -0,0 +1,146 @@
1
+# garfield-portal
2
+
3
+XDG Desktop Portal backend for garfield file picker.
4
+
5
+This daemon implements the `org.freedesktop.impl.portal.FileChooser` interface, allowing applications to use garfield as the system file picker dialog.
6
+
7
+## How It Works
8
+
9
+When an application (GTK, Qt, Flatpak, etc.) requests a file dialog through the portal API:
10
+
11
+1. `xdg-desktop-portal` receives the request
12
+2. It routes the request to `garfield-portal` based on configuration
13
+3. `garfield-portal` spawns `garfield --picker` with appropriate options
14
+4. User selects files in garfield
15
+5. Selected paths are returned to the application as `file://` URIs
16
+
17
+## Building
18
+
19
+```bash
20
+cargo build --release -p garfield-portal
21
+```
22
+
23
+## Installation
24
+
25
+### System-wide (recommended)
26
+
27
+```bash
28
+cd garfield-portal
29
+./install.sh
30
+```
31
+
32
+This installs:
33
+- Binary to `/usr/local/bin/garfield-portal`
34
+- Portal config to `/usr/share/xdg-desktop-portal/portals/garfield.portal`
35
+- Systemd service to `/usr/lib/systemd/user/garfield-portal.service`
36
+- Portal preferences to `/usr/share/xdg-desktop-portal/gar-portals.conf`
37
+
38
+### User-only
39
+
40
+```bash
41
+./install.sh --user
42
+```
43
+
44
+Installs to `~/.local/` directories instead.
45
+
46
+### Enable the Service
47
+
48
+After installation:
49
+
50
+```bash
51
+systemctl --user daemon-reload
52
+systemctl --user enable --now garfield-portal
53
+```
54
+
55
+## Testing
56
+
57
+Run the test script:
58
+
59
+```bash
60
+./test-portal.sh
61
+```
62
+
63
+Or test with real applications:
64
+
65
+```bash
66
+# GTK apps
67
+GTK_USE_PORTAL=1 gedit
68
+GTK_USE_PORTAL=1 firefox
69
+
70
+# Qt/KDE apps
71
+QT_QPA_PLATFORMTHEME=xdgdesktopportal dolphin
72
+
73
+# Flatpak apps (automatically use portals)
74
+flatpak run org.gnome.TextEditor
75
+```
76
+
77
+## Configuration
78
+
79
+### Making garfield the default
80
+
81
+For the gar desktop environment, the installer creates `gar-portals.conf`:
82
+
83
+```ini
84
+[preferred]
85
+default=garfield;gtk
86
+org.freedesktop.impl.portal.FileChooser=garfield
87
+```
88
+
89
+Set `XDG_CURRENT_DESKTOP=gar` in your session to activate this configuration.
90
+
91
+### Manual configuration
92
+
93
+To use garfield in other desktop environments, create `~/.config/xdg-desktop-portal/portals.conf`:
94
+
95
+```ini
96
+[preferred]
97
+default=garfield;gtk
98
+```
99
+
100
+Then restart xdg-desktop-portal:
101
+
102
+```bash
103
+systemctl --user restart xdg-desktop-portal
104
+```
105
+
106
+## Uninstallation
107
+
108
+```bash
109
+./install.sh --uninstall      # System-wide
110
+./install.sh --uninstall-user # User-only
111
+```
112
+
113
+## Troubleshooting
114
+
115
+### Check installation status
116
+
117
+```bash
118
+./install.sh --check
119
+```
120
+
121
+### View logs
122
+
123
+```bash
124
+journalctl --user -u garfield-portal -f
125
+```
126
+
127
+### Common issues
128
+
129
+**"Portal not found"**: Make sure `garfield-portal` is running:
130
+```bash
131
+systemctl --user status garfield-portal
132
+```
133
+
134
+**"garfield not found"**: The `garfield` binary must be in PATH for picker mode to work.
135
+
136
+**Dialog doesn't appear**: Check if `DISPLAY` is set and garfield can connect to X11.
137
+
138
+## D-Bus Interface
139
+
140
+The portal implements `org.freedesktop.impl.portal.FileChooser`:
141
+
142
+- `OpenFile(handle, app_id, parent_window, title, options)` - Open file dialog
143
+- `SaveFile(handle, app_id, parent_window, title, options)` - Save file dialog
144
+- `SaveFiles(handle, app_id, parent_window, title, options)` - Save multiple files
145
+
146
+See the [XDG Desktop Portal documentation](https://flatpak.github.io/xdg-desktop-portal/) for full details.
garfield-portal/data/gar-portals.confadded
@@ -0,0 +1,15 @@
1
+# XDG Desktop Portal configuration for gar desktop environment
2
+#
3
+# This file tells xdg-desktop-portal to prefer garfield for file chooser dialogs.
4
+# Install to: /usr/share/xdg-desktop-portal/gar-portals.conf
5
+#          or: ~/.config/xdg-desktop-portal/gar-portals.conf
6
+#
7
+# The desktop environment is detected from XDG_CURRENT_DESKTOP.
8
+# For gar, set: XDG_CURRENT_DESKTOP=gar
9
+
10
+[preferred]
11
+# Use garfield for file dialogs, fall back to gtk if garfield unavailable
12
+default=garfield;gtk
13
+
14
+# Explicitly prefer garfield for FileChooser interface
15
+org.freedesktop.impl.portal.FileChooser=garfield
garfield-portal/data/garfield-portal.serviceadded
@@ -0,0 +1,12 @@
1
+[Unit]
2
+Description=Garfield Portal Backend
3
+PartOf=graphical-session.target
4
+After=graphical-session.target
5
+
6
+[Service]
7
+Type=dbus
8
+BusName=org.freedesktop.impl.portal.desktop.garfield
9
+ExecStart=/usr/local/bin/garfield-portal
10
+
11
+[Install]
12
+WantedBy=xdg-desktop-portal.service
garfield-portal/data/garfield.portaladded
@@ -0,0 +1,4 @@
1
+[portal]
2
+DBusName=org.freedesktop.impl.portal.desktop.garfield
3
+Interfaces=org.freedesktop.impl.portal.FileChooser
4
+UseIn=gar
garfield-portal/install.shadded
@@ -0,0 +1,296 @@
1
+#!/bin/bash
2
+# Install script for garfield-portal
3
+#
4
+# This script installs the garfield portal backend which allows
5
+# applications to use garfield as the system file picker.
6
+#
7
+# Usage:
8
+#   ./install.sh         - Install to system (requires sudo)
9
+#   ./install.sh --user  - Install to user directory only
10
+#   ./install.sh --check - Check installation status
11
+
12
+set -e
13
+
14
+# Colors for output
15
+RED='\033[0;31m'
16
+GREEN='\033[0;32m'
17
+YELLOW='\033[1;33m'
18
+NC='\033[0m' # No Color
19
+
20
+# Directories
21
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
22
+BINARY_NAME="garfield-portal"
23
+GARFIELD_BINARY="garfield"
24
+PORTAL_FILE="garfield.portal"
25
+SERVICE_FILE="garfield-portal.service"
26
+
27
+# System paths
28
+SYS_BIN_DIR="/usr/local/bin"
29
+SYS_PORTAL_DIR="/usr/share/xdg-desktop-portal/portals"
30
+SYS_PORTALS_CONF_DIR="/usr/share/xdg-desktop-portal"
31
+
32
+# User paths
33
+USER_BIN_DIR="$HOME/.local/bin"
34
+USER_PORTAL_DIR="$HOME/.local/share/xdg-desktop-portal/portals"
35
+USER_SERVICE_DIR="$HOME/.config/systemd/user"
36
+USER_PORTALS_CONF_DIR="$HOME/.config/xdg-desktop-portal"
37
+
38
+print_status() {
39
+    echo -e "${GREEN}[OK]${NC} $1"
40
+}
41
+
42
+print_warning() {
43
+    echo -e "${YELLOW}[WARN]${NC} $1"
44
+}
45
+
46
+print_error() {
47
+    echo -e "${RED}[ERROR]${NC} $1"
48
+}
49
+
50
+check_binary() {
51
+    local name="$1"
52
+    local binary_path="$SCRIPT_DIR/../target/release/$name"
53
+    if [[ ! -f "$binary_path" ]]; then
54
+        binary_path="$SCRIPT_DIR/target/release/$name"
55
+    fi
56
+    if [[ ! -f "$binary_path" ]]; then
57
+        # Try workspace root
58
+        binary_path="$(dirname "$SCRIPT_DIR")/target/release/$name"
59
+    fi
60
+    if [[ ! -f "$binary_path" ]]; then
61
+        print_error "Binary '$name' not found. Please run 'cargo build --release' first."
62
+        exit 1
63
+    fi
64
+    echo "$binary_path"
65
+}
66
+
67
+install_system() {
68
+    echo "Installing garfield-portal (system-wide)..."
69
+
70
+    local portal_binary garfield_binary
71
+    portal_binary=$(check_binary "$BINARY_NAME")
72
+    garfield_binary=$(check_binary "$GARFIELD_BINARY")
73
+
74
+    # Install portal daemon binary
75
+    sudo install -Dm755 "$portal_binary" "$SYS_BIN_DIR/$BINARY_NAME"
76
+    print_status "Installed binary to $SYS_BIN_DIR/$BINARY_NAME"
77
+
78
+    # Install main garfield binary (required for picker mode)
79
+    sudo install -Dm755 "$garfield_binary" "$SYS_BIN_DIR/$GARFIELD_BINARY"
80
+    print_status "Installed binary to $SYS_BIN_DIR/$GARFIELD_BINARY"
81
+
82
+    # Install portal file
83
+    sudo install -Dm644 "$SCRIPT_DIR/data/$PORTAL_FILE" "$SYS_PORTAL_DIR/$PORTAL_FILE"
84
+    print_status "Installed portal config to $SYS_PORTAL_DIR/$PORTAL_FILE"
85
+
86
+    # Install systemd service (user service, but to system location)
87
+    sudo install -Dm644 "$SCRIPT_DIR/data/$SERVICE_FILE" "/usr/lib/systemd/user/$SERVICE_FILE"
88
+    print_status "Installed systemd service to /usr/lib/systemd/user/$SERVICE_FILE"
89
+
90
+    # Install gar-portals.conf
91
+    sudo install -Dm644 "$SCRIPT_DIR/data/gar-portals.conf" "$SYS_PORTALS_CONF_DIR/gar-portals.conf"
92
+    print_status "Installed portal preferences to $SYS_PORTALS_CONF_DIR/gar-portals.conf"
93
+
94
+    echo ""
95
+    echo "Installation complete!"
96
+    echo ""
97
+    echo "To enable the portal service, run:"
98
+    echo "  systemctl --user daemon-reload"
99
+    echo "  systemctl --user enable --now garfield-portal"
100
+    echo ""
101
+    echo "To test, run a GTK app with:"
102
+    echo "  GTK_USE_PORTAL=1 gedit"
103
+}
104
+
105
+install_user() {
106
+    echo "Installing garfield-portal (user only)..."
107
+
108
+    local portal_binary garfield_binary
109
+    portal_binary=$(check_binary "$BINARY_NAME")
110
+    garfield_binary=$(check_binary "$GARFIELD_BINARY")
111
+
112
+    # Create directories
113
+    mkdir -p "$USER_BIN_DIR"
114
+    mkdir -p "$USER_PORTAL_DIR"
115
+    mkdir -p "$USER_SERVICE_DIR"
116
+    mkdir -p "$USER_PORTALS_CONF_DIR"
117
+
118
+    # Install portal daemon binary
119
+    install -m755 "$portal_binary" "$USER_BIN_DIR/$BINARY_NAME"
120
+    print_status "Installed binary to $USER_BIN_DIR/$BINARY_NAME"
121
+
122
+    # Install main garfield binary (required for picker mode)
123
+    install -m755 "$garfield_binary" "$USER_BIN_DIR/$GARFIELD_BINARY"
124
+    print_status "Installed binary to $USER_BIN_DIR/$GARFIELD_BINARY"
125
+
126
+    # Install portal file
127
+    install -m644 "$SCRIPT_DIR/data/$PORTAL_FILE" "$USER_PORTAL_DIR/$PORTAL_FILE"
128
+    print_status "Installed portal config to $USER_PORTAL_DIR/$PORTAL_FILE"
129
+
130
+    # Install systemd service with modified path
131
+    sed "s|/usr/local/bin/garfield-portal|$USER_BIN_DIR/garfield-portal|g" \
132
+        "$SCRIPT_DIR/data/$SERVICE_FILE" > "$USER_SERVICE_DIR/$SERVICE_FILE"
133
+    print_status "Installed systemd service to $USER_SERVICE_DIR/$SERVICE_FILE"
134
+
135
+    # Install gar-portals.conf
136
+    install -m644 "$SCRIPT_DIR/data/gar-portals.conf" "$USER_PORTALS_CONF_DIR/gar-portals.conf"
137
+    print_status "Installed portal preferences to $USER_PORTALS_CONF_DIR/gar-portals.conf"
138
+
139
+    echo ""
140
+    echo "Installation complete!"
141
+    echo ""
142
+    echo "Make sure $USER_BIN_DIR is in your PATH."
143
+    echo ""
144
+    echo "To enable the portal service, run:"
145
+    echo "  systemctl --user daemon-reload"
146
+    echo "  systemctl --user enable --now garfield-portal"
147
+    echo ""
148
+    echo "To test, run a GTK app with:"
149
+    echo "  GTK_USE_PORTAL=1 gedit"
150
+}
151
+
152
+uninstall_system() {
153
+    echo "Uninstalling garfield-portal (system-wide)..."
154
+
155
+    # Stop service first
156
+    systemctl --user stop garfield-portal 2>/dev/null || true
157
+    systemctl --user disable garfield-portal 2>/dev/null || true
158
+
159
+    sudo rm -f "$SYS_BIN_DIR/$BINARY_NAME"
160
+    sudo rm -f "$SYS_BIN_DIR/$GARFIELD_BINARY"
161
+    sudo rm -f "/usr/local/sbin/$GARFIELD_BINARY"  # Clean up stray copy
162
+    sudo rm -f "$SYS_PORTAL_DIR/$PORTAL_FILE"
163
+    sudo rm -f "/usr/lib/systemd/user/$SERVICE_FILE"
164
+    sudo rm -f "$SYS_PORTALS_CONF_DIR/gar-portals.conf"
165
+
166
+    systemctl --user daemon-reload
167
+
168
+    print_status "Uninstalled garfield-portal"
169
+}
170
+
171
+uninstall_user() {
172
+    echo "Uninstalling garfield-portal (user only)..."
173
+
174
+    # Stop service first
175
+    systemctl --user stop garfield-portal 2>/dev/null || true
176
+    systemctl --user disable garfield-portal 2>/dev/null || true
177
+
178
+    rm -f "$USER_BIN_DIR/$BINARY_NAME"
179
+    rm -f "$USER_BIN_DIR/$GARFIELD_BINARY"
180
+    rm -f "$USER_PORTAL_DIR/$PORTAL_FILE"
181
+    rm -f "$USER_SERVICE_DIR/$SERVICE_FILE"
182
+    rm -f "$USER_PORTALS_CONF_DIR/gar-portals.conf"
183
+
184
+    systemctl --user daemon-reload
185
+
186
+    print_status "Uninstalled garfield-portal"
187
+}
188
+
189
+check_installation() {
190
+    echo "Checking garfield-portal installation..."
191
+    echo ""
192
+
193
+    # Check portal daemon binary
194
+    if command -v garfield-portal &> /dev/null; then
195
+        print_status "Portal binary found: $(command -v garfield-portal)"
196
+    elif [[ -f "$USER_BIN_DIR/$BINARY_NAME" ]]; then
197
+        print_warning "Portal binary found at $USER_BIN_DIR/$BINARY_NAME (not in PATH)"
198
+    elif [[ -f "$SYS_BIN_DIR/$BINARY_NAME" ]]; then
199
+        print_status "Portal binary found at $SYS_BIN_DIR/$BINARY_NAME"
200
+    else
201
+        print_error "Portal binary not found"
202
+    fi
203
+
204
+    # Check main garfield binary (required for picker mode)
205
+    local garfield_path
206
+    if [[ -f "$SYS_BIN_DIR/$GARFIELD_BINARY" ]]; then
207
+        garfield_path="$SYS_BIN_DIR/$GARFIELD_BINARY"
208
+    elif [[ -f "$USER_BIN_DIR/$GARFIELD_BINARY" ]]; then
209
+        garfield_path="$USER_BIN_DIR/$GARFIELD_BINARY"
210
+    elif command -v garfield &> /dev/null; then
211
+        garfield_path="$(command -v garfield)"
212
+    fi
213
+
214
+    if [[ -n "$garfield_path" ]]; then
215
+        # Check if garfield has picker mode support
216
+        if "$garfield_path" --help 2>&1 | grep -q "\-\-picker"; then
217
+            print_status "Garfield binary found with picker support: $garfield_path"
218
+        else
219
+            print_error "Garfield binary at $garfield_path does NOT have picker mode!"
220
+            print_error "The portal will not work. Please reinstall."
221
+        fi
222
+    else
223
+        print_error "Garfield binary not found (required for picker mode)"
224
+    fi
225
+
226
+    # Check portal config
227
+    if [[ -f "$SYS_PORTAL_DIR/$PORTAL_FILE" ]]; then
228
+        print_status "Portal config found: $SYS_PORTAL_DIR/$PORTAL_FILE"
229
+    elif [[ -f "$USER_PORTAL_DIR/$PORTAL_FILE" ]]; then
230
+        print_status "Portal config found: $USER_PORTAL_DIR/$PORTAL_FILE"
231
+    else
232
+        print_error "Portal config not found"
233
+    fi
234
+
235
+    # Check systemd service
236
+    if systemctl --user is-enabled garfield-portal &> /dev/null; then
237
+        print_status "Systemd service enabled"
238
+        if systemctl --user is-active garfield-portal &> /dev/null; then
239
+            print_status "Systemd service running"
240
+        else
241
+            print_warning "Systemd service not running"
242
+        fi
243
+    else
244
+        print_warning "Systemd service not enabled"
245
+    fi
246
+
247
+    # Check D-Bus name
248
+    if dbus-send --session --print-reply --dest=org.freedesktop.DBus \
249
+        /org/freedesktop/DBus org.freedesktop.DBus.NameHasOwner \
250
+        string:"org.freedesktop.impl.portal.desktop.garfield" 2>/dev/null | grep -q "true"; then
251
+        print_status "D-Bus service registered"
252
+    else
253
+        print_warning "D-Bus service not registered (portal may not be running)"
254
+    fi
255
+}
256
+
257
+show_help() {
258
+    echo "Usage: $0 [OPTION]"
259
+    echo ""
260
+    echo "Install garfield-portal as the system file picker."
261
+    echo ""
262
+    echo "Options:"
263
+    echo "  --user       Install to user directories only (no sudo required)"
264
+    echo "  --uninstall  Remove system installation"
265
+    echo "  --uninstall-user  Remove user installation"
266
+    echo "  --check      Check installation status"
267
+    echo "  --help       Show this help message"
268
+    echo ""
269
+    echo "Without options, installs system-wide (requires sudo)."
270
+}
271
+
272
+case "${1:-}" in
273
+    --user)
274
+        install_user
275
+        ;;
276
+    --uninstall)
277
+        uninstall_system
278
+        ;;
279
+    --uninstall-user)
280
+        uninstall_user
281
+        ;;
282
+    --check)
283
+        check_installation
284
+        ;;
285
+    --help|-h)
286
+        show_help
287
+        ;;
288
+    "")
289
+        install_system
290
+        ;;
291
+    *)
292
+        print_error "Unknown option: $1"
293
+        show_help
294
+        exit 1
295
+        ;;
296
+esac
garfield-portal/src/file_chooser.rsadded
@@ -0,0 +1,319 @@
1
+//! FileChooser portal interface implementation.
2
+//!
3
+//! Implements org.freedesktop.impl.portal.FileChooser by spawning
4
+//! garfield in picker mode.
5
+
6
+use crate::request::{build_file_chooser_response, Request, RequestManager, ResponseCode};
7
+use std::collections::HashMap;
8
+use std::process::Stdio;
9
+use tokio::io::{AsyncBufReadExt, BufReader};
10
+use tokio::process::Command;
11
+use zbus::zvariant::{ObjectPath, OwnedObjectPath, Value};
12
+use zbus::{fdo, interface};
13
+
14
+/// FileChooser portal backend.
15
+pub struct FileChooser {
16
+    request_manager: RequestManager,
17
+}
18
+
19
+impl FileChooser {
20
+    pub fn new() -> Self {
21
+        Self {
22
+            request_manager: RequestManager::new(),
23
+        }
24
+    }
25
+
26
+    /// Spawn garfield in picker mode and collect results.
27
+    async fn spawn_picker(
28
+        &self,
29
+        handle: OwnedObjectPath,
30
+        title: &str,
31
+        directory_mode: bool,
32
+        multiple: bool,
33
+        filters: Vec<String>,
34
+        current_folder: Option<String>,
35
+    ) -> (u32, HashMap<String, Value<'static>>) {
36
+        // Use full path to ensure we get the right garfield binary
37
+        // (user may have old version in ~/.cargo/bin before /usr/local/bin in PATH)
38
+        let garfield_path = if std::path::Path::new("/usr/local/bin/garfield").exists() {
39
+            "/usr/local/bin/garfield"
40
+        } else {
41
+            "garfield" // Fall back to PATH lookup
42
+        };
43
+
44
+        let mut cmd = Command::new(garfield_path);
45
+        cmd.arg("--picker");
46
+
47
+        if !title.is_empty() {
48
+            cmd.arg("--title").arg(title);
49
+        }
50
+
51
+        if directory_mode {
52
+            cmd.arg("--directory");
53
+        }
54
+
55
+        if multiple {
56
+            cmd.arg("--multiple");
57
+        }
58
+
59
+        if !filters.is_empty() {
60
+            cmd.arg("--filter").arg(filters.join(";"));
61
+        }
62
+
63
+        if let Some(folder) = current_folder {
64
+            cmd.arg(&folder);
65
+        }
66
+
67
+        cmd.stdout(Stdio::piped());
68
+        cmd.stderr(Stdio::inherit()); // Let garfield stderr through for debugging
69
+
70
+        tracing::info!("Spawning garfield picker: {:?}", cmd);
71
+
72
+        let mut child = match cmd.spawn() {
73
+            Ok(c) => c,
74
+            Err(e) => {
75
+                tracing::error!("Failed to spawn garfield: {}", e);
76
+                return (ResponseCode::Error as u32, HashMap::new());
77
+            }
78
+        };
79
+
80
+        tracing::info!("garfield spawned with PID {:?}", child.id());
81
+
82
+        // Get stdout handle before adding to manager (we need ownership)
83
+        let stdout = match child.stdout.take() {
84
+            Some(s) => s,
85
+            None => {
86
+                tracing::error!("Failed to get stdout from garfield");
87
+                return (ResponseCode::Error as u32, HashMap::new());
88
+            }
89
+        };
90
+
91
+        // Track the request for cancellation (child still runs, just stdout detached)
92
+        self.request_manager.add(handle.clone(), child).await;
93
+
94
+        // Wait for the child to exit by reading stdout until EOF
95
+        tracing::info!("Waiting for garfield to complete...");
96
+        let reader = BufReader::new(stdout);
97
+        let mut lines = reader.lines();
98
+        let mut paths = Vec::new();
99
+
100
+        while let Ok(Some(line)) = lines.next_line().await {
101
+            if !line.is_empty() {
102
+                tracing::debug!("garfield output: {}", line);
103
+                paths.push(line);
104
+            }
105
+        }
106
+
107
+        tracing::info!("garfield stdout closed, got {} paths", paths.len());
108
+
109
+        // Now remove from manager and check status
110
+        let request = self.request_manager.remove(&handle.as_ref()).await;
111
+
112
+        // Check if cancelled
113
+        if let Some(req) = &request {
114
+            if req.cancelled {
115
+                tracing::info!("Request was cancelled");
116
+                return (ResponseCode::Cancelled as u32, HashMap::new());
117
+            }
118
+        }
119
+
120
+        // If we got paths, it's a success
121
+        if paths.is_empty() {
122
+            tracing::info!("No paths selected, treating as cancelled");
123
+            (ResponseCode::Cancelled as u32, HashMap::new())
124
+        } else {
125
+            tracing::info!("Returning {} selected paths", paths.len());
126
+            (ResponseCode::Success as u32, build_file_chooser_response(paths))
127
+        }
128
+    }
129
+
130
+    /// Parse filter options from the portal format.
131
+    fn parse_filters(options: &HashMap<&str, Value<'_>>) -> Vec<String> {
132
+        // Portal filters are: a(sa(us)) - array of (name, array of (type, pattern))
133
+        // For now, we'll extract glob patterns
134
+        let mut result = Vec::new();
135
+
136
+        if let Some(Value::Array(filters_array)) = options.get("filters") {
137
+            for filter in filters_array.iter() {
138
+                // Each filter is (name, patterns_array)
139
+                if let Value::Structure(s) = filter {
140
+                    let fields = s.fields();
141
+                    if fields.len() >= 2 {
142
+                        if let Value::Array(patterns) = &fields[1] {
143
+                            for pattern in patterns.iter() {
144
+                                // Each pattern is (type, pattern_string)
145
+                                // type 0 = glob, type 1 = mime
146
+                                if let Value::Structure(ps) = pattern {
147
+                                    let pfields = ps.fields();
148
+                                    if pfields.len() >= 2 {
149
+                                        if let (Value::U32(0), Value::Str(glob)) = (&pfields[0], &pfields[1]) {
150
+                                            result.push(glob.to_string());
151
+                                        }
152
+                                    }
153
+                                }
154
+                            }
155
+                        }
156
+                    }
157
+                }
158
+            }
159
+        }
160
+
161
+        result
162
+    }
163
+
164
+    /// Extract current folder from options.
165
+    fn parse_current_folder(options: &HashMap<&str, Value<'_>>) -> Option<String> {
166
+        if let Some(Value::Array(bytes)) = options.get("current_folder") {
167
+            // current_folder is a byte array (path as bytes)
168
+            let path_bytes: Vec<u8> = bytes.iter()
169
+                .filter_map(|v| {
170
+                    if let Value::U8(b) = v {
171
+                        Some(*b)
172
+                    } else {
173
+                        None
174
+                    }
175
+                })
176
+                .collect();
177
+
178
+            // Remove trailing null if present
179
+            let path_bytes: Vec<u8> = path_bytes.into_iter()
180
+                .take_while(|&b| b != 0)
181
+                .collect();
182
+
183
+            String::from_utf8(path_bytes).ok()
184
+        } else {
185
+            None
186
+        }
187
+    }
188
+}
189
+
190
+#[interface(name = "org.freedesktop.impl.portal.FileChooser")]
191
+impl FileChooser {
192
+    /// Open a file chooser dialog.
193
+    ///
194
+    /// Portal method for opening files.
195
+    async fn open_file(
196
+        &self,
197
+        #[zbus(object_server)] server: &zbus::ObjectServer,
198
+        handle: ObjectPath<'_>,
199
+        _app_id: &str,
200
+        _parent_window: &str,
201
+        title: &str,
202
+        options: HashMap<&str, Value<'_>>,
203
+    ) -> fdo::Result<(u32, HashMap<String, Value<'static>>)> {
204
+        tracing::info!("OpenFile request: handle={}, title={}", handle, title);
205
+
206
+        let handle_owned: OwnedObjectPath = handle.into();
207
+
208
+        // Parse options
209
+        let multiple = options.get("multiple")
210
+            .and_then(|v| if let Value::Bool(b) = v { Some(*b) } else { None })
211
+            .unwrap_or(false);
212
+
213
+        let directory = options.get("directory")
214
+            .and_then(|v| if let Value::Bool(b) = v { Some(*b) } else { None })
215
+            .unwrap_or(false);
216
+
217
+        let filters = Self::parse_filters(&options);
218
+        let current_folder = Self::parse_current_folder(&options);
219
+
220
+        tracing::debug!("Registering request object at {}", handle_owned);
221
+
222
+        // Register request object for cancellation
223
+        let request = Request::new(handle_owned.clone(), self.request_manager.clone());
224
+        server.at(handle_owned.as_ref(), request).await
225
+            .map_err(|e| fdo::Error::Failed(format!("Failed to register request: {}", e)))?;
226
+
227
+        tracing::debug!("Request object registered, spawning picker");
228
+
229
+        // Spawn picker and wait for result
230
+        let result = self.spawn_picker(
231
+            handle_owned.clone(),
232
+            title,
233
+            directory,
234
+            multiple,
235
+            filters,
236
+            current_folder,
237
+        ).await;
238
+
239
+        tracing::debug!("Picker returned: {:?}", result.0);
240
+
241
+        // Remove request object
242
+        let _ = server.remove::<Request, _>(&handle_owned).await;
243
+
244
+        tracing::info!("OpenFile returning response code {}", result.0);
245
+        Ok(result)
246
+    }
247
+
248
+    /// Save a file dialog.
249
+    ///
250
+    /// Portal method for saving files.
251
+    async fn save_file(
252
+        &self,
253
+        #[zbus(object_server)] server: &zbus::ObjectServer,
254
+        handle: ObjectPath<'_>,
255
+        _app_id: &str,
256
+        _parent_window: &str,
257
+        title: &str,
258
+        options: HashMap<&str, Value<'_>>,
259
+    ) -> fdo::Result<(u32, HashMap<String, Value<'static>>)> {
260
+        tracing::info!("SaveFile request: handle={}, title={}", handle, title);
261
+
262
+        // For now, save dialogs work like open dialogs but for directories
263
+        // A full implementation would show a save dialog with filename input
264
+        let handle_owned: OwnedObjectPath = handle.into();
265
+        let current_folder = Self::parse_current_folder(&options);
266
+
267
+        let request = Request::new(handle_owned.clone(), self.request_manager.clone());
268
+        server.at(handle_owned.as_ref(), request).await
269
+            .map_err(|e| fdo::Error::Failed(format!("Failed to register request: {}", e)))?;
270
+
271
+        // For save, we pick a directory and the caller handles the filename
272
+        let result = self.spawn_picker(
273
+            handle_owned.clone(),
274
+            title,
275
+            true, // directory mode for save location
276
+            false,
277
+            Vec::new(),
278
+            current_folder,
279
+        ).await;
280
+
281
+        let _ = server.remove::<Request, _>(&handle_owned).await;
282
+
283
+        Ok(result)
284
+    }
285
+
286
+    /// Save multiple files.
287
+    async fn save_files(
288
+        &self,
289
+        #[zbus(object_server)] server: &zbus::ObjectServer,
290
+        handle: ObjectPath<'_>,
291
+        _app_id: &str,
292
+        _parent_window: &str,
293
+        title: &str,
294
+        options: HashMap<&str, Value<'_>>,
295
+    ) -> fdo::Result<(u32, HashMap<String, Value<'static>>)> {
296
+        tracing::info!("SaveFiles request: handle={}, title={}", handle, title);
297
+
298
+        // SaveFiles picks a directory for saving multiple files
299
+        let handle_owned: OwnedObjectPath = handle.into();
300
+        let current_folder = Self::parse_current_folder(&options);
301
+
302
+        let request = Request::new(handle_owned.clone(), self.request_manager.clone());
303
+        server.at(handle_owned.as_ref(), request).await
304
+            .map_err(|e| fdo::Error::Failed(format!("Failed to register request: {}", e)))?;
305
+
306
+        let result = self.spawn_picker(
307
+            handle_owned.clone(),
308
+            title,
309
+            true,
310
+            false,
311
+            Vec::new(),
312
+            current_folder,
313
+        ).await;
314
+
315
+        let _ = server.remove::<Request, _>(&handle_owned).await;
316
+
317
+        Ok(result)
318
+    }
319
+}
garfield-portal/src/main.rsadded
@@ -0,0 +1,56 @@
1
+//! garfield-portal - XDG Desktop Portal backend for garfield file picker.
2
+//!
3
+//! This daemon implements the org.freedesktop.impl.portal.FileChooser interface,
4
+//! spawning garfield in picker mode when applications request file dialogs.
5
+
6
+mod file_chooser;
7
+mod request;
8
+
9
+use anyhow::Result;
10
+use clap::Parser;
11
+use tracing_subscriber::{fmt, EnvFilter};
12
+use zbus::connection::Builder;
13
+
14
+/// garfield-portal - XDG Desktop Portal backend
15
+#[derive(Parser, Debug)]
16
+#[command(name = "garfield-portal", about = "XDG Desktop Portal backend for garfield")]
17
+struct Args {
18
+    /// Run in foreground (don't daemonize)
19
+    #[arg(long, short = 'f')]
20
+    foreground: bool,
21
+}
22
+
23
+#[tokio::main]
24
+async fn main() -> Result<()> {
25
+    // Initialize logging
26
+    let filter = EnvFilter::try_from_default_env()
27
+        .unwrap_or_else(|_| EnvFilter::new("info"));
28
+
29
+    fmt()
30
+        .with_env_filter(filter)
31
+        .with_target(false)
32
+        .init();
33
+
34
+    let _args = Args::parse();
35
+
36
+    tracing::info!("Starting garfield-portal");
37
+
38
+    // Create the FileChooser interface
39
+    let file_chooser = file_chooser::FileChooser::new();
40
+
41
+    // Connect to session bus and serve the interface
42
+    let _connection = Builder::session()?
43
+        .name("org.freedesktop.impl.portal.desktop.garfield")?
44
+        .serve_at("/org/freedesktop/portal/desktop", file_chooser)?
45
+        .build()
46
+        .await?;
47
+
48
+    tracing::info!("garfield-portal listening on D-Bus session bus");
49
+
50
+    // Keep the service running
51
+    loop {
52
+        // The connection handles incoming method calls automatically
53
+        // We just need to keep the main task alive
54
+        tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
55
+    }
56
+}
garfield-portal/src/request.rsadded
@@ -0,0 +1,117 @@
1
+//! Request tracking for portal dialogs.
2
+//!
3
+//! Each portal request gets a unique handle path and can be cancelled.
4
+
5
+use std::collections::HashMap;
6
+use std::sync::Arc;
7
+use tokio::process::Child;
8
+use tokio::sync::Mutex;
9
+use zbus::zvariant::{ObjectPath, OwnedObjectPath, Value};
10
+use zbus::{interface, fdo};
11
+
12
+/// A pending file chooser request.
13
+pub struct PendingRequest {
14
+    /// The child process running garfield.
15
+    pub child: Child,
16
+    /// Whether the request has been cancelled.
17
+    pub cancelled: bool,
18
+}
19
+
20
+/// Manages pending requests.
21
+#[derive(Clone, Default)]
22
+pub struct RequestManager {
23
+    requests: Arc<Mutex<HashMap<OwnedObjectPath, PendingRequest>>>,
24
+}
25
+
26
+impl RequestManager {
27
+    pub fn new() -> Self {
28
+        Self::default()
29
+    }
30
+
31
+    /// Add a new pending request.
32
+    pub async fn add(&self, handle: OwnedObjectPath, child: Child) {
33
+        let mut requests = self.requests.lock().await;
34
+        requests.insert(handle, PendingRequest {
35
+            child,
36
+            cancelled: false,
37
+        });
38
+    }
39
+
40
+    /// Remove a request and return it.
41
+    pub async fn remove(&self, handle: &ObjectPath<'_>) -> Option<PendingRequest> {
42
+        let mut requests = self.requests.lock().await;
43
+        requests.remove(&handle.to_owned())
44
+    }
45
+
46
+    /// Cancel a request by killing the child process.
47
+    pub async fn cancel(&self, handle: &ObjectPath<'_>) -> bool {
48
+        let mut requests = self.requests.lock().await;
49
+        if let Some(request) = requests.get_mut(&handle.to_owned()) {
50
+            request.cancelled = true;
51
+            let _ = request.child.kill().await;
52
+            true
53
+        } else {
54
+            false
55
+        }
56
+    }
57
+
58
+    /// Check if a request was cancelled.
59
+    #[allow(dead_code)]
60
+    pub async fn is_cancelled(&self, handle: &ObjectPath<'_>) -> bool {
61
+        let requests = self.requests.lock().await;
62
+        requests.get(&handle.to_owned())
63
+            .map(|r| r.cancelled)
64
+            .unwrap_or(false)
65
+    }
66
+}
67
+
68
+/// Request object that can be exported on D-Bus for cancellation.
69
+pub struct Request {
70
+    handle: OwnedObjectPath,
71
+    manager: RequestManager,
72
+}
73
+
74
+impl Request {
75
+    pub fn new(handle: OwnedObjectPath, manager: RequestManager) -> Self {
76
+        Self { handle, manager }
77
+    }
78
+}
79
+
80
+#[interface(name = "org.freedesktop.impl.portal.Request")]
81
+impl Request {
82
+    /// Close/cancel the request.
83
+    async fn close(&self) -> fdo::Result<()> {
84
+        // Log with backtrace info
85
+        tracing::warn!("Close() called on request: {}", self.handle);
86
+        let cancelled = self.manager.cancel(&self.handle.as_ref()).await;
87
+        tracing::warn!("Cancel result: {} (true = child was killed)", cancelled);
88
+        Ok(())
89
+    }
90
+}
91
+
92
+/// Response codes for portal dialogs.
93
+#[repr(u32)]
94
+pub enum ResponseCode {
95
+    /// User accepted the dialog.
96
+    Success = 0,
97
+    /// User cancelled the dialog.
98
+    Cancelled = 1,
99
+    /// An error occurred.
100
+    Error = 2,
101
+}
102
+
103
+/// Build response results for file chooser.
104
+pub fn build_file_chooser_response(
105
+    paths: Vec<String>,
106
+) -> HashMap<String, Value<'static>> {
107
+    let mut results = HashMap::new();
108
+
109
+    // Convert paths to file:// URIs
110
+    let uris: Vec<Value> = paths
111
+        .into_iter()
112
+        .map(|p| Value::from(format!("file://{}", p)))
113
+        .collect();
114
+
115
+    results.insert("uris".to_string(), Value::Array(uris.into()));
116
+    results
117
+}
garfield-portal/test-portal.shadded
@@ -0,0 +1,76 @@
1
+#!/bin/bash
2
+# Test script for garfield-portal
3
+#
4
+# This script verifies that the portal is working correctly.
5
+
6
+set -e
7
+
8
+RED='\033[0;31m'
9
+GREEN='\033[0;32m'
10
+YELLOW='\033[1;33m'
11
+NC='\033[0m'
12
+
13
+print_status() { echo -e "${GREEN}[PASS]${NC} $1"; }
14
+print_warning() { echo -e "${YELLOW}[WARN]${NC} $1"; }
15
+print_error() { echo -e "${RED}[FAIL]${NC} $1"; }
16
+
17
+echo "Testing garfield-portal..."
18
+echo ""
19
+
20
+# Test 1: Check if D-Bus name is registered
21
+echo "1. Checking D-Bus registration..."
22
+if busctl --user status org.freedesktop.impl.portal.desktop.garfield &>/dev/null; then
23
+    print_status "D-Bus name registered"
24
+else
25
+    print_error "D-Bus name not registered"
26
+    echo "   Try: systemctl --user start garfield-portal"
27
+    exit 1
28
+fi
29
+
30
+# Test 2: Check if FileChooser interface is available
31
+echo ""
32
+echo "2. Checking FileChooser interface..."
33
+if busctl --user introspect org.freedesktop.impl.portal.desktop.garfield /org/freedesktop/portal/desktop 2>/dev/null | grep -q "OpenFile"; then
34
+    print_status "FileChooser.OpenFile method available"
35
+else
36
+    print_error "FileChooser interface not found"
37
+    exit 1
38
+fi
39
+
40
+# Test 3: Check portal config
41
+echo ""
42
+echo "3. Checking portal configuration..."
43
+if [[ -f /usr/share/xdg-desktop-portal/portals/garfield.portal ]] || \
44
+   [[ -f ~/.local/share/xdg-desktop-portal/portals/garfield.portal ]]; then
45
+    print_status "Portal config file exists"
46
+else
47
+    print_error "Portal config not found"
48
+    exit 1
49
+fi
50
+
51
+# Test 4: Check if garfield binary is available
52
+echo ""
53
+echo "4. Checking garfield binary..."
54
+if command -v garfield &>/dev/null; then
55
+    print_status "garfield binary found: $(command -v garfield)"
56
+else
57
+    print_warning "garfield not in PATH (needed for picker mode)"
58
+fi
59
+
60
+echo ""
61
+echo "All tests passed!"
62
+echo ""
63
+echo "To test with a real application:"
64
+echo ""
65
+echo "  # With GTK apps:"
66
+echo "  GTK_USE_PORTAL=1 gedit"
67
+echo "  GTK_USE_PORTAL=1 firefox"
68
+echo ""
69
+echo "  # With Qt apps (KDE):"
70
+echo "  QT_QPA_PLATFORMTHEME=xdgdesktopportal dolphin"
71
+echo ""
72
+echo "  # With Flatpak apps:"
73
+echo "  flatpak run org.gnome.TextEditor"
74
+echo ""
75
+echo "To use garfield as the default file picker in gar desktop,"
76
+echo "set XDG_CURRENT_DESKTOP=gar in your session."
garfield/Cargo.tomlmodified
@@ -42,6 +42,9 @@ freedesktop_entry_parser.workspace = true
42
 # Fuzzy matching
42
 # Fuzzy matching
43
 nucleo-matcher.workspace = true
43
 nucleo-matcher.workspace = true
44
 
44
 
45
+# CLI
46
+clap.workspace = true
47
+
45
 # IPC
48
 # IPC
46
 garfield-ipc.workspace = true
49
 garfield-ipc.workspace = true
47
 
50
 
garfield/src/app.rsmodified
@@ -1,12 +1,13 @@
1
 //! Application state and event loop.
1
 //! Application state and event loop.
2
 
2
 
3
+use crate::PickerConfig;
3
 use garfield::core::{
4
 use garfield::core::{
4
     Clipboard, ClipboardOperation, DragTarget, FileOperation, FileDragController, ImagePreviewLoader, PdfPreviewLoader, PreviewLoader, UndoStack,
5
     Clipboard, ClipboardOperation, DragTarget, FileOperation, FileDragController, ImagePreviewLoader, PdfPreviewLoader, PreviewLoader, UndoStack,
5
     copy_files, move_files, delete_files, create_directory,
6
     copy_files, move_files, delete_files, create_directory,
6
-    trash_files, restore_from_trash,
7
+    trash_files, restore_from_trash, matches_any_filter,
7
 };
8
 };
8
 use garfield::ui::pane::SplitDirection;
9
 use garfield::ui::pane::SplitDirection;
9
-use garfield::ui::{AddressBar, AppPickerDialog, AppPickerResult, Breadcrumb, ConfirmDialog, ConflictAction, ConflictDialog, ContextMenu, ContextMenuAction, ContextType, DialogResult, HelpModal, IconSize, InputDialog, InputResult, Pane, ProgressDialog, Sidebar, StatusBar, TabBar, TabInfo, Toolbar, ToolbarAction, ViewMode, TAB_BAR_HEIGHT, TOOLBAR_HEIGHT};
10
+use garfield::ui::{AddressBar, AppPickerDialog, AppPickerResult, Breadcrumb, ConfirmDialog, ConflictAction, ConflictDialog, ContextMenu, ContextMenuAction, ContextType, DialogResult, HelpModal, IconSize, InputDialog, InputResult, Pane, PaneToolbarClick, PickerToolbar, PickerToolbarClick, ProgressDialog, Sidebar, StatusBar, TabBar, TabBarClickResult, TabInfo, Toolbar, ToolbarAction, ViewMode, TAB_BAR_HEIGHT, TOOLBAR_HEIGHT, PICKER_TOOLBAR_HEIGHT};
10
 use anyhow::Result;
11
 use anyhow::Result;
11
 use gartk_core::{InputEvent, Key, MouseButton, Point, Rect, Theme};
12
 use gartk_core::{InputEvent, Key, MouseButton, Point, Rect, Theme};
12
 use gartk_render::{Renderer, TextStyle};
13
 use gartk_render::{Renderer, TextStyle};
@@ -104,6 +105,10 @@ pub struct App {
104
     pdf_preview_loader: PdfPreviewLoader,
105
     pdf_preview_loader: PdfPreviewLoader,
105
     /// X11 clipboard manager for system clipboard integration.
106
     /// X11 clipboard manager for system clipboard integration.
106
     x11_clipboard: ClipboardManager,
107
     x11_clipboard: ClipboardManager,
108
+    /// Picker mode configuration (None for normal browser).
109
+    picker_config: PickerConfig,
110
+    /// Picker toolbar (only used in picker mode).
111
+    picker_toolbar: Option<PickerToolbar>,
107
 }
112
 }
108
 
113
 
109
 /// State for a paste operation with conflicts.
114
 /// State for a paste operation with conflicts.
@@ -120,31 +125,48 @@ struct PendingPaste {
120
 
125
 
121
 impl App {
126
 impl App {
122
     /// Create a new application.
127
     /// Create a new application.
123
-    pub fn new(start_dir: Option<PathBuf>) -> Result<Self> {
128
+    pub fn new(start_dir: Option<PathBuf>, picker_config: PickerConfig) -> Result<Self> {
124
         // Connect to X11
129
         // Connect to X11
125
         let conn = Connection::connect(None)?;
130
         let conn = Connection::connect(None)?;
126
 
131
 
127
         // Get primary monitor for window sizing
132
         // Get primary monitor for window sizing
128
         let monitor = gartk_x11::primary_monitor(&conn)?;
133
         let monitor = gartk_x11::primary_monitor(&conn)?;
129
 
134
 
130
-        // Calculate window size (70% of screen)
135
+        // Calculate window size (70% of screen, smaller for picker mode)
131
-        let width = (monitor.rect.width as f64 * 0.7) as u32;
136
+        let scale = if picker_config.is_picker() { 0.5 } else { 0.7 };
132
-        let height = (monitor.rect.height as f64 * 0.7) as u32;
137
+        let width = (monitor.rect.width as f64 * scale) as u32;
138
+        let height = (monitor.rect.height as f64 * scale) as u32;
133
         let x = monitor.rect.x + (monitor.rect.width as i32 - width as i32) / 2;
139
         let x = monitor.rect.x + (monitor.rect.width as i32 - width as i32) / 2;
134
         let y = monitor.rect.y + (monitor.rect.height as i32 - height as i32) / 2;
140
         let y = monitor.rect.y + (monitor.rect.height as i32 - height as i32) / 2;
135
 
141
 
136
-        // Create window
142
+        // Window title depends on mode
137
-        let window = Window::create(
143
+        let title = if picker_config.is_picker() {
138
-            conn.clone(),
144
+            picker_config.title.clone().unwrap_or_else(|| {
139
-            WindowConfig::default()
145
+                if picker_config.mode.is_directory_mode() {
140
-                .title("garfield")
146
+                    "Select Folder".to_string()
147
+                } else {
148
+                    "Open File".to_string()
149
+                }
150
+            })
151
+        } else {
152
+            "garfield".to_string()
153
+        };
154
+
155
+        // Create window - use Dialog type for picker mode
156
+        let mut window_config = WindowConfig::default()
157
+            .title(&title)
141
             .class("garfield")
158
             .class("garfield")
142
             .position(x, y)
159
             .position(x, y)
143
             .size(width, height)
160
             .size(width, height)
144
-                .transparent(false),
161
+            .transparent(false);
145
-        )?;
146
 
162
 
147
-        window.focus()?;
163
+        // Use Dialog window type for picker mode (better focus handling)
164
+        if picker_config.is_picker() {
165
+            window_config = window_config.window_type(gartk_x11::WindowType::Dialog);
166
+        }
167
+
168
+        let window = Window::create(conn.clone(), window_config)?;
169
+        conn.flush()?;
148
 
170
 
149
         // Create X11 clipboard manager for system clipboard integration
171
         // Create X11 clipboard manager for system clipboard integration
150
         let x11_clipboard = ClipboardManager::new(conn.clone(), window.id())?;
172
         let x11_clipboard = ClipboardManager::new(conn.clone(), window.id())?;
@@ -174,6 +196,26 @@ impl App {
174
         );
196
         );
175
         let toolbar = Toolbar::new(toolbar_bounds);
197
         let toolbar = Toolbar::new(toolbar_bounds);
176
 
198
 
199
+        // Create picker toolbar at BOTTOM of window (only if in picker mode)
200
+        let picker_toolbar = if picker_config.is_picker() {
201
+            let picker_toolbar_bounds = Rect::new(
202
+                sidebar_w as i32,
203
+                (height - PICKER_TOOLBAR_HEIGHT) as i32,
204
+                width - sidebar_w,
205
+                PICKER_TOOLBAR_HEIGHT,
206
+            );
207
+            let mut pt = PickerToolbar::new(picker_toolbar_bounds, picker_config.accept_label.clone());
208
+            // Set filter description if we have filters
209
+            let filters = picker_config.mode.filters();
210
+            if !filters.is_empty() {
211
+                let desc = format!("Filter: {}", filters.join(", "));
212
+                pt.set_filter_description(Some(desc));
213
+            }
214
+            Some(pt)
215
+        } else {
216
+            None
217
+        };
218
+
177
         // Create breadcrumb (below toolbar)
219
         // Create breadcrumb (below toolbar)
178
         let breadcrumb_bounds = Rect::new(
220
         let breadcrumb_bounds = Rect::new(
179
             sidebar_w as i32,
221
             sidebar_w as i32,
@@ -227,11 +269,17 @@ impl App {
227
         let app_picker = AppPickerDialog::new(Rect::new(0, 0, width, height));
269
         let app_picker = AppPickerDialog::new(Rect::new(0, 0, width, height));
228
 
270
 
229
         // Content area bounds (for panes)
271
         // Content area bounds (for panes)
272
+        // In picker mode, picker toolbar replaces status bar at bottom
273
+        let footer_height = if picker_config.is_picker() {
274
+            PICKER_TOOLBAR_HEIGHT
275
+        } else {
276
+            STATUS_BAR_HEIGHT
277
+        };
230
         let content_bounds = Rect::new(
278
         let content_bounds = Rect::new(
231
             sidebar_w as i32,
279
             sidebar_w as i32,
232
             header_height as i32,
280
             header_height as i32,
233
             width - sidebar_w,
281
             width - sidebar_w,
234
-            height - header_height - STATUS_BAR_HEIGHT,
282
+            height - header_height - footer_height,
235
         );
283
         );
236
 
284
 
237
         // Create root pane with initial tab
285
         // Create root pane with initial tab
@@ -286,6 +334,8 @@ impl App {
286
             image_preview_loader: ImagePreviewLoader::new(),
334
             image_preview_loader: ImagePreviewLoader::new(),
287
             pdf_preview_loader: PdfPreviewLoader::new(),
335
             pdf_preview_loader: PdfPreviewLoader::new(),
288
             x11_clipboard,
336
             x11_clipboard,
337
+            picker_config,
338
+            picker_toolbar,
289
         };
339
         };
290
 
340
 
291
         app.update_status_bar();
341
         app.update_status_bar();
@@ -303,6 +353,96 @@ impl App {
303
         self.root_pane.leaf_by_id_mut(self.focused_pane_id)
353
         self.root_pane.leaf_by_id_mut(self.focused_pane_id)
304
     }
354
     }
305
 
355
 
356
+    /// Check if the current selection is valid for picker mode.
357
+    fn has_valid_picker_selection(&self) -> bool {
358
+        let Some(pane) = self.focused_pane() else {
359
+            return false;
360
+        };
361
+        let Some(tab) = pane.active_tab() else {
362
+            return false;
363
+        };
364
+
365
+        let selected = tab.selected_entries();
366
+        let filters = self.picker_config.mode.filters();
367
+
368
+        // In directory mode, we can always accept (use current directory if nothing selected)
369
+        if self.picker_config.mode.is_directory_mode() {
370
+            // If nothing selected, the current directory is the selection
371
+            if selected.is_empty() {
372
+                return true;
373
+            }
374
+            // Otherwise, at least one directory must be selected
375
+            return selected.iter().any(|e| e.is_dir());
376
+        }
377
+
378
+        // In file mode, we need at least one file selected that matches filters
379
+        // If multiple is disabled, we need exactly one matching file
380
+        let matching_file_count = selected.iter()
381
+            .filter(|e| !e.is_dir() && matches_any_filter(e, filters))
382
+            .count();
383
+
384
+        if matching_file_count == 0 {
385
+            return false;
386
+        }
387
+
388
+        if !self.picker_config.mode.allows_multiple() && matching_file_count > 1 {
389
+            return false;
390
+        }
391
+
392
+        true
393
+    }
394
+
395
+    /// Get the selected paths for picker mode.
396
+    fn get_picker_selection(&self) -> Vec<PathBuf> {
397
+        let Some(pane) = self.focused_pane() else {
398
+            return Vec::new();
399
+        };
400
+        let Some(tab) = pane.active_tab() else {
401
+            return Vec::new();
402
+        };
403
+
404
+        let filters = self.picker_config.mode.filters();
405
+
406
+        if self.picker_config.mode.is_directory_mode() {
407
+            // In directory mode, return selected directories or current directory
408
+            let dirs: Vec<_> = tab.selected_entries().iter()
409
+                .filter(|e| e.is_dir())
410
+                .map(|e| e.path.clone())
411
+                .collect();
412
+
413
+            if dirs.is_empty() {
414
+                // Return current directory
415
+                vec![tab.current_path().to_path_buf()]
416
+            } else {
417
+                dirs
418
+            }
419
+        } else {
420
+            // In file mode, return selected files that match filters
421
+            tab.selected_entries().iter()
422
+                .filter(|e| !e.is_dir() && matches_any_filter(e, filters))
423
+                .map(|e| e.path.clone())
424
+                .collect()
425
+        }
426
+    }
427
+
428
+    /// Output picker selection and exit.
429
+    fn accept_picker_selection(&mut self) {
430
+        let paths = self.get_picker_selection();
431
+
432
+        // Output paths to stdout (one per line)
433
+        for path in &paths {
434
+            println!("{}", path.display());
435
+        }
436
+
437
+        self.should_quit = true;
438
+    }
439
+
440
+    /// Cancel picker and exit with no output.
441
+    fn cancel_picker(&mut self) {
442
+        // Exit with code 1 to indicate cancellation
443
+        self.should_quit = true;
444
+    }
445
+
306
     /// Run the application event loop.
446
     /// Run the application event loop.
307
     pub fn run(&mut self) -> Result<()> {
447
     pub fn run(&mut self) -> Result<()> {
308
         let mut event_loop = EventLoop::new(&self.window, EventLoopConfig::default())?;
448
         let mut event_loop = EventLoop::new(&self.window, EventLoopConfig::default())?;
@@ -527,21 +667,50 @@ impl App {
527
                 return;
667
                 return;
528
             }
668
             }
529
 
669
 
530
-            // Handle tab bar close button clicks (non-drag)
670
+            // Handle tab bar clicks (non-drag)
531
-            if let Some((tab_index, is_close)) = self.tab_bar.on_click(pos) {
671
+            match self.tab_bar.on_click(pos) {
672
+                TabBarClickResult::Tab(tab_index, is_close) => {
532
                     if is_close {
673
                     if is_close {
533
                         self.close_tab(tab_index);
674
                         self.close_tab(tab_index);
534
                     }
675
                     }
535
                     // Tab selection happens on mouse release if not dragged
676
                     // Tab selection happens on mouse release if not dragged
536
                     return;
677
                     return;
537
                 }
678
                 }
679
+                TabBarClickResult::NewTab => {
680
+                    self.new_tab();
681
+                    return;
682
+                }
683
+                TabBarClickResult::None => {}
684
+            }
538
         }
685
         }
539
 
686
 
540
-        // Check toolbar clicks
687
+        // Check picker toolbar clicks (if in picker mode)
688
+        if let Some(picker_toolbar) = &self.picker_toolbar {
689
+            match picker_toolbar.on_click(pos) {
690
+                PickerToolbarClick::Accept => {
691
+                    self.accept_picker_selection();
692
+                    return;
693
+                }
694
+                PickerToolbarClick::Cancel => {
695
+                    self.cancel_picker();
696
+                    return;
697
+                }
698
+                PickerToolbarClick::None => {}
699
+            }
700
+        } else {
701
+            // Check normal toolbar clicks
541
             if let Some(action) = self.toolbar.on_click(pos) {
702
             if let Some(action) = self.toolbar.on_click(pos) {
542
                 self.handle_toolbar_action(action);
703
                 self.handle_toolbar_action(action);
543
                 return;
704
                 return;
544
             }
705
             }
706
+        }
707
+
708
+        // Check pane toolbar clicks (view mode buttons)
709
+        if let PaneToolbarClick::ViewMode(_mode) = self.root_pane.on_toolbar_click(pos) {
710
+            self.sync_toolbar_view();
711
+            self.update_status_bar();
712
+            return;
713
+        }
545
 
714
 
546
         // Check sidebar clicks - try to start bookmark drag first
715
         // Check sidebar clicks - try to start bookmark drag first
547
         if self.sidebar.start_bookmark_drag(pos) {
716
         if self.sidebar.start_bookmark_drag(pos) {
@@ -888,10 +1057,15 @@ impl App {
888
         }
1057
         }
889
 
1058
 
890
         // Check hover states - only redraw if any changed
1059
         // Check hover states - only redraw if any changed
1060
+        if let Some(picker_toolbar) = &mut self.picker_toolbar {
1061
+            needs_redraw |= picker_toolbar.on_mouse_move(pos);
1062
+        } else {
891
             needs_redraw |= self.toolbar.on_mouse_move(pos);
1063
             needs_redraw |= self.toolbar.on_mouse_move(pos);
1064
+        }
892
         needs_redraw |= self.breadcrumb.on_mouse_move(pos);
1065
         needs_redraw |= self.breadcrumb.on_mouse_move(pos);
893
         needs_redraw |= self.sidebar.on_mouse_move(pos);
1066
         needs_redraw |= self.sidebar.on_mouse_move(pos);
894
         needs_redraw |= self.tab_bar.on_mouse_move(pos);
1067
         needs_redraw |= self.tab_bar.on_mouse_move(pos);
1068
+        needs_redraw |= self.root_pane.on_toolbar_mouse_move(pos);
895
 
1069
 
896
         let mut is_dragging = false;
1070
         let mut is_dragging = false;
897
         let mut selection_count = 0;
1071
         let mut selection_count = 0;
@@ -1003,6 +1177,12 @@ impl App {
1003
             return;
1177
             return;
1004
         }
1178
         }
1005
 
1179
 
1180
+        // Handle Escape in picker mode (cancel)
1181
+        if *key == Key::Escape && self.picker_config.is_picker() {
1182
+            self.cancel_picker();
1183
+            return;
1184
+        }
1185
+
1006
         // F1 toggles help
1186
         // F1 toggles help
1007
         if *key == Key::F1 {
1187
         if *key == Key::F1 {
1008
             self.help_modal.show();
1188
             self.help_modal.show();
@@ -1350,13 +1530,7 @@ impl App {
1350
 
1530
 
1351
     /// Create a new tab in the focused pane.
1531
     /// Create a new tab in the focused pane.
1352
     fn new_tab(&mut self) {
1532
     fn new_tab(&mut self) {
1353
-        let path = if let Some(pane) = self.focused_pane() {
1533
+        let path = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
1354
-            pane.active_tab().map(|t| t.current_path().clone())
1355
-        } else {
1356
-            None
1357
-        };
1358
-
1359
-        let path = path.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")));
1360
 
1534
 
1361
         if let Some(pane) = self.focused_pane_mut() {
1535
         if let Some(pane) = self.focused_pane_mut() {
1362
             pane.add_tab(path);
1536
             pane.add_tab(path);
@@ -1438,17 +1612,21 @@ impl App {
1438
 
1612
 
1439
     /// Split the focused pane horizontally.
1613
     /// Split the focused pane horizontally.
1440
     fn split_horizontal(&mut self) {
1614
     fn split_horizontal(&mut self) {
1441
-        let path = if let Some(pane) = self.focused_pane() {
1615
+        let (path, view_mode) = if let Some(pane) = self.focused_pane() {
1442
-            pane.active_tab().map(|t| t.current_path().clone())
1616
+            if let Some(tab) = pane.active_tab() {
1617
+                (Some(tab.current_path().clone()), Some(tab.view_mode()))
1443
             } else {
1618
             } else {
1444
-            None
1619
+                (None, None)
1620
+            }
1621
+        } else {
1622
+            (None, None)
1445
         };
1623
         };
1446
 
1624
 
1447
         let path = path.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")));
1625
         let path = path.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")));
1448
         let new_id = self.next_pane_id;
1626
         let new_id = self.next_pane_id;
1449
 
1627
 
1450
         if let Some(pane) = self.focused_pane_mut() {
1628
         if let Some(pane) = self.focused_pane_mut() {
1451
-            if pane.split(SplitDirection::Horizontal, path, new_id).is_some() {
1629
+            if pane.split(SplitDirection::Horizontal, path, new_id, view_mode).is_some() {
1452
                 self.next_pane_id += 1;
1630
                 self.next_pane_id += 1;
1453
                 self.focused_pane_id = new_id;
1631
                 self.focused_pane_id = new_id;
1454
             }
1632
             }
@@ -1461,17 +1639,21 @@ impl App {
1461
 
1639
 
1462
     /// Split the focused pane vertically.
1640
     /// Split the focused pane vertically.
1463
     fn split_vertical(&mut self) {
1641
     fn split_vertical(&mut self) {
1464
-        let path = if let Some(pane) = self.focused_pane() {
1642
+        let (path, view_mode) = if let Some(pane) = self.focused_pane() {
1465
-            pane.active_tab().map(|t| t.current_path().clone())
1643
+            if let Some(tab) = pane.active_tab() {
1644
+                (Some(tab.current_path().clone()), Some(tab.view_mode()))
1466
             } else {
1645
             } else {
1467
-            None
1646
+                (None, None)
1647
+            }
1648
+        } else {
1649
+            (None, None)
1468
         };
1650
         };
1469
 
1651
 
1470
         let path = path.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")));
1652
         let path = path.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("/")));
1471
         let new_id = self.next_pane_id;
1653
         let new_id = self.next_pane_id;
1472
 
1654
 
1473
         if let Some(pane) = self.focused_pane_mut() {
1655
         if let Some(pane) = self.focused_pane_mut() {
1474
-            if pane.split(SplitDirection::Vertical, path, new_id).is_some() {
1656
+            if pane.split(SplitDirection::Vertical, path, new_id, view_mode).is_some() {
1475
                 self.next_pane_id += 1;
1657
                 self.next_pane_id += 1;
1476
                 self.focused_pane_id = new_id;
1658
                 self.focused_pane_id = new_id;
1477
             }
1659
             }
@@ -1553,6 +1735,29 @@ impl App {
1553
     /// Enter the selected entry.
1735
     /// Enter the selected entry.
1554
     fn enter_selected(&mut self) {
1736
     fn enter_selected(&mut self) {
1555
         self.status_bar.clear_status_message();
1737
         self.status_bar.clear_status_message();
1738
+
1739
+        // In picker mode, check if we should accept the selection instead of navigating
1740
+        if self.picker_config.is_picker() {
1741
+            if let Some(pane) = self.focused_pane() {
1742
+                if let Some(tab) = pane.active_tab() {
1743
+                    let selected = tab.selected_entries();
1744
+                    if !selected.is_empty() {
1745
+                        // If it's a directory in non-directory mode, navigate into it
1746
+                        if !self.picker_config.mode.is_directory_mode() {
1747
+                            let first = &selected[0];
1748
+                            if first.is_dir() {
1749
+                                // Fall through to normal enter behavior
1750
+                            } else {
1751
+                                // File selected - accept it
1752
+                                self.accept_picker_selection();
1753
+                                return;
1754
+                            }
1755
+                        }
1756
+                    }
1757
+                }
1758
+            }
1759
+        }
1760
+
1556
         if let Some(pane) = self.focused_pane_mut() {
1761
         if let Some(pane) = self.focused_pane_mut() {
1557
             if let Some(tab) = pane.active_tab_mut() {
1762
             if let Some(tab) = pane.active_tab_mut() {
1558
                 tab.enter_selected();
1763
                 tab.enter_selected();
@@ -2962,12 +3167,13 @@ impl App {
2962
             TAB_BAR_HEIGHT,
3167
             TAB_BAR_HEIGHT,
2963
         ));
3168
         ));
2964
 
3169
 
2965
-        self.toolbar.set_bounds(Rect::new(
3170
+        let toolbar_bounds = Rect::new(
2966
             sidebar_w as i32,
3171
             sidebar_w as i32,
2967
             TAB_BAR_HEIGHT as i32,
3172
             TAB_BAR_HEIGHT as i32,
2968
             width - sidebar_w,
3173
             width - sidebar_w,
2969
             TOOLBAR_HEIGHT,
3174
             TOOLBAR_HEIGHT,
2970
-        ));
3175
+        );
3176
+        self.toolbar.set_bounds(toolbar_bounds);
2971
 
3177
 
2972
         let breadcrumb_bounds = Rect::new(
3178
         let breadcrumb_bounds = Rect::new(
2973
             sidebar_w as i32,
3179
             sidebar_w as i32,
@@ -2978,20 +3184,38 @@ impl App {
2978
         self.breadcrumb.set_bounds(breadcrumb_bounds);
3184
         self.breadcrumb.set_bounds(breadcrumb_bounds);
2979
         self.address_bar.set_bounds(breadcrumb_bounds);
3185
         self.address_bar.set_bounds(breadcrumb_bounds);
2980
 
3186
 
3187
+        // Calculate footer height (status bar + picker toolbar if present)
3188
+        let footer_height = if self.picker_toolbar.is_some() {
3189
+            PICKER_TOOLBAR_HEIGHT  // Picker toolbar replaces status bar
3190
+        } else {
3191
+            STATUS_BAR_HEIGHT
3192
+        };
3193
+
2981
         let content_bounds = Rect::new(
3194
         let content_bounds = Rect::new(
2982
             sidebar_w as i32,
3195
             sidebar_w as i32,
2983
             header_height as i32,
3196
             header_height as i32,
2984
             width - sidebar_w,
3197
             width - sidebar_w,
2985
-            height - header_height - STATUS_BAR_HEIGHT,
3198
+            height - header_height - footer_height,
2986
         );
3199
         );
2987
         self.root_pane.set_bounds(content_bounds);
3200
         self.root_pane.set_bounds(content_bounds);
2988
 
3201
 
3202
+        // Update picker toolbar bounds at bottom (if in picker mode)
3203
+        if let Some(ref mut picker_toolbar) = self.picker_toolbar {
3204
+            picker_toolbar.set_bounds(Rect::new(
3205
+                sidebar_w as i32,
3206
+                (height - PICKER_TOOLBAR_HEIGHT) as i32,
3207
+                width - sidebar_w,
3208
+                PICKER_TOOLBAR_HEIGHT,
3209
+            ));
3210
+        } else {
3211
+            // Only show status bar when not in picker mode
2989
             self.status_bar.set_bounds(Rect::new(
3212
             self.status_bar.set_bounds(Rect::new(
2990
                 sidebar_w as i32,
3213
                 sidebar_w as i32,
2991
                 (height - STATUS_BAR_HEIGHT) as i32,
3214
                 (height - STATUS_BAR_HEIGHT) as i32,
2992
                 width - sidebar_w,
3215
                 width - sidebar_w,
2993
                 STATUS_BAR_HEIGHT,
3216
                 STATUS_BAR_HEIGHT,
2994
             ));
3217
             ));
3218
+        }
2995
 
3219
 
2996
         self.help_modal.set_bounds(Rect::new(0, 0, width, height));
3220
         self.help_modal.set_bounds(Rect::new(0, 0, width, height));
2997
         self.confirm_dialog.set_bounds(Rect::new(0, 0, width, height));
3221
         self.confirm_dialog.set_bounds(Rect::new(0, 0, width, height));
@@ -3018,7 +3242,7 @@ impl App {
3018
         // Draw tab bar
3242
         // Draw tab bar
3019
         self.tab_bar.render(&self.renderer)?;
3243
         self.tab_bar.render(&self.renderer)?;
3020
 
3244
 
3021
-        // Update and draw toolbar
3245
+        // Update and draw toolbar (or picker toolbar)
3022
         let (can_back, can_forward) = if let Some(pane) = self.focused_pane() {
3246
         let (can_back, can_forward) = if let Some(pane) = self.focused_pane() {
3023
             if let Some(tab) = pane.active_tab() {
3247
             if let Some(tab) = pane.active_tab() {
3024
                 (tab.can_go_back(), tab.can_go_forward())
3248
                 (tab.can_go_back(), tab.can_go_forward())
@@ -3028,6 +3252,8 @@ impl App {
3028
         } else {
3252
         } else {
3029
             (false, false)
3253
             (false, false)
3030
         };
3254
         };
3255
+
3256
+        // Always render the regular toolbar
3031
         self.toolbar.set_nav_state(can_back, can_forward);
3257
         self.toolbar.set_nav_state(can_back, can_forward);
3032
         self.toolbar.render(&self.renderer)?;
3258
         self.toolbar.render(&self.renderer)?;
3033
 
3259
 
@@ -3051,8 +3277,17 @@ impl App {
3051
         // Draw pane content
3277
         // Draw pane content
3052
         self.root_pane.render(&self.renderer, Some(self.focused_pane_id))?;
3278
         self.root_pane.render(&self.renderer, Some(self.focused_pane_id))?;
3053
 
3279
 
3054
-        // Draw status bar
3280
+        // Draw status bar or picker toolbar at bottom
3281
+        if self.picker_toolbar.is_some() {
3282
+            // Picker mode: draw picker toolbar instead of status bar
3283
+            let has_valid_selection = self.has_valid_picker_selection();
3284
+            let picker_toolbar = self.picker_toolbar.as_mut().unwrap();
3285
+            picker_toolbar.set_accept_enabled(has_valid_selection);
3286
+            picker_toolbar.render(&self.renderer)?;
3287
+        } else {
3288
+            // Normal mode: draw status bar
3055
             self.status_bar.render(&self.renderer)?;
3289
             self.status_bar.render(&self.renderer)?;
3290
+        }
3056
 
3291
 
3057
         // Draw toolbar tooltip overlay (on top of other UI)
3292
         // Draw toolbar tooltip overlay (on top of other UI)
3058
         self.toolbar.render_tooltip_overlay(&self.renderer)?;
3293
         self.toolbar.render_tooltip_overlay(&self.renderer)?;
garfield/src/core/entry.rsmodified
@@ -206,6 +206,71 @@ pub fn sort_entries(entries: &mut [FileEntry], order: SortOrder, direction: Sort
206
     });
206
     });
207
 }
207
 }
208
 
208
 
209
+/// Check if a filename matches a glob pattern.
210
+///
211
+/// Supports simple patterns:
212
+/// - `*.ext` - matches any file ending with `.ext`
213
+/// - `name.*` - matches any file starting with `name.`
214
+/// - `exact` - matches exact filename
215
+pub fn matches_filter(filename: &str, pattern: &str) -> bool {
216
+    let filename_lower = filename.to_lowercase();
217
+    let pattern_lower = pattern.to_lowercase();
218
+
219
+    if pattern_lower == "*" {
220
+        return true;
221
+    }
222
+
223
+    if let Some(suffix) = pattern_lower.strip_prefix("*.") {
224
+        // *.ext pattern
225
+        filename_lower.ends_with(&format!(".{}", suffix))
226
+    } else if let Some(prefix) = pattern_lower.strip_suffix(".*") {
227
+        // name.* pattern
228
+        filename_lower.starts_with(&format!("{}.", prefix))
229
+    } else if pattern_lower.contains('*') {
230
+        // More complex patterns - split by * and check if parts exist in order
231
+        let parts: Vec<&str> = pattern_lower.split('*').collect();
232
+        let mut pos = 0;
233
+        for (i, part) in parts.iter().enumerate() {
234
+            if part.is_empty() {
235
+                continue;
236
+            }
237
+            if i == 0 && !filename_lower.starts_with(part) {
238
+                return false;
239
+            }
240
+            if i == parts.len() - 1 && !filename_lower.ends_with(part) {
241
+                return false;
242
+            }
243
+            if let Some(found_pos) = filename_lower[pos..].find(part) {
244
+                pos += found_pos + part.len();
245
+            } else {
246
+                return false;
247
+            }
248
+        }
249
+        true
250
+    } else {
251
+        // Exact match
252
+        filename_lower == pattern_lower
253
+    }
254
+}
255
+
256
+/// Check if a file entry matches any of the given filter patterns.
257
+/// Directories always match (for navigation).
258
+/// Empty filters match everything.
259
+pub fn matches_any_filter(entry: &FileEntry, filters: &[String]) -> bool {
260
+    // Directories always visible for navigation
261
+    if entry.is_dir() {
262
+        return true;
263
+    }
264
+
265
+    // No filters means show everything
266
+    if filters.is_empty() {
267
+        return true;
268
+    }
269
+
270
+    // Check each filter
271
+    filters.iter().any(|pattern| matches_filter(&entry.name, pattern))
272
+}
273
+
209
 /// Format bytes as human-readable string.
274
 /// Format bytes as human-readable string.
210
 fn format_bytes(bytes: u64) -> String {
275
 fn format_bytes(bytes: u64) -> String {
211
     const KB: u64 = 1024;
276
     const KB: u64 = 1024;
garfield/src/core/mod.rsmodified
@@ -14,7 +14,7 @@ pub mod undo;
14
 
14
 
15
 pub use clipboard::{Clipboard, ClipboardOperation};
15
 pub use clipboard::{Clipboard, ClipboardOperation};
16
 pub use entry::{
16
 pub use entry::{
17
-    read_directory, sort_entries, EntryType, FileEntry, SortDirection, SortOrder,
17
+    matches_any_filter, matches_filter, read_directory, sort_entries, EntryType, FileEntry, SortDirection, SortOrder,
18
 };
18
 };
19
 pub use file_drag::{DragTarget, FileDragController, FileDragState, FlashState};
19
 pub use file_drag::{DragTarget, FileDragController, FileDragState, FlashState};
20
 pub use history::History;
20
 pub use history::History;
garfield/src/main.rsmodified
@@ -3,28 +3,162 @@
3
 mod app;
3
 mod app;
4
 
4
 
5
 use anyhow::Result;
5
 use anyhow::Result;
6
+use clap::Parser;
6
 use std::path::PathBuf;
7
 use std::path::PathBuf;
7
 use tracing_subscriber::{fmt, EnvFilter};
8
 use tracing_subscriber::{fmt, EnvFilter};
8
 
9
 
10
+/// garfield - gar file explorer
11
+#[derive(Parser, Debug)]
12
+#[command(name = "garfield", about = "gar file explorer", version)]
13
+pub struct Args {
14
+    /// Starting directory
15
+    #[arg(value_name = "PATH")]
16
+    pub start_dir: Option<PathBuf>,
17
+
18
+    /// Enable file picker mode
19
+    #[arg(long, short = 'p')]
20
+    pub picker: bool,
21
+
22
+    /// Select directories only (not files), requires --picker
23
+    #[arg(long, short = 'd', requires = "picker")]
24
+    pub directory: bool,
25
+
26
+    /// Allow multiple selection, requires --picker
27
+    #[arg(long, short = 'm', requires = "picker")]
28
+    pub multiple: bool,
29
+
30
+    /// File filter patterns (semicolon-separated, e.g., "*.rs;*.toml")
31
+    #[arg(long, short = 'f', requires = "picker")]
32
+    pub filter: Option<String>,
33
+
34
+    /// Dialog title, requires --picker
35
+    #[arg(long, short = 't', requires = "picker")]
36
+    pub title: Option<String>,
37
+
38
+    /// Custom accept button text (default: "Open")
39
+    #[arg(long, requires = "picker")]
40
+    pub accept_label: Option<String>,
41
+}
42
+
43
+/// Picker mode configuration parsed from CLI args.
44
+#[derive(Debug, Clone)]
45
+pub enum PickerMode {
46
+    /// Normal file browser mode.
47
+    None,
48
+    /// Open file picker.
49
+    OpenFile {
50
+        multiple: bool,
51
+        filters: Vec<String>,
52
+    },
53
+    /// Open directory picker.
54
+    OpenDirectory {
55
+        multiple: bool,
56
+    },
57
+}
58
+
59
+impl PickerMode {
60
+    /// Whether this is a picker mode (not normal browser).
61
+    pub fn is_picker(&self) -> bool {
62
+        !matches!(self, PickerMode::None)
63
+    }
64
+
65
+    /// Whether multiple selection is allowed.
66
+    pub fn allows_multiple(&self) -> bool {
67
+        match self {
68
+            PickerMode::None => true,
69
+            PickerMode::OpenFile { multiple, .. } => *multiple,
70
+            PickerMode::OpenDirectory { multiple } => *multiple,
71
+        }
72
+    }
73
+
74
+    /// Get file filters if any.
75
+    pub fn filters(&self) -> &[String] {
76
+        match self {
77
+            PickerMode::OpenFile { filters, .. } => filters,
78
+            _ => &[],
79
+        }
80
+    }
81
+
82
+    /// Whether we're picking directories only.
83
+    pub fn is_directory_mode(&self) -> bool {
84
+        matches!(self, PickerMode::OpenDirectory { .. })
85
+    }
86
+}
87
+
88
+/// Picker configuration derived from Args.
89
+#[derive(Debug, Clone)]
90
+pub struct PickerConfig {
91
+    /// The picker mode.
92
+    pub mode: PickerMode,
93
+    /// Dialog title.
94
+    pub title: Option<String>,
95
+    /// Accept button label.
96
+    pub accept_label: String,
97
+}
98
+
99
+impl PickerConfig {
100
+    /// Create from command line arguments.
101
+    pub fn from_args(args: &Args) -> Self {
102
+        let mode = if args.picker {
103
+            if args.directory {
104
+                PickerMode::OpenDirectory {
105
+                    multiple: args.multiple,
106
+                }
107
+            } else {
108
+                let filters = args.filter
109
+                    .as_ref()
110
+                    .map(|f| f.split(';').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect())
111
+                    .unwrap_or_default();
112
+                PickerMode::OpenFile {
113
+                    multiple: args.multiple,
114
+                    filters,
115
+                }
116
+            }
117
+        } else {
118
+            PickerMode::None
119
+        };
120
+
121
+        Self {
122
+            mode,
123
+            title: args.title.clone(),
124
+            accept_label: args.accept_label.clone().unwrap_or_else(|| "Open".to_string()),
125
+        }
126
+    }
127
+
128
+    /// Whether this is picker mode.
129
+    pub fn is_picker(&self) -> bool {
130
+        self.mode.is_picker()
131
+    }
132
+}
133
+
9
 fn main() -> Result<()> {
134
 fn main() -> Result<()> {
10
-    // Initialize logging
135
+    // Parse command line arguments first (before logging setup)
136
+    let args = Args::parse();
137
+    let picker_config = PickerConfig::from_args(&args);
138
+
139
+    // Initialize logging - use stderr in picker mode to keep stdout clean for results
11
     let filter = EnvFilter::try_from_default_env()
140
     let filter = EnvFilter::try_from_default_env()
12
         .unwrap_or_else(|_| EnvFilter::new("info"));
141
         .unwrap_or_else(|_| EnvFilter::new("info"));
13
 
142
 
143
+    if picker_config.is_picker() {
144
+        // Picker mode: log to stderr so stdout is clean for path output
145
+        fmt()
146
+            .with_env_filter(filter)
147
+            .with_target(false)
148
+            .with_writer(std::io::stderr)
149
+            .init();
150
+        tracing::info!("Starting garfield in picker mode");
151
+    } else {
152
+        // Normal mode: log to stdout
14
         fmt()
153
         fmt()
15
             .with_env_filter(filter)
154
             .with_env_filter(filter)
16
             .with_target(false)
155
             .with_target(false)
17
             .init();
156
             .init();
18
-
19
         tracing::info!("Starting garfield");
157
         tracing::info!("Starting garfield");
20
-
158
+    }
21
-    // Parse command line arguments (simple for now)
22
-    let start_dir = std::env::args()
23
-        .nth(1)
24
-        .map(PathBuf::from);
25
 
159
 
26
     // Create and run app
160
     // Create and run app
27
-    let mut app = app::App::new(start_dir)?;
161
+    let mut app = app::App::new(args.start_dir, picker_config)?;
28
     app.run()?;
162
     app.run()?;
29
 
163
 
30
     tracing::info!("garfield exiting");
164
     tracing::info!("garfield exiting");
garfield/src/ui/mod.rsmodified
@@ -10,6 +10,7 @@ pub mod grid_view;
10
 pub mod help_modal;
10
 pub mod help_modal;
11
 pub mod list_view;
11
 pub mod list_view;
12
 pub mod pane;
12
 pub mod pane;
13
+pub mod picker_toolbar;
13
 pub mod sidebar;
14
 pub mod sidebar;
14
 pub mod status_bar;
15
 pub mod status_bar;
15
 pub mod tab;
16
 pub mod tab;
@@ -25,9 +26,10 @@ pub use column_view::{ColumnClickResult, ColumnView};
25
 pub use grid_view::{GridView, IconSize};
26
 pub use grid_view::{GridView, IconSize};
26
 pub use help_modal::HelpModal;
27
 pub use help_modal::HelpModal;
27
 pub use list_view::ListView;
28
 pub use list_view::ListView;
28
-pub use pane::Pane;
29
+pub use pane::{Pane, PaneToolbarClick};
30
+pub use picker_toolbar::{PickerToolbar, PickerToolbarClick, PICKER_TOOLBAR_HEIGHT};
29
 pub use sidebar::Sidebar;
31
 pub use sidebar::Sidebar;
30
 pub use status_bar::StatusBar;
32
 pub use status_bar::StatusBar;
31
 pub use tab::{RenameState, Tab, ViewMode};
33
 pub use tab::{RenameState, Tab, ViewMode};
32
-pub use tab_bar::{TabBar, TabInfo, TAB_BAR_HEIGHT};
34
+pub use tab_bar::{TabBar, TabBarClickResult, TabInfo, TAB_BAR_HEIGHT};
33
 pub use toolbar::{Toolbar, ToolbarAction, TOOLBAR_HEIGHT};
35
 pub use toolbar::{Toolbar, ToolbarAction, TOOLBAR_HEIGHT};
garfield/src/ui/pane.rsmodified
@@ -1,8 +1,8 @@
1
 //! Pane management with split support.
1
 //! Pane management with split support.
2
 
2
 
3
-use crate::ui::tab::Tab;
3
+use crate::ui::tab::{Tab, ViewMode};
4
 use gartk_core::{Point, Rect};
4
 use gartk_core::{Point, Rect};
5
-use gartk_render::Renderer;
5
+use gartk_render::{Renderer, TextStyle};
6
 use std::path::PathBuf;
6
 use std::path::PathBuf;
7
 
7
 
8
 /// Split direction for panes.
8
 /// Split direction for panes.
@@ -12,12 +12,27 @@ pub enum SplitDirection {
12
     Vertical,
12
     Vertical,
13
 }
13
 }
14
 
14
 
15
+/// Result of clicking on the pane toolbar.
16
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17
+pub enum PaneToolbarClick {
18
+    /// Set view mode.
19
+    ViewMode(ViewMode),
20
+    /// No action.
21
+    None,
22
+}
23
+
15
 /// Minimum pane size (width or height).
24
 /// Minimum pane size (width or height).
16
 pub const MIN_PANE_SIZE: u32 = 100;
25
 pub const MIN_PANE_SIZE: u32 = 100;
17
 
26
 
18
 /// Divider size for split panes.
27
 /// Divider size for split panes.
19
 const DIVIDER_SIZE: u32 = 4;
28
 const DIVIDER_SIZE: u32 = 4;
20
 
29
 
30
+/// Height of the pane toolbar.
31
+const PANE_TOOLBAR_HEIGHT: u32 = 24;
32
+
33
+/// Width of each view mode button.
34
+const VIEW_BUTTON_WIDTH: u32 = 24;
35
+
21
 /// A pane that can be a leaf (with tabs) or a split (with two child panes).
36
 /// A pane that can be a leaf (with tabs) or a split (with two child panes).
22
 pub enum Pane {
37
 pub enum Pane {
23
     /// Leaf pane containing tabs.
38
     /// Leaf pane containing tabs.
@@ -26,10 +41,12 @@ pub enum Pane {
26
         tabs: Vec<Tab>,
41
         tabs: Vec<Tab>,
27
         /// Active tab index.
42
         /// Active tab index.
28
         active_tab: usize,
43
         active_tab: usize,
29
-        /// Pane bounds.
44
+        /// Pane bounds (full bounds including toolbar).
30
         bounds: Rect,
45
         bounds: Rect,
31
         /// Unique pane ID.
46
         /// Unique pane ID.
32
         id: u32,
47
         id: u32,
48
+        /// Hovered view mode button (0=List, 1=Grid, 2=Columns).
49
+        hovered_view_button: Option<usize>,
33
     },
50
     },
34
     /// Split pane containing two child panes.
51
     /// Split pane containing two child panes.
35
     Split {
52
     Split {
@@ -49,13 +66,39 @@ pub enum Pane {
49
 impl Pane {
66
 impl Pane {
50
     /// Create a new leaf pane with a single tab.
67
     /// Create a new leaf pane with a single tab.
51
     pub fn new_leaf(path: PathBuf, bounds: Rect, id: u32) -> Self {
68
     pub fn new_leaf(path: PathBuf, bounds: Rect, id: u32) -> Self {
52
-        let tab = Tab::new(path, bounds);
69
+        // Content bounds exclude the toolbar
70
+        let content_bounds = Self::content_bounds_from_pane_bounds(bounds);
71
+        let tab = Tab::new(path, content_bounds);
53
         Pane::Leaf {
72
         Pane::Leaf {
54
             tabs: vec![tab],
73
             tabs: vec![tab],
55
             active_tab: 0,
74
             active_tab: 0,
56
             bounds,
75
             bounds,
57
             id,
76
             id,
77
+            hovered_view_button: None,
78
+        }
79
+    }
80
+
81
+    /// Calculate content bounds (for tabs) from pane bounds, accounting for toolbar.
82
+    fn content_bounds_from_pane_bounds(bounds: Rect) -> Rect {
83
+        Rect::new(
84
+            bounds.x,
85
+            bounds.y + PANE_TOOLBAR_HEIGHT as i32,
86
+            bounds.width,
87
+            bounds.height.saturating_sub(PANE_TOOLBAR_HEIGHT),
88
+        )
58
     }
89
     }
90
+
91
+    /// Get the toolbar bounds for a leaf pane.
92
+    fn toolbar_bounds(bounds: Rect) -> Rect {
93
+        Rect::new(bounds.x, bounds.y, bounds.width, PANE_TOOLBAR_HEIGHT)
94
+    }
95
+
96
+    /// Get bounds for a view mode button (0=List, 1=Grid, 2=Columns).
97
+    fn view_button_bounds(bounds: Rect, index: usize) -> Rect {
98
+        let toolbar = Self::toolbar_bounds(bounds);
99
+        // Position buttons on the right side of the toolbar
100
+        let x = toolbar.x + toolbar.width as i32 - ((3 - index as i32) * VIEW_BUTTON_WIDTH as i32) - 4;
101
+        Rect::new(x, toolbar.y + 2, VIEW_BUTTON_WIDTH, PANE_TOOLBAR_HEIGHT - 4)
59
     }
102
     }
60
 
103
 
61
     /// Get pane bounds.
104
     /// Get pane bounds.
@@ -71,8 +114,9 @@ impl Pane {
71
         match self {
114
         match self {
72
             Pane::Leaf { bounds, tabs, .. } => {
115
             Pane::Leaf { bounds, tabs, .. } => {
73
                 *bounds = new_bounds;
116
                 *bounds = new_bounds;
117
+                let content_bounds = Self::content_bounds_from_pane_bounds(new_bounds);
74
                 for tab in tabs {
118
                 for tab in tabs {
75
-                    tab.set_bounds(new_bounds);
119
+                    tab.set_bounds(content_bounds);
76
                 }
120
                 }
77
             }
121
             }
78
             Pane::Split {
122
             Pane::Split {
@@ -368,21 +412,30 @@ impl Pane {
368
 
412
 
369
     /// Split this pane. Only works on leaf panes.
413
     /// Split this pane. Only works on leaf panes.
370
     /// Returns the new pane ID if successful.
414
     /// Returns the new pane ID if successful.
371
-    pub fn split(&mut self, direction: SplitDirection, new_path: PathBuf, new_id: u32) -> Option<u32> {
415
+    /// The new pane will inherit the specified view mode.
416
+    pub fn split(&mut self, direction: SplitDirection, new_path: PathBuf, new_id: u32, view_mode: Option<ViewMode>) -> Option<u32> {
372
         let current_bounds = self.bounds();
417
         let current_bounds = self.bounds();
373
 
418
 
374
         match self {
419
         match self {
375
-            Pane::Leaf { tabs, active_tab, bounds, id } => {
420
+            Pane::Leaf { tabs, active_tab, bounds, id, .. } => {
376
                 // Create new leaf from current state
421
                 // Create new leaf from current state
377
                 let first_pane = Pane::Leaf {
422
                 let first_pane = Pane::Leaf {
378
                     tabs: std::mem::take(tabs),
423
                     tabs: std::mem::take(tabs),
379
                     active_tab: *active_tab,
424
                     active_tab: *active_tab,
380
                     bounds: *bounds,
425
                     bounds: *bounds,
381
                     id: *id,
426
                     id: *id,
427
+                    hovered_view_button: None,
382
                 };
428
                 };
383
 
429
 
384
                 // Create second leaf with new tab
430
                 // Create second leaf with new tab
385
-                let second_pane = Pane::new_leaf(new_path, *bounds, new_id);
431
+                let mut second_pane = Pane::new_leaf(new_path, *bounds, new_id);
432
+
433
+                // Set the view mode on the new pane's tab if specified
434
+                if let (Some(mode), Pane::Leaf { tabs, .. }) = (view_mode, &mut second_pane) {
435
+                    if let Some(tab) = tabs.first_mut() {
436
+                        tab.set_view_mode(mode);
437
+                    }
438
+                }
386
 
439
 
387
                 // Replace self with split
440
                 // Replace self with split
388
                 *self = Pane::Split {
441
                 *self = Pane::Split {
@@ -405,7 +458,11 @@ impl Pane {
405
     /// Render the pane.
458
     /// Render the pane.
406
     pub fn render(&self, renderer: &Renderer, focused_id: Option<u32>) -> anyhow::Result<()> {
459
     pub fn render(&self, renderer: &Renderer, focused_id: Option<u32>) -> anyhow::Result<()> {
407
         match self {
460
         match self {
408
-            Pane::Leaf { tabs, active_tab, bounds, id } => {
461
+            Pane::Leaf { tabs, active_tab, bounds, id, hovered_view_button } => {
462
+                // Render the pane toolbar
463
+                self.render_toolbar(renderer, *bounds, tabs.get(*active_tab), *hovered_view_button)?;
464
+
465
+                // Render the active tab
409
                 if let Some(tab) = tabs.get(*active_tab) {
466
                 if let Some(tab) = tabs.get(*active_tab) {
410
                     tab.render(renderer)?;
467
                     tab.render(renderer)?;
411
                 }
468
                 }
@@ -413,11 +470,11 @@ impl Pane {
413
                 // Draw focus indicator if this pane is focused
470
                 // Draw focus indicator if this pane is focused
414
                 if focused_id == Some(*id) {
471
                 if focused_id == Some(*id) {
415
                     let theme = renderer.theme();
472
                     let theme = renderer.theme();
416
-                    renderer.stroke_rect(*bounds, theme.selection_background, 2.0)?;
473
+                    let content_bounds = Self::content_bounds_from_pane_bounds(*bounds);
474
+                    renderer.stroke_rect(content_bounds, theme.selection_background, 2.0)?;
417
                 }
475
                 }
418
             }
476
             }
419
             Pane::Split {
477
             Pane::Split {
420
-                direction,
421
                 first,
478
                 first,
422
                 second,
479
                 second,
423
                 ..
480
                 ..
@@ -436,6 +493,66 @@ impl Pane {
436
         Ok(())
493
         Ok(())
437
     }
494
     }
438
 
495
 
496
+    /// Render the pane toolbar with view mode buttons.
497
+    fn render_toolbar(&self, renderer: &Renderer, bounds: Rect, active_tab: Option<&Tab>, hovered_button: Option<usize>) -> anyhow::Result<()> {
498
+        let theme = renderer.theme();
499
+        let toolbar_bounds = Self::toolbar_bounds(bounds);
500
+
501
+        // Draw toolbar background
502
+        renderer.fill_rect(toolbar_bounds, theme.item_background.darken(0.02))?;
503
+
504
+        // Draw bottom border
505
+        renderer.line(
506
+            toolbar_bounds.x as f64,
507
+            (toolbar_bounds.y + toolbar_bounds.height as i32) as f64,
508
+            (toolbar_bounds.x + toolbar_bounds.width as i32) as f64,
509
+            (toolbar_bounds.y + toolbar_bounds.height as i32) as f64,
510
+            theme.border.with_alpha(0.3),
511
+            1.0,
512
+        )?;
513
+
514
+        // Get current view mode
515
+        let current_mode = active_tab.map(|t| t.view_mode()).unwrap_or(ViewMode::List);
516
+
517
+        // Draw view mode buttons
518
+        let buttons = [
519
+            (ViewMode::List, "≡"),    // List icon
520
+            (ViewMode::Grid, "⊞"),    // Grid icon
521
+            (ViewMode::Columns, "⫼"), // Columns icon
522
+        ];
523
+
524
+        for (i, (mode, icon)) in buttons.iter().enumerate() {
525
+            let btn_bounds = Self::view_button_bounds(bounds, i);
526
+            let is_active = *mode == current_mode;
527
+            let is_hovered = hovered_button == Some(i);
528
+
529
+            // Button background
530
+            if is_active {
531
+                renderer.fill_rounded_rect(btn_bounds, 3.0, theme.selection_background.with_alpha(0.4))?;
532
+            } else if is_hovered {
533
+                renderer.fill_rounded_rect(btn_bounds, 3.0, theme.item_background)?;
534
+            }
535
+
536
+            // Button icon
537
+            let color = if is_active {
538
+                theme.foreground
539
+            } else if is_hovered {
540
+                theme.foreground.with_alpha(0.8)
541
+            } else {
542
+                theme.foreground.with_alpha(0.5)
543
+            };
544
+
545
+            let text_style = TextStyle::new()
546
+                .font_family(&theme.font_family)
547
+                .font_size(theme.font_size)
548
+                .color(color);
549
+
550
+            renderer.text_in_rect(icon, btn_bounds, &text_style)?;
551
+        }
552
+
553
+        Ok(())
554
+    }
555
+
439
     // === Tab operations (for leaf panes) ===
556
     // === Tab operations (for leaf panes) ===
440
 
557
 
441
     /// Get active tab (for leaf panes).
558
     /// Get active tab (for leaf panes).
@@ -457,12 +574,102 @@ impl Pane {
457
     /// Add a new tab to a leaf pane.
574
     /// Add a new tab to a leaf pane.
458
     pub fn add_tab(&mut self, path: PathBuf) {
575
     pub fn add_tab(&mut self, path: PathBuf) {
459
         if let Pane::Leaf { tabs, active_tab, bounds, .. } = self {
576
         if let Pane::Leaf { tabs, active_tab, bounds, .. } = self {
460
-            let tab = Tab::new(path, *bounds);
577
+            let content_bounds = Self::content_bounds_from_pane_bounds(*bounds);
578
+            let tab = Tab::new(path, content_bounds);
461
             tabs.push(tab);
579
             tabs.push(tab);
462
             *active_tab = tabs.len() - 1;
580
             *active_tab = tabs.len() - 1;
463
         }
581
         }
464
     }
582
     }
465
 
583
 
584
+    /// Handle mouse move on the pane toolbar. Returns true if hover state changed.
585
+    pub fn on_toolbar_mouse_move(&mut self, pos: Point) -> bool {
586
+        match self {
587
+            Pane::Leaf { bounds, hovered_view_button, .. } => {
588
+                let old_hovered = *hovered_view_button;
589
+                *hovered_view_button = None;
590
+
591
+                let toolbar_bounds = Self::toolbar_bounds(*bounds);
592
+                if !toolbar_bounds.contains_point(pos) {
593
+                    return old_hovered != *hovered_view_button;
594
+                }
595
+
596
+                // Check which button is hovered
597
+                for i in 0..3 {
598
+                    let btn_bounds = Self::view_button_bounds(*bounds, i);
599
+                    if btn_bounds.contains_point(pos) {
600
+                        *hovered_view_button = Some(i);
601
+                        break;
602
+                    }
603
+                }
604
+
605
+                old_hovered != *hovered_view_button
606
+            }
607
+            Pane::Split { first, second, .. } => {
608
+                first.on_toolbar_mouse_move(pos) || second.on_toolbar_mouse_move(pos)
609
+            }
610
+        }
611
+    }
612
+
613
+    /// Clear toolbar hover state.
614
+    pub fn clear_toolbar_hover(&mut self) {
615
+        match self {
616
+            Pane::Leaf { hovered_view_button, .. } => {
617
+                *hovered_view_button = None;
618
+            }
619
+            Pane::Split { first, second, .. } => {
620
+                first.clear_toolbar_hover();
621
+                second.clear_toolbar_hover();
622
+            }
623
+        }
624
+    }
625
+
626
+    /// Handle click on the pane toolbar. Returns the action if any.
627
+    pub fn on_toolbar_click(&mut self, pos: Point) -> PaneToolbarClick {
628
+        match self {
629
+            Pane::Leaf { bounds, tabs, active_tab, .. } => {
630
+                let toolbar_bounds = Self::toolbar_bounds(*bounds);
631
+                if !toolbar_bounds.contains_point(pos) {
632
+                    return PaneToolbarClick::None;
633
+                }
634
+
635
+                // Check which button was clicked
636
+                let modes = [ViewMode::List, ViewMode::Grid, ViewMode::Columns];
637
+                for (i, mode) in modes.iter().enumerate() {
638
+                    let btn_bounds = Self::view_button_bounds(*bounds, i);
639
+                    if btn_bounds.contains_point(pos) {
640
+                        // Set the view mode on the active tab
641
+                        if let Some(tab) = tabs.get_mut(*active_tab) {
642
+                            tab.set_view_mode(*mode);
643
+                        }
644
+                        return PaneToolbarClick::ViewMode(*mode);
645
+                    }
646
+                }
647
+
648
+                PaneToolbarClick::None
649
+            }
650
+            Pane::Split { first, second, .. } => {
651
+                let result = first.on_toolbar_click(pos);
652
+                if result != PaneToolbarClick::None {
653
+                    return result;
654
+                }
655
+                second.on_toolbar_click(pos)
656
+            }
657
+        }
658
+    }
659
+
660
+    /// Check if a point is within the toolbar area.
661
+    pub fn is_in_toolbar(&self, pos: Point) -> bool {
662
+        match self {
663
+            Pane::Leaf { bounds, .. } => {
664
+                let toolbar_bounds = Self::toolbar_bounds(*bounds);
665
+                toolbar_bounds.contains_point(pos)
666
+            }
667
+            Pane::Split { first, second, .. } => {
668
+                first.is_in_toolbar(pos) || second.is_in_toolbar(pos)
669
+            }
670
+        }
671
+    }
672
+
466
     /// Close the active tab. Returns true if pane should be removed (no tabs left).
673
     /// Close the active tab. Returns true if pane should be removed (no tabs left).
467
     pub fn close_active_tab(&mut self) -> bool {
674
     pub fn close_active_tab(&mut self) -> bool {
468
         if let Pane::Leaf { tabs, active_tab, .. } = self {
675
         if let Pane::Leaf { tabs, active_tab, .. } = self {
garfield/src/ui/picker_toolbar.rsadded
@@ -0,0 +1,241 @@
1
+//! Picker toolbar component with Accept/Cancel buttons.
2
+//!
3
+//! This toolbar replaces the normal toolbar when garfield runs in picker mode.
4
+
5
+use anyhow::Result;
6
+use gartk_core::{Point, Rect};
7
+use gartk_render::{Renderer, TextStyle};
8
+
9
+/// Height of the picker toolbar (same as normal toolbar).
10
+pub const PICKER_TOOLBAR_HEIGHT: u32 = 36;
11
+
12
+/// Button width.
13
+const BUTTON_WIDTH: u32 = 100;
14
+
15
+/// Button height.
16
+const BUTTON_HEIGHT: u32 = 28;
17
+
18
+/// Picker toolbar click result.
19
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20
+pub enum PickerToolbarClick {
21
+    /// Accept button clicked.
22
+    Accept,
23
+    /// Cancel button clicked.
24
+    Cancel,
25
+    /// Nothing clicked.
26
+    None,
27
+}
28
+
29
+/// Picker toolbar with Accept and Cancel buttons.
30
+pub struct PickerToolbar {
31
+    /// Toolbar bounds.
32
+    bounds: Rect,
33
+    /// Accept button label.
34
+    accept_label: String,
35
+    /// Cancel button label.
36
+    cancel_label: String,
37
+    /// Accept button bounds.
38
+    accept_bounds: Rect,
39
+    /// Cancel button bounds.
40
+    cancel_bounds: Rect,
41
+    /// Hovered button (0 = accept, 1 = cancel).
42
+    hovered: Option<usize>,
43
+    /// Focused button for keyboard navigation (0 = accept, 1 = cancel).
44
+    focused: usize,
45
+    /// Whether accept button is enabled (has valid selection).
46
+    accept_enabled: bool,
47
+    /// Filter description shown in toolbar.
48
+    filter_description: Option<String>,
49
+}
50
+
51
+impl PickerToolbar {
52
+    /// Create a new picker toolbar.
53
+    pub fn new(bounds: Rect, accept_label: String) -> Self {
54
+        let mut toolbar = Self {
55
+            bounds,
56
+            accept_label,
57
+            cancel_label: "Cancel".to_string(),
58
+            accept_bounds: Rect::default(),
59
+            cancel_bounds: Rect::default(),
60
+            hovered: None,
61
+            focused: 0,
62
+            accept_enabled: false,
63
+            filter_description: None,
64
+        };
65
+        toolbar.layout_buttons();
66
+        toolbar
67
+    }
68
+
69
+    /// Set bounds.
70
+    pub fn set_bounds(&mut self, bounds: Rect) {
71
+        self.bounds = bounds;
72
+        self.layout_buttons();
73
+    }
74
+
75
+    /// Layout buttons.
76
+    fn layout_buttons(&mut self) {
77
+        // Buttons are right-aligned
78
+        let padding = 8;
79
+        let button_gap = 12;
80
+
81
+        // Cancel button (rightmost)
82
+        let cancel_x = self.bounds.x + self.bounds.width as i32 - BUTTON_WIDTH as i32 - padding;
83
+        let button_y = self.bounds.y + (self.bounds.height as i32 - BUTTON_HEIGHT as i32) / 2;
84
+        self.cancel_bounds = Rect::new(cancel_x, button_y, BUTTON_WIDTH, BUTTON_HEIGHT);
85
+
86
+        // Accept button (to the left of cancel)
87
+        let accept_x = cancel_x - BUTTON_WIDTH as i32 - button_gap;
88
+        self.accept_bounds = Rect::new(accept_x, button_y, BUTTON_WIDTH, BUTTON_HEIGHT);
89
+    }
90
+
91
+    /// Set whether accept button is enabled.
92
+    pub fn set_accept_enabled(&mut self, enabled: bool) {
93
+        self.accept_enabled = enabled;
94
+    }
95
+
96
+    /// Set filter description shown in toolbar.
97
+    pub fn set_filter_description(&mut self, desc: Option<String>) {
98
+        self.filter_description = desc;
99
+    }
100
+
101
+    /// Handle mouse move. Returns true if hovered state changed.
102
+    pub fn on_mouse_move(&mut self, pos: Point) -> bool {
103
+        if !self.bounds.contains_point(pos) {
104
+            if self.hovered.is_some() {
105
+                self.hovered = None;
106
+                return true;
107
+            }
108
+            return false;
109
+        }
110
+
111
+        let new_hovered = if self.accept_bounds.contains_point(pos) {
112
+            Some(0)
113
+        } else if self.cancel_bounds.contains_point(pos) {
114
+            Some(1)
115
+        } else {
116
+            None
117
+        };
118
+
119
+        let changed = new_hovered != self.hovered;
120
+        self.hovered = new_hovered;
121
+        changed
122
+    }
123
+
124
+    /// Handle click. Returns the action if a button was clicked.
125
+    pub fn on_click(&self, pos: Point) -> PickerToolbarClick {
126
+        if self.accept_bounds.contains_point(pos) && self.accept_enabled {
127
+            PickerToolbarClick::Accept
128
+        } else if self.cancel_bounds.contains_point(pos) {
129
+            PickerToolbarClick::Cancel
130
+        } else {
131
+            PickerToolbarClick::None
132
+        }
133
+    }
134
+
135
+    /// Cycle focus between buttons.
136
+    pub fn cycle_focus(&mut self) {
137
+        self.focused = 1 - self.focused;
138
+    }
139
+
140
+    /// Activate focused button.
141
+    pub fn activate_focused(&self) -> PickerToolbarClick {
142
+        if self.focused == 0 && self.accept_enabled {
143
+            PickerToolbarClick::Accept
144
+        } else if self.focused == 1 {
145
+            PickerToolbarClick::Cancel
146
+        } else {
147
+            PickerToolbarClick::None
148
+        }
149
+    }
150
+
151
+    /// Check if point is within toolbar bounds.
152
+    pub fn contains_point(&self, pos: Point) -> bool {
153
+        self.bounds.contains_point(pos)
154
+    }
155
+
156
+    /// Render the toolbar.
157
+    pub fn render(&self, renderer: &Renderer) -> Result<()> {
158
+        let theme = renderer.theme();
159
+
160
+        // Toolbar background
161
+        renderer.fill_rect(self.bounds, theme.item_background)?;
162
+
163
+        // Filter description (left side)
164
+        if let Some(desc) = &self.filter_description {
165
+            let text_style = TextStyle::new()
166
+                .font_family(&theme.font_family)
167
+                .font_size(theme.font_size - 1.0)
168
+                .color(theme.item_foreground);
169
+
170
+            renderer.text(
171
+                desc,
172
+                (self.bounds.x + 12) as f64,
173
+                (self.bounds.y + (self.bounds.height as i32 - theme.font_size as i32) / 2) as f64,
174
+                &text_style,
175
+            )?;
176
+        }
177
+
178
+        // Accept button
179
+        let accept_hovered = self.hovered == Some(0);
180
+        let accept_focused = self.focused == 0;
181
+        let (accept_bg, accept_fg) = if !self.accept_enabled {
182
+            // Disabled state - use input_background for visibility
183
+            (theme.input_background.with_alpha(0.5), theme.item_foreground.with_alpha(0.5))
184
+        } else if accept_hovered || accept_focused {
185
+            // Active state - use accent color
186
+            (theme.selection_background, theme.foreground)
187
+        } else {
188
+            // Normal state - accent color slightly dimmed
189
+            (theme.selection_background.with_alpha(0.8), theme.foreground)
190
+        };
191
+
192
+        renderer.fill_rounded_rect(self.accept_bounds, 4.0, accept_bg)?;
193
+        if accept_focused && self.accept_enabled {
194
+            renderer.stroke_rounded_rect(self.accept_bounds, 4.0, theme.foreground, 2.0)?;
195
+        }
196
+
197
+        let button_style = TextStyle::new()
198
+            .font_family(&theme.font_family)
199
+            .font_size(theme.font_size)
200
+            .color(accept_fg);
201
+
202
+        let accept_metrics = renderer.measure_text(&self.accept_label, &button_style)?;
203
+        let accept_text_x = self.accept_bounds.x + (self.accept_bounds.width as i32 - accept_metrics.width as i32) / 2;
204
+        let accept_text_y = self.accept_bounds.y + (self.accept_bounds.height as i32 - accept_metrics.height as i32) / 2;
205
+        renderer.text(&self.accept_label, accept_text_x as f64, accept_text_y as f64, &button_style)?;
206
+
207
+        // Cancel button - use input_background for visibility
208
+        let cancel_hovered = self.hovered == Some(1);
209
+        let cancel_focused = self.focused == 1;
210
+        let cancel_bg = if cancel_hovered || cancel_focused {
211
+            theme.item_hover_background
212
+        } else {
213
+            theme.input_background
214
+        };
215
+
216
+        renderer.fill_rounded_rect(self.cancel_bounds, 4.0, cancel_bg)?;
217
+        if cancel_focused {
218
+            renderer.stroke_rounded_rect(self.cancel_bounds, 4.0, theme.foreground, 2.0)?;
219
+        }
220
+        renderer.stroke_rounded_rect(self.cancel_bounds, 4.0, theme.border, 1.0)?;
221
+
222
+        let cancel_style = TextStyle::new()
223
+            .font_family(&theme.font_family)
224
+            .font_size(theme.font_size)
225
+            .color(theme.foreground);
226
+
227
+        let cancel_metrics = renderer.measure_text(&self.cancel_label, &cancel_style)?;
228
+        let cancel_text_x = self.cancel_bounds.x + (self.cancel_bounds.width as i32 - cancel_metrics.width as i32) / 2;
229
+        let cancel_text_y = self.cancel_bounds.y + (self.cancel_bounds.height as i32 - cancel_metrics.height as i32) / 2;
230
+        renderer.text(&self.cancel_label, cancel_text_x as f64, cancel_text_y as f64, &cancel_style)?;
231
+
232
+        // Bottom border
233
+        let border_y = self.bounds.y + self.bounds.height as i32 - 1;
234
+        renderer.fill_rect(
235
+            Rect::new(self.bounds.x, border_y, self.bounds.width, 1),
236
+            theme.border,
237
+        )?;
238
+
239
+        Ok(())
240
+    }
241
+}
garfield/src/ui/tab.rsmodified
@@ -255,13 +255,18 @@ impl Tab {
255
         }
255
         }
256
     }
256
     }
257
 
257
 
258
-    /// Get paths of all selected entries.
258
+    /// Get all selected entries.
259
-    pub fn selected_paths(&self) -> Vec<std::path::PathBuf> {
259
+    pub fn selected_entries(&self) -> Vec<&FileEntry> {
260
         match self.view_mode {
260
         match self.view_mode {
261
-            ViewMode::List => self.list_view.selected_entries().iter().map(|e| e.path.clone()).collect(),
261
+            ViewMode::List => self.list_view.selected_entries(),
262
-            ViewMode::Grid => self.grid_view.selected_entries().iter().map(|e| e.path.clone()).collect(),
262
+            ViewMode::Grid => self.grid_view.selected_entries(),
263
-            ViewMode::Columns => self.column_view.selected_entries().iter().map(|e| e.path.clone()).collect(),
263
+            ViewMode::Columns => self.column_view.selected_entries(),
264
+        }
264
     }
265
     }
266
+
267
+    /// Get paths of all selected entries.
268
+    pub fn selected_paths(&self) -> Vec<std::path::PathBuf> {
269
+        self.selected_entries().iter().map(|e| e.path.clone()).collect()
265
     }
270
     }
266
 
271
 
267
     /// Check if a path is in the current selection.
272
     /// Check if a path is in the current selection.
garfield/src/ui/tab_bar.rsmodified
@@ -18,6 +18,20 @@ const MAX_TAB_WIDTH: u32 = 200;
18
 /// Padding inside tabs.
18
 /// Padding inside tabs.
19
 const TAB_PADDING: u32 = 12;
19
 const TAB_PADDING: u32 = 12;
20
 
20
 
21
+/// Width of the new tab button.
22
+const NEW_TAB_BUTTON_WIDTH: u32 = 32;
23
+
24
+/// Result of clicking on the tab bar.
25
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26
+pub enum TabBarClickResult {
27
+    /// Clicked on a tab (index, is_close_button).
28
+    Tab(usize, bool),
29
+    /// Clicked on the new tab button.
30
+    NewTab,
31
+    /// No click target.
32
+    None,
33
+}
34
+
21
 /// Information about a tab for rendering.
35
 /// Information about a tab for rendering.
22
 #[derive(Clone)]
36
 #[derive(Clone)]
23
 pub struct TabInfo {
37
 pub struct TabInfo {
@@ -39,8 +53,12 @@ pub struct TabBar {
39
     hovered_tab: Option<usize>,
53
     hovered_tab: Option<usize>,
40
     /// Hovered close button index.
54
     /// Hovered close button index.
41
     hovered_close: Option<usize>,
55
     hovered_close: Option<usize>,
56
+    /// Hovered new tab button.
57
+    hovered_new_tab: bool,
42
     /// Cached tab bounds.
58
     /// Cached tab bounds.
43
     tab_bounds: Vec<Rect>,
59
     tab_bounds: Vec<Rect>,
60
+    /// Bounds of the new tab button.
61
+    new_tab_bounds: Option<Rect>,
44
     /// Tab being dragged (index).
62
     /// Tab being dragged (index).
45
     dragging_tab: Option<usize>,
63
     dragging_tab: Option<usize>,
46
     /// Drag start position.
64
     /// Drag start position.
@@ -60,7 +78,9 @@ impl TabBar {
60
             active_index: 0,
78
             active_index: 0,
61
             hovered_tab: None,
79
             hovered_tab: None,
62
             hovered_close: None,
80
             hovered_close: None,
81
+            hovered_new_tab: false,
63
             tab_bounds: Vec::new(),
82
             tab_bounds: Vec::new(),
83
+            new_tab_bounds: None,
64
             dragging_tab: None,
84
             dragging_tab: None,
65
             drag_start: None,
85
             drag_start: None,
66
             drag_active: false,
86
             drag_active: false,
@@ -94,12 +114,22 @@ impl TabBar {
94
     /// Recalculate tab bounds based on number of tabs.
114
     /// Recalculate tab bounds based on number of tabs.
95
     fn recalculate_tab_bounds(&mut self) {
115
     fn recalculate_tab_bounds(&mut self) {
96
         self.tab_bounds.clear();
116
         self.tab_bounds.clear();
117
+        self.new_tab_bounds = None;
118
+
119
+        // Reserve space for the new tab button
120
+        let available_width = self.bounds.width.saturating_sub(NEW_TAB_BUTTON_WIDTH + 4);
97
 
121
 
98
         if self.tabs.is_empty() {
122
         if self.tabs.is_empty() {
123
+            // Even with no tabs, show the + button
124
+            self.new_tab_bounds = Some(Rect::new(
125
+                self.bounds.x + 4,
126
+                self.bounds.y + 4,
127
+                NEW_TAB_BUTTON_WIDTH,
128
+                TAB_BAR_HEIGHT - 8,
129
+            ));
99
             return;
130
             return;
100
         }
131
         }
101
 
132
 
102
-        let available_width = self.bounds.width;
103
         let tab_count = self.tabs.len() as u32;
133
         let tab_count = self.tabs.len() as u32;
104
 
134
 
105
         // Calculate tab width (equal distribution, clamped)
135
         // Calculate tab width (equal distribution, clamped)
@@ -111,6 +141,14 @@ impl TabBar {
111
             self.tab_bounds.push(Rect::new(x, self.bounds.y, tab_width, TAB_BAR_HEIGHT));
141
             self.tab_bounds.push(Rect::new(x, self.bounds.y, tab_width, TAB_BAR_HEIGHT));
112
             x += tab_width as i32;
142
             x += tab_width as i32;
113
         }
143
         }
144
+
145
+        // Position the new tab button after the last tab
146
+        self.new_tab_bounds = Some(Rect::new(
147
+            x + 4,
148
+            self.bounds.y + 4,
149
+            NEW_TAB_BUTTON_WIDTH,
150
+            TAB_BAR_HEIGHT - 8,
151
+        ));
114
     }
152
     }
115
 
153
 
116
     /// Get the close button bounds for a tab.
154
     /// Get the close button bounds for a tab.
@@ -129,12 +167,26 @@ impl TabBar {
129
     pub fn on_mouse_move(&mut self, pos: Point) -> bool {
167
     pub fn on_mouse_move(&mut self, pos: Point) -> bool {
130
         let old_hovered_tab = self.hovered_tab;
168
         let old_hovered_tab = self.hovered_tab;
131
         let old_hovered_close = self.hovered_close;
169
         let old_hovered_close = self.hovered_close;
170
+        let old_hovered_new_tab = self.hovered_new_tab;
132
 
171
 
133
         self.hovered_tab = None;
172
         self.hovered_tab = None;
134
         self.hovered_close = None;
173
         self.hovered_close = None;
174
+        self.hovered_new_tab = false;
135
 
175
 
136
         if !self.bounds.contains_point(pos) {
176
         if !self.bounds.contains_point(pos) {
137
-            return self.hovered_tab != old_hovered_tab || self.hovered_close != old_hovered_close;
177
+            return self.hovered_tab != old_hovered_tab
178
+                || self.hovered_close != old_hovered_close
179
+                || self.hovered_new_tab != old_hovered_new_tab;
180
+        }
181
+
182
+        // Check new tab button
183
+        if let Some(new_tab_bounds) = self.new_tab_bounds {
184
+            if new_tab_bounds.contains_point(pos) {
185
+                self.hovered_new_tab = true;
186
+                return self.hovered_tab != old_hovered_tab
187
+                    || self.hovered_close != old_hovered_close
188
+                    || self.hovered_new_tab != old_hovered_new_tab;
189
+            }
138
         }
190
         }
139
 
191
 
140
         for (i, tab_bounds) in self.tab_bounds.iter().enumerate() {
192
         for (i, tab_bounds) in self.tab_bounds.iter().enumerate() {
@@ -143,21 +195,34 @@ impl TabBar {
143
                 if let Some(close_bounds) = self.close_button_bounds(i) {
195
                 if let Some(close_bounds) = self.close_button_bounds(i) {
144
                     if close_bounds.contains_point(pos) {
196
                     if close_bounds.contains_point(pos) {
145
                         self.hovered_close = Some(i);
197
                         self.hovered_close = Some(i);
146
-                        return self.hovered_tab != old_hovered_tab || self.hovered_close != old_hovered_close;
198
+                        return self.hovered_tab != old_hovered_tab
199
+                            || self.hovered_close != old_hovered_close
200
+                            || self.hovered_new_tab != old_hovered_new_tab;
147
                     }
201
                     }
148
                 }
202
                 }
149
                 self.hovered_tab = Some(i);
203
                 self.hovered_tab = Some(i);
150
-                return self.hovered_tab != old_hovered_tab || self.hovered_close != old_hovered_close;
204
+                return self.hovered_tab != old_hovered_tab
205
+                    || self.hovered_close != old_hovered_close
206
+                    || self.hovered_new_tab != old_hovered_new_tab;
151
             }
207
             }
152
         }
208
         }
153
 
209
 
154
-        self.hovered_tab != old_hovered_tab || self.hovered_close != old_hovered_close
210
+        self.hovered_tab != old_hovered_tab
211
+            || self.hovered_close != old_hovered_close
212
+            || self.hovered_new_tab != old_hovered_new_tab
155
     }
213
     }
156
 
214
 
157
-    /// Handle click. Returns (clicked_tab, is_close_button).
215
+    /// Handle click. Returns the click result.
158
-    pub fn on_click(&self, pos: Point) -> Option<(usize, bool)> {
216
+    pub fn on_click(&self, pos: Point) -> TabBarClickResult {
159
         if !self.bounds.contains_point(pos) {
217
         if !self.bounds.contains_point(pos) {
160
-            return None;
218
+            return TabBarClickResult::None;
219
+        }
220
+
221
+        // Check new tab button
222
+        if let Some(new_tab_bounds) = self.new_tab_bounds {
223
+            if new_tab_bounds.contains_point(pos) {
224
+                return TabBarClickResult::NewTab;
225
+            }
161
         }
226
         }
162
 
227
 
163
         for (i, tab_bounds) in self.tab_bounds.iter().enumerate() {
228
         for (i, tab_bounds) in self.tab_bounds.iter().enumerate() {
@@ -165,20 +230,21 @@ impl TabBar {
165
                 // Check if clicking close button
230
                 // Check if clicking close button
166
                 if let Some(close_bounds) = self.close_button_bounds(i) {
231
                 if let Some(close_bounds) = self.close_button_bounds(i) {
167
                     if close_bounds.contains_point(pos) {
232
                     if close_bounds.contains_point(pos) {
168
-                        return Some((i, true));
233
+                        return TabBarClickResult::Tab(i, true);
169
                     }
234
                     }
170
                 }
235
                 }
171
-                return Some((i, false));
236
+                return TabBarClickResult::Tab(i, false);
172
             }
237
             }
173
         }
238
         }
174
 
239
 
175
-        None
240
+        TabBarClickResult::None
176
     }
241
     }
177
 
242
 
178
     /// Clear hover state.
243
     /// Clear hover state.
179
     pub fn clear_hover(&mut self) {
244
     pub fn clear_hover(&mut self) {
180
         self.hovered_tab = None;
245
         self.hovered_tab = None;
181
         self.hovered_close = None;
246
         self.hovered_close = None;
247
+        self.hovered_new_tab = false;
182
     }
248
     }
183
 
249
 
184
     /// Start potential tab drag.
250
     /// Start potential tab drag.
@@ -457,6 +523,43 @@ impl TabBar {
457
             }
523
             }
458
         }
524
         }
459
 
525
 
526
+        // Draw new tab button
527
+        if let Some(bounds) = self.new_tab_bounds {
528
+            // Background on hover
529
+            if self.hovered_new_tab {
530
+                renderer.fill_rounded_rect(bounds, 4.0, theme.item_background)?;
531
+            }
532
+
533
+            // Draw + symbol
534
+            let cx = bounds.x + bounds.width as i32 / 2;
535
+            let cy = bounds.y + bounds.height as i32 / 2;
536
+            let size = 6;
537
+            let color = if self.hovered_new_tab {
538
+                theme.foreground
539
+            } else {
540
+                theme.foreground.with_alpha(0.6)
541
+            };
542
+
543
+            // Horizontal line
544
+            renderer.line(
545
+                (cx - size) as f64,
546
+                cy as f64,
547
+                (cx + size) as f64,
548
+                cy as f64,
549
+                color,
550
+                1.5,
551
+            )?;
552
+            // Vertical line
553
+            renderer.line(
554
+                cx as f64,
555
+                (cy - size) as f64,
556
+                cx as f64,
557
+                (cy + size) as f64,
558
+                color,
559
+                1.5,
560
+            )?;
561
+        }
562
+
460
         Ok(())
563
         Ok(())
461
     }
564
     }
462
 }
565
 }